//! Offline verification primitives. Passing these checks does not authorize installation or execution. use crate::workspace::{hash, HostError, Result}; use base64::{engine::general_purpose::STANDARD, Engine}; use ed25519_dalek::{Signature, VerifyingKey}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ collections::{BTreeMap, BTreeSet}, io::{Cursor, Read}, }; use unicode_casefold::UnicodeCaseFold; use unicode_normalization::UnicodeNormalization; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct Release { pub schema_version: u32, pub namespace: String, pub package_id: String, #[serde(rename = "type")] pub kind: String, pub version: String, pub name: String, pub author_id: String, pub license: String, pub description: String, pub sha256: String, pub size: u64, pub platforms: Vec, pub architectures: Vec, pub min_app_version: String, pub max_app_version: Option, pub dependencies: BTreeMap, pub permissions: Vec, pub changelog: String, pub published_at: String, pub key_id: String, pub signature: String, } fn bounded(s: &str, min: usize, max: usize) -> bool { (min..=max).contains(&s.chars().count()) } fn identity(s: &str) -> bool { (2..=64).contains(&s.len()) && s.as_bytes()[0].is_ascii_alphanumeric() && s.bytes() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') } fn version(s: &str) -> bool { if s.len() > 80 { return false; } let (core, pre) = s .split_once('-') .map(|(a, b)| (a, Some(b))) .unwrap_or((s, None)); let parts: Vec<_> = core.split('.').collect(); parts.len() == 3 && parts .iter() .all(|v| !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit())) && pre.is_none_or(|v| { !v.is_empty() && v.bytes() .all(|b| b.is_ascii_alphanumeric() || b".-".contains(&b)) }) } impl Release { /// Full offline package check. Online revocation freshness and runtime permissions remain Host responsibilities. pub fn verify_package( &self, pinned: &[u8; 32], key_id: &str, namespace: &str, revoked: bool, withdrawn: bool, archive: &[u8], ) -> Result<(Inventory, Value)> { self.verify(pinned, key_id, namespace, revoked, withdrawn, archive)?; let inventory = inspect(self, archive)?; let manifest = crate::extension_manifest::read(self, archive, &inventory)?; Ok((inventory, manifest)) } pub fn validate(&self) -> Result<()> { let valid = self.schema_version == 1 && identity(&self.namespace) && identity(&self.package_id) && matches!( self.kind.as_str(), "theme" | "skill" | "plugin" | "mcp" | "persona" | "template" | "model" ) && version(&self.version) && bounded(&self.name, 1, 120) && bounded(&self.author_id, 1, 80) && bounded(&self.license, 1, 80) && !matches!( self.license.to_lowercase().as_str(), "unknown" | "none" | "unlicensed" | "tbd" ) && bounded(&self.description, 0, 10000) && bounded(&self.changelog, 0, 10000) && self.sha256.len() == 64 && self .sha256 .bytes() .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) && (1..=10 * 1024 * 1024).contains(&self.size) && self.platforms.len() <= 12 && self.architectures.len() <= 12 && self .platforms .iter() .chain(&self.architectures) .all(|s| bounded(s, 1, 80)) && version(&self.min_app_version) && self.max_app_version.as_ref().is_none_or(|s| version(s)) && self.dependencies.len() <= 64 && self .dependencies .iter() .all(|(k, v)| bounded(k, 1, 160) && bounded(v, 1, 256)) && self.permissions.len() <= 64 && self.permissions.iter().all(|v| bounded(v, 1, 256)) && bounded(&self.published_at, 0, 40) && bounded(&self.key_id, 1, 80) && self .key_id .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-') && self.signature.len() <= 128; if valid { Ok(()) } else { Err(HostError::new("EXTENSION_RELEASE_INVALID")) } } pub fn signed_payload(&self) -> Result> { self.validate()?; let mut value = serde_json::to_value(self).map_err(|_| HostError::new("EXTENSION_RELEASE_INVALID"))?; value.as_object_mut().unwrap().remove("signature"); fn canonical(v: &Value) -> String { match v { Value::Object(map) => { let sorted: BTreeMap<_, _> = map.iter().collect(); format!( "{{{}}}", sorted .iter() .map(|(k, v)| format!( "{}:{}", serde_json::to_string(k).unwrap(), canonical(v) )) .collect::>() .join(",") ) } Value::Array(items) => format!( "[{}]", items.iter().map(canonical).collect::>().join(",") ), _ => v.to_string(), } } Ok(canonical(&value).into_bytes()) } /// `pinned` must come from the Host trust store, never from the archive or a WebView assertion. pub fn verify( &self, pinned: &[u8; 32], key_id: &str, namespace: &str, revoked: bool, withdrawn: bool, archive: &[u8], ) -> Result<()> { self.validate()?; if revoked || withdrawn { return Err(HostError::new("EXTENSION_REVOKED")); } if key_id != self.key_id || namespace != self.namespace { return Err(HostError::new("EXTENSION_SIGNER_MISMATCH")); } let bytes = STANDARD .decode(&self.signature) .map_err(|_| HostError::new("EXTENSION_SIGNATURE_INVALID"))?; let signature = Signature::from_slice(&bytes) .map_err(|_| HostError::new("EXTENSION_SIGNATURE_INVALID"))?; let key = VerifyingKey::from_bytes(pinned) .map_err(|_| HostError::new("EXTENSION_SIGNATURE_INVALID"))?; key.verify_strict(&self.signed_payload()?, &signature) .map_err(|_| HostError::new("EXTENSION_SIGNATURE_INVALID"))?; if archive.len() as u64 != self.size || hash(archive) != self.sha256 { return Err(HostError::new("EXTENSION_ARCHIVE_MISMATCH")); } Ok(()) } } #[derive(Debug)] pub struct Inventory { pub files: BTreeMap, pub expanded_size: u64, pub manifest: String, } // ZipArchive stores names in a map and can hide duplicate central entries. Inspect the // bounded central directory before handing the archive to its decompressor. fn directory(bytes: &[u8], max_entries: usize) -> Result<()> { let bad = || HostError::new("EXTENSION_ZIP_INVALID"); let u16at = |p: usize| -> Result { Ok(u16::from_le_bytes(bytes.get(p..p + 2).ok_or_else(bad)?.try_into().unwrap()) as usize) }; let u32at = |p: usize| -> Result { Ok(u32::from_le_bytes(bytes.get(p..p + 4).ok_or_else(bad)?.try_into().unwrap()) as usize) }; let end = (bytes.len().saturating_sub(65557)..bytes.len().saturating_sub(21)) .rev() .find(|p| { bytes.get(*p..*p + 4) == Some(b"PK\x05\x06") && u16at(*p + 20).is_ok_and(|n| *p + 22 + n == bytes.len()) }) .ok_or_else(bad)?; let count = u16at(end + 10)?; if u16at(end + 4)? != 0 || u16at(end + 6)? != 0 || u16at(end + 8)? != count || count == 0 || count > max_entries { return Err(HostError::new("EXTENSION_ZIP_LIMIT")); } let mut pos = u32at(end + 16)?; let size = u32at(end + 12)?; if pos.checked_add(size) != Some(end) { return Err(bad()); } let start = pos; let mut raw_names = BTreeSet::new(); let mut ranges = Vec::new(); for _ in 0..count { if bytes.get(pos..pos + 4) != Some(b"PK\x01\x02") { return Err(bad()); } let length = u16at(pos + 28)?; let extra = u16at(pos + 30)?; let comment = u16at(pos + 32)?; let next = pos .checked_add(46 + length + extra + comment) .filter(|p| *p <= end) .ok_or_else(bad)?; let name = bytes.get(pos + 46..pos + 46 + length).ok_or_else(bad)?; if !raw_names.insert(name) { return Err(HostError::new("EXTENSION_ZIP_DUPLICATE")); } let local = u32at(pos + 42)?; if local >= start || bytes.get(local..local + 4) != Some(b"PK\x03\x04") || u16at(pos + 34)? != 0 || u16at(local + 6)? != u16at(pos + 8)? || u16at(local + 8)? != u16at(pos + 10)? { return Err(bad()); } let local_name = u16at(local + 26)?; let local_extra = u16at(local + 28)?; if bytes.get(local + 30..local + 30 + local_name) != Some(name) { return Err(bad()); } let data_end = local .checked_add(30 + local_name + local_extra) .and_then(|p| p.checked_add(u32at(pos + 20).ok()?)) .filter(|p| *p <= start) .ok_or_else(bad)?; ranges.push((local, data_end)); pos = next; } if pos != end { return Err(bad()); } ranges.sort_unstable(); if ranges.windows(2).any(|pair| pair[0].1 > pair[1].0) { return Err(bad()); } Ok(()) } fn path(name: &str) -> Result { if name.is_empty() || name.len() > 1024 || name.nfc().collect::() != name || name .chars() .any(|v| v.is_control() || "\\:<>|?*\"".contains(v)) { return Err(HostError::new("EXTENSION_ZIP_PATH")); } let name = name.strip_suffix('/').unwrap_or(name); for part in name.split('/') { let stem = part.split('.').next().unwrap_or("").to_uppercase(); if part.is_empty() || matches!(part, "." | "..") || part.ends_with(['.', ' ']) || matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") || ["COM", "LPT"].iter().any(|p| { stem.strip_prefix(p) .is_some_and(|s| s.len() == 1 && b"123456789".contains(&s.as_bytes()[0])) }) { return Err(HostError::new("EXTENSION_ZIP_PATH")); } } Ok(name.to_owned()) } /// Checks all bytes (including CRC), without creating any package files. /// Type-specific manifest schema/identity validation must follow before staging. pub fn inspect(release: &Release, bytes: &[u8]) -> Result { release.validate()?; if bytes.len() as u64 != release.size || hash(bytes) != release.sha256 { return Err(HostError::new("EXTENSION_ARCHIVE_MISMATCH")); } let (compressed, expanded, entries) = if release.kind == "theme" { (5 * 1024 * 1024, 10 * 1024 * 1024, 100) } else { (10 * 1024 * 1024, 50 * 1024 * 1024, 2048) }; if bytes.len() > compressed { return Err(HostError::new("EXTENSION_ZIP_LIMIT")); } directory(bytes, entries)?; let mut archive = zip::ZipArchive::new(Cursor::new(bytes)) .map_err(|_| HostError::new("EXTENSION_ZIP_INVALID"))?; if archive.len() > entries { return Err(HostError::new("EXTENSION_ZIP_LIMIT")); } let mut names = BTreeMap::new(); let mut total = 0u64; let mut files = BTreeMap::new(); for index in 0..archive.len() { let mut file = archive .by_index(index) .map_err(|_| HostError::new("EXTENSION_ZIP_INVALID"))?; let name = path(file.name())?; // Include current Rust Unicode case mappings as well as full multi-character folds. let folded: String = name .case_fold() .flat_map(char::to_uppercase) .flat_map(char::to_lowercase) .nfc() .collect(); if names.insert(folded, file.is_dir()).is_some() { return Err(HostError::new("EXTENSION_ZIP_DUPLICATE")); } let mode = file.unix_mode().unwrap_or(0) & 0o170000; if file.encrypted() || !matches!( file.compression(), zip::CompressionMethod::Stored | zip::CompressionMethod::Deflated ) || !matches!(mode, 0 | 0o100000 | 0o040000) || (mode == 0o040000 && !file.is_dir()) || (mode == 0o100000 && file.is_dir()) { return Err(HostError::new("EXTENSION_ZIP_UNSAFE")); } total = total .checked_add(file.size()) .ok_or_else(|| HostError::new("EXTENSION_ZIP_LIMIT"))?; if total > expanded { return Err(HostError::new("EXTENSION_ZIP_LIMIT")); } if file.is_dir() { if file.size() != 0 { return Err(HostError::new("EXTENSION_ZIP_UNSAFE")); } continue; } use sha2::{Digest, Sha256}; let expected = file.size(); let mut count = 0u64; let mut digest = Sha256::new(); let mut buffer = [0u8; 65536]; loop { let read = file .read(&mut buffer) .map_err(|_| HostError::new("EXTENSION_ZIP_INVALID"))?; if read == 0 { break; } count += read as u64; if count > expected { return Err(HostError::new("EXTENSION_ZIP_LIMIT")); } digest.update(&buffer[..read]); } if count != expected { return Err(HostError::new("EXTENSION_ZIP_INVALID")); } files.insert(name, format!("{:x}", digest.finalize())); } for (name, is_dir) in &names { if !is_dir && names .range(format!("{name}/")..) .next() .is_some_and(|(next, _)| next.starts_with(&format!("{name}/"))) { return Err(HostError::new("EXTENSION_ZIP_PREFIX")); } } let required = match release.kind.as_str() { "theme" => "theme.yaml", "skill" => "skill.yaml", "plugin" => "plugin.yaml", "mcp" => "mcp.json", "persona" => "persona.json", "template" => "template.json", _ => "model.json", }; let candidates: Vec<_> = files .keys() .filter(|name| name.as_str() == required || name.ends_with(&format!("/{required}"))) .collect(); if candidates.len() != 1 { return Err(HostError::new("EXTENSION_MANIFEST_INVALID")); } Ok(Inventory { manifest: candidates[0].clone(), files, expanded_size: total, }) } #[cfg(test)] mod tests { use super::*; use std::io::Write; fn fixture() -> (Release, Vec, [u8; 32]) { let mut data: Value = serde_json::from_str(include_str!( "../../src/services/fixtures/community-python-vector.json" )) .unwrap(); for field in ["release_id", "withdrawn", "download_path"] { data["release"].as_object_mut().unwrap().remove(field); } ( serde_json::from_value(data["release"].clone()).unwrap(), STANDARD .decode(data["archive_base64"].as_str().unwrap()) .unwrap(), STANDARD .decode(data["key"]["public_key"].as_str().unwrap()) .unwrap() .try_into() .unwrap(), ) } fn archive(items: &[(&str, &[u8])], method: zip::CompressionMethod) -> Vec { let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); for (name, body) in items { writer .start_file( *name, zip::write::SimpleFileOptions::default() .compression_method(method) .unix_permissions(0o644), ) .unwrap(); writer.write_all(body).unwrap(); } writer.finish().unwrap().into_inner() } fn check(bytes: &[u8], kind: &str) -> Result { let mut r = fixture().0; r.kind = kind.into(); r.size = bytes.len() as u64; r.sha256 = hash(bytes); inspect(&r, bytes) } #[test] fn python_signature_binds_all_metadata_archive_and_signer() { let (release, bytes, key) = fixture(); release .verify(&key, "test-key", "examples", false, false, &bytes) .unwrap(); let inventory = inspect(&release, &bytes).unwrap(); assert_eq!(inventory.manifest, "persona.json"); let (_, manifest) = release .verify_package(&key, "test-key", "examples", false, false, &bytes) .unwrap(); assert!(manifest["system_prompt"].is_string()); let value = serde_json::to_value(&release).unwrap(); for field in value .as_object() .unwrap() .keys() .filter(|k| k.as_str() != "signature") { let mut changed = value.clone(); match &mut changed[field] { Value::String(v) => v.push('x'), Value::Number(v) => *v = serde_json::Number::from(v.as_u64().unwrap() + 1), Value::Array(v) => v.push(Value::String("tampered".into())), Value::Object(v) => { v.insert("tampered".into(), Value::String("1.0.0".into())); } Value::Null => changed[field] = Value::String("9.0.0".into()), _ => panic!(), } let changed: Release = serde_json::from_value(changed).unwrap(); assert!( changed .verify(&key, "test-key", "examples", false, false, &bytes) .is_err(), "unsigned field {field}" ); } for (revoked, withdrawn) in [(true, false), (false, true)] { assert_eq!( release .verify(&key, "test-key", "examples", revoked, withdrawn, &bytes) .unwrap_err() .code, "EXTENSION_REVOKED" ); } assert!(release .verify(&[0; 32], "test-key", "examples", false, false, &bytes) .is_err()); assert!(release .verify(&key, "other", "examples", false, false, &bytes) .is_err()); let mut changed = bytes.clone(); changed[0] ^= 1; assert!(release .verify(&key, "test-key", "examples", false, false, &changed) .is_err()); } #[test] fn zip_rejects_traversal_aliases_links_duplicates_and_corrupt_local_headers() { for name in [ "../outside", "/absolute", "a/../outside", "a\\b", "C:ads", "CON.txt", "a./b", "a /b", "a//b", "a\u{0001}b", "a\u{0301}.txt", ] { let bytes = archive( &[("persona.json", b"{}"), (name, b"outside")], zip::CompressionMethod::Stored, ); assert!(check(&bytes, "persona").is_err(), "unsafe path {name:?}"); } for (one, two) in [ ("A.txt", "a.txt"), ("Straße", "STRASSE"), ("ა", "Ა"), ("a", "a/b"), ] { let bytes = archive( &[("persona.json", b"{}"), (one, b"1"), (two, b"2")], zip::CompressionMethod::Stored, ); assert!(check(&bytes, "persona").is_err()); } let mut duplicate = archive( &[("persona.json", b"{}"), ("A.txt", b"1"), ("B.txt", b"2")], zip::CompressionMethod::Stored, ); for p in 0..duplicate.len() - 5 { if &duplicate[p..p + 5] == b"B.txt" { duplicate[p] = b'A'; } } assert_eq!( check(&duplicate, "persona").unwrap_err().code, "EXTENSION_ZIP_DUPLICATE" ); let original = archive(&[("persona.json", b"{}")], zip::CompressionMethod::Stored); let central = original .windows(4) .position(|b| b == b"PK\x01\x02") .unwrap(); let mut link = original.clone(); link[central + 38..central + 42].copy_from_slice(&(0o120777u32 << 16).to_le_bytes()); assert!(check(&link, "persona").is_err()); let mut encrypted = original.clone(); encrypted[6] |= 1; encrypted[central + 8] |= 1; assert!(check(&encrypted, "persona").is_err()); let mut mismatch = original.clone(); mismatch[30] = b'X'; assert!(check(&mismatch, "persona").is_err()); let mut corrupt = original; let body = 30 + "persona.json".len(); corrupt[body] ^= 1; assert!(check(&corrupt, "persona").is_err()); } #[test] fn compressed_limits_are_inclusive_for_both_categories() { for (kind, manifest, limit) in [ ("theme", "theme.yaml", 5 * 1024 * 1024), ("plugin", "plugin.yaml", 10 * 1024 * 1024), ] { let overhead = archive( &[(manifest, b"{}"), ("payload", b"")], zip::CompressionMethod::Stored, ) .len(); for extra in [0, 1] { let payload = vec![0; limit - overhead + extra]; let bytes = archive( &[(manifest, b"{}"), ("payload", &payload)], zip::CompressionMethod::Stored, ); assert_eq!(bytes.len(), limit + extra); assert_eq!(check(&bytes, kind).is_ok(), extra == 0); } } } #[test] fn category_entry_and_expanded_limits_accept_boundary_and_reject_next_byte() { for (kind, manifest, limit, count) in [ ("theme", "theme.yaml", 10 * 1024 * 1024, 100), ("plugin", "plugin.yaml", 50 * 1024 * 1024, 2048), ] { let names: Vec<_> = (1..count + 1).map(|i| format!("entry-{i}")).collect(); let mut items = vec![(manifest, b"{}".as_slice())]; items.extend( names .iter() .take(count - 1) .map(|s| (s.as_str(), b"".as_slice())), ); assert!(check(&archive(&items, zip::CompressionMethod::Stored), kind).is_ok()); items.push((names.last().unwrap(), b"")); assert!(check(&archive(&items, zip::CompressionMethod::Stored), kind).is_err()); let payload = vec![0; limit - 2]; let bytes = archive( &[(manifest, b"{}"), ("payload", &payload)], zip::CompressionMethod::Deflated, ); assert_eq!(check(&bytes, kind).unwrap().expanded_size, limit as u64); let payload = vec![0; limit - 1]; let bytes = archive( &[(manifest, b"{}"), ("payload", &payload)], zip::CompressionMethod::Deflated, ); assert_eq!(check(&bytes, kind).unwrap_err().code, "EXTENSION_ZIP_LIMIT"); } } }