Skip to main content

systemprompt_loader/bundle/
verify.rs

1//! Bundle verification, in the order the trust chain requires.
2//!
3//! The archive digest is checked before a single byte is parsed, the manifest
4//! signature before the manifest is believed, and the per-file checksums
5//! before the extracted tree is used. A bundle that fails any step is never
6//! installed and never cached: there is no warn-and-continue path here.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use 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}