systemprompt_loader/bundle/
verify.rs1use std::collections::BTreeSet;
12use std::fs;
13use std::io::Read;
14use std::path::Path;
15
16use flate2::read::GzDecoder;
17use sha2::{Digest, Sha256};
18use systemprompt_models::profile::BundleVerification;
19use systemprompt_models::services::bundle::{
20 BUNDLE_ALLOWED_DIRS, BUNDLE_FORMAT_VERSION, BUNDLE_MANIFEST_FILE, BUNDLE_SIGNATURE_ALG,
21 ServicesBundleManifest, SignedBundleManifest,
22};
23use systemprompt_security::manifest_signing::{
24 canonical_manifest_bytes, key_id_for_pubkey, verify_with_pubkey,
25};
26use tar::Archive;
27
28use super::error::{BundleError, BundleResult, VerifyFailure};
29use super::pack::collect_files;
30
31pub fn file_digest(path: &Path) -> BundleResult<String> {
32 let mut file = fs::File::open(path)?;
33 let mut hasher = Sha256::new();
34 let mut buf = vec![0u8; 64 * 1024];
35 loop {
36 let read = file.read(&mut buf)?;
37 if read == 0 {
38 break;
39 }
40 hasher.update(&buf[..read]);
41 }
42 Ok(hex::encode(hasher.finalize()))
43}
44
45pub fn verify_bundle(
46 archive: &Path,
47 verification: &BundleVerification,
48 core_version: &str,
49) -> BundleResult<SignedBundleManifest> {
50 if let Some(expected) = verification.sha256.as_deref() {
51 let actual = file_digest(archive)?;
52 if !actual.eq_ignore_ascii_case(expected) {
53 return Err(VerifyFailure::DigestMismatch {
54 expected: expected.to_owned(),
55 actual,
56 }
57 .into());
58 }
59 }
60
61 let signed = read_manifest(archive)?;
62 if signed.manifest.format != BUNDLE_FORMAT_VERSION {
63 return Err(VerifyFailure::UnsupportedFormat {
64 format: signed.manifest.format,
65 supported: BUNDLE_FORMAT_VERSION,
66 }
67 .into());
68 }
69
70 verify_signature(&signed, &verification.ed25519_public_keys)?;
71
72 let satisfied = signed
73 .manifest
74 .core_satisfies(core_version)
75 .map_err(|e| BundleError::policy(format!("requires_core is not a semver range: {e}")))?;
76 if !satisfied {
77 return Err(VerifyFailure::RequiresCore {
78 required: signed.manifest.requires_core,
79 actual: core_version.to_owned(),
80 }
81 .into());
82 }
83
84 Ok(signed)
85}
86
87pub fn read_manifest(archive: &Path) -> BundleResult<SignedBundleManifest> {
88 let file = fs::File::open(archive)?;
89 let mut tar = Archive::new(GzDecoder::new(file));
90 for entry in tar.entries()? {
91 let mut entry = entry?;
92 if entry.path()?.as_ref() != Path::new(BUNDLE_MANIFEST_FILE) {
93 continue;
94 }
95 let mut raw = String::new();
96 entry.read_to_string(&mut raw)?;
97 return serde_json::from_str(&raw)
98 .map_err(|e| BundleError::policy(format!("bundle.json does not parse: {e}")));
99 }
100 Err(VerifyFailure::MissingManifest.into())
101}
102
103fn verify_signature(signed: &SignedBundleManifest, pinned_keys: &[String]) -> BundleResult<()> {
104 if pinned_keys.is_empty() {
105 return Ok(());
106 }
107 let Some(signature) = signed.signature.as_ref() else {
108 return Err(VerifyFailure::MissingSignature.into());
109 };
110 if signature.alg != BUNDLE_SIGNATURE_ALG {
111 return Err(VerifyFailure::UnsupportedAlgorithm {
112 alg: signature.alg.clone(),
113 }
114 .into());
115 }
116
117 let payload = canonical_manifest_bytes(&signed.manifest)
118 .map_err(|e| BundleError::policy(format!("manifest cannot be canonicalised: {e}")))?;
119
120 let matching: Vec<&String> = pinned_keys
121 .iter()
122 .filter(|k| key_id_for_pubkey(k) == signature.key_id)
123 .collect();
124 if matching.is_empty() {
125 return Err(VerifyFailure::UnknownKey {
126 key_id: signature.key_id.clone(),
127 }
128 .into());
129 }
130 for key in matching {
131 if verify_with_pubkey(key, &payload, &signature.sig_b64).is_ok() {
132 return Ok(());
133 }
134 }
135 Err(VerifyFailure::BadSignature.into())
136}
137
138pub fn verify_extracted(root: &Path, manifest: &ServicesBundleManifest) -> BundleResult<()> {
139 for entry in &manifest.files {
140 let path = root.join(&entry.path);
141 let content = fs::read(&path).map_err(|_e| VerifyFailure::FileChecksum {
142 path: entry.path.clone(),
143 })?;
144 let digest = hex::encode(Sha256::digest(&content));
145 if digest != entry.sha256 || content.len() as u64 != entry.size {
146 return Err(VerifyFailure::FileChecksum {
147 path: entry.path.clone(),
148 }
149 .into());
150 }
151 }
152
153 let declared: BTreeSet<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect();
154 let present = collect_files(root, BUNDLE_ALLOWED_DIRS)?;
155 let extra = present
156 .iter()
157 .filter(|f| !declared.contains(f.path.as_str()))
158 .count();
159 if extra > 0 {
160 return Err(VerifyFailure::UnexpectedFiles { count: extra }.into());
161 }
162
163 if ServicesBundleManifest::compute_content_hash(&manifest.files) != manifest.content_hash {
164 return Err(VerifyFailure::ContentHash.into());
165 }
166 Ok(())
167}
168
169pub fn require_marketplace_only(manifest: &ServicesBundleManifest) -> BundleResult<()> {
170 for dir in &manifest.owns.dirs {
171 if !systemprompt_models::services::bundle::MARKETPLACE_BUNDLE_DIRS.contains(&dir.as_str()) {
172 return Err(VerifyFailure::NotMarketplaceOnly { dir: dir.clone() }.into());
173 }
174 }
175 Ok(())
176}