Skip to main content

treeship_core/bundle/
mod.rs

1use std::path::Path;
2
3use crate::{
4    attestation::{sign, ArtifactId, Envelope, SignError, Signer, Verifier, VerifyError},
5    statements::{payload_type, ArtifactRef, BundleStatement},
6    storage::{Record, StorageError, Store},
7};
8
9/// Error from bundle operations.
10#[derive(Debug)]
11pub enum BundleError {
12    Storage(StorageError),
13    Sign(SignError),
14    Io(std::io::Error),
15    Json(serde_json::Error),
16    ArtifactNotFound(String),
17    InvalidBundle(String),
18    /// A signature on an imported envelope did not verify against the
19    /// configured trust root. Carries the offending envelope's index in
20    /// the export (0 = bundle envelope, 1..=N = artifact envelopes) and
21    /// the underlying verification error.
22    UnverifiedEnvelope {
23        index: usize,
24        source: VerifyError,
25    },
26    /// `import` was called with an empty trust root. Without trusted keys
27    /// there is nothing to verify signatures against, so import would
28    /// degenerate to "trust whatever the file says" — refused loudly.
29    NoTrustRoot,
30}
31
32impl std::fmt::Display for BundleError {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Self::Storage(e) => write!(f, "bundle storage: {e}"),
36            Self::Sign(e) => write!(f, "bundle sign: {e}"),
37            Self::Io(e) => write!(f, "bundle io: {e}"),
38            Self::Json(e) => write!(f, "bundle json: {e}"),
39            Self::ArtifactNotFound(id) => write!(f, "artifact not found: {id}"),
40            Self::InvalidBundle(msg) => write!(f, "invalid bundle: {msg}"),
41            Self::UnverifiedEnvelope { index, source } => write!(
42                f,
43                "envelope {index} failed signature verification: {source}",
44            ),
45            Self::NoTrustRoot => write!(
46                f,
47                "bundle import requires a configured trust root: \
48                 generate or import a signer key (treeship init / treeship keys add) \
49                 before importing a .treeship bundle",
50            ),
51        }
52    }
53}
54
55impl std::error::Error for BundleError {}
56impl From<StorageError> for BundleError {
57    fn from(e: StorageError) -> Self {
58        Self::Storage(e)
59    }
60}
61impl From<SignError> for BundleError {
62    fn from(e: SignError) -> Self {
63        Self::Sign(e)
64    }
65}
66impl From<std::io::Error> for BundleError {
67    fn from(e: std::io::Error) -> Self {
68        Self::Io(e)
69    }
70}
71impl From<serde_json::Error> for BundleError {
72    fn from(e: serde_json::Error) -> Self {
73        Self::Json(e)
74    }
75}
76
77/// The result of creating a bundle.
78#[derive(Debug)]
79pub struct CreateResult {
80    pub artifact_id: ArtifactId,
81    pub digest: String,
82    pub record: Record,
83    pub statement: BundleStatement,
84}
85
86/// A .treeship export file: the bundle envelope plus all referenced artifact envelopes.
87#[derive(Debug, serde::Serialize, serde::Deserialize)]
88pub struct ExportFile {
89    /// Format version for forward compatibility.
90    pub version: String,
91
92    /// The signed bundle envelope.
93    pub bundle: Envelope,
94
95    /// All artifact envelopes referenced by the bundle, in chain order.
96    pub artifacts: Vec<Envelope>,
97}
98
99const EXPORT_VERSION: &str = "treeship-export/v1";
100
101/// Create a bundle from a list of artifact IDs.
102///
103/// Reads each artifact from storage, builds a `BundleStatement` referencing
104/// them, signs it, and stores the bundle as a regular artifact.
105pub fn create(
106    artifact_ids: &[&str],
107    tag: Option<&str>,
108    description: Option<&str>,
109    storage: &Store,
110    signer: &dyn Signer,
111) -> Result<CreateResult, BundleError> {
112    if artifact_ids.is_empty() {
113        return Err(BundleError::InvalidBundle(
114            "no artifact IDs provided".into(),
115        ));
116    }
117
118    // Read each artifact and build the reference list.
119    let mut refs = Vec::with_capacity(artifact_ids.len());
120    let mut records = Vec::with_capacity(artifact_ids.len());
121
122    for &id in artifact_ids {
123        let rec = storage
124            .read(id)
125            .map_err(|_| BundleError::ArtifactNotFound(id.to_string()))?;
126        refs.push(ArtifactRef {
127            id: rec.artifact_id.clone(),
128            digest: rec.digest.clone(),
129            type_: rec.payload_type.clone(),
130        });
131        records.push(rec);
132    }
133
134    let stmt = BundleStatement {
135        type_: crate::statements::TYPE_BUNDLE.into(),
136        timestamp: crate::statements::unix_to_rfc3339(now_secs()),
137        tag: tag.map(|s| s.to_string()),
138        description: description.map(|s| s.to_string()),
139        artifacts: refs,
140        policy_ref: None,
141        meta: None,
142    };
143
144    let pt = payload_type("bundle");
145    let result = sign(&pt, &stmt, signer)?;
146
147    let record = Record {
148        artifact_id: result.artifact_id.clone(),
149        digest: result.digest.clone(),
150        payload_type: pt,
151        key_id: signer.key_id().to_string(),
152        signed_at: stmt.timestamp.clone(),
153        parent_id: None,
154        envelope: result.envelope,
155        hub_url: None,
156    };
157
158    storage.write(&record)?;
159
160    Ok(CreateResult {
161        artifact_id: result.artifact_id,
162        digest: result.digest,
163        record,
164        statement: stmt,
165    })
166}
167
168/// Export a bundle to a .treeship file.
169///
170/// The export file contains the bundle envelope and all referenced artifact
171/// envelopes. This is the portable format for sharing proof chains.
172pub fn export(bundle_id: &str, out_path: &Path, storage: &Store) -> Result<(), BundleError> {
173    let bundle_rec = storage.read(bundle_id)?;
174
175    // Verify this is actually a bundle.
176    let expected_pt = payload_type("bundle");
177    if bundle_rec.payload_type != expected_pt {
178        return Err(BundleError::InvalidBundle(format!(
179            "artifact {} is {}, not a bundle",
180            bundle_id, bundle_rec.payload_type
181        )));
182    }
183
184    // Decode the bundle statement to get artifact references.
185    let stmt: BundleStatement = bundle_rec
186        .envelope
187        .unmarshal_statement()
188        .map_err(|e| BundleError::InvalidBundle(format!("cannot decode bundle: {e}")))?;
189
190    // Collect all referenced artifact envelopes.
191    let mut artifact_envelopes = Vec::with_capacity(stmt.artifacts.len());
192    for art_ref in &stmt.artifacts {
193        let rec = storage
194            .read(&art_ref.id)
195            .map_err(|_| BundleError::ArtifactNotFound(art_ref.id.clone()))?;
196        artifact_envelopes.push(rec.envelope);
197    }
198
199    let export = ExportFile {
200        version: EXPORT_VERSION.into(),
201        bundle: bundle_rec.envelope,
202        artifacts: artifact_envelopes,
203    };
204
205    let json = serde_json::to_vec_pretty(&export)?;
206    std::fs::write(out_path, &json)?;
207
208    Ok(())
209}
210
211/// Import a .treeship file into local storage.
212///
213/// Reads the export file, verifies every envelope's signatures against the
214/// provided `verifier`, re-derives content-addressed IDs, and stores everything
215/// locally. Returns the bundle's artifact ID.
216///
217/// P0 #5 (audit): import previously called `record_from_envelope` with no
218/// signature check, which made imported bundles forged-record vectors. The
219/// `verifier` argument carries the caller's trust root — typically built from
220/// the local keystore's public keys. If verification fails for any envelope
221/// the entire import is rejected; partial writes are avoided by verifying all
222/// envelopes before writing any record.
223pub fn import(
224    path: &Path,
225    storage: &Store,
226    verifier: &Verifier,
227) -> Result<ArtifactId, BundleError> {
228    let bytes = std::fs::read(path)?;
229    let export: ExportFile = serde_json::from_slice(&bytes)?;
230
231    if export.version != EXPORT_VERSION {
232        return Err(BundleError::InvalidBundle(format!(
233            "unsupported export version: {} (expected {})",
234            export.version, EXPORT_VERSION
235        )));
236    }
237
238    // Verify every envelope before writing any record. `verify_any` (not
239    // `verify`) is the right primitive here: an envelope may carry multiple
240    // signatures from a rotation/co-sign setup and the local trust root only
241    // needs one to match. Index 0 = bundle envelope, 1..=N = artifact
242    // envelopes (matches the order shown in error messages and CLI output).
243    let bundle_vr = verifier
244        .verify_any(&export.bundle)
245        .map_err(|source| BundleError::UnverifiedEnvelope { index: 0, source })?;
246    let mut artifact_verified_keys: Vec<Option<String>> =
247        Vec::with_capacity(export.artifacts.len());
248    for (i, env) in export.artifacts.iter().enumerate() {
249        let vr = verifier
250            .verify_any(env)
251            .map_err(|source| BundleError::UnverifiedEnvelope {
252                index: i + 1,
253                source,
254            })?;
255        artifact_verified_keys.push(vr.verified_key_ids.first().cloned());
256    }
257
258    // All signatures check out: now write, attributing each record to the key
259    // that actually verified (AUD-13), not to signatures.first().
260    for (env, vk) in export.artifacts.iter().zip(artifact_verified_keys.iter()) {
261        let record = record_from_envelope(env, vk.as_deref())?;
262        storage.write(&record)?;
263    }
264
265    let bundle_record = record_from_envelope(
266        &export.bundle,
267        bundle_vr.verified_key_ids.first().map(|s| s.as_str()),
268    )?;
269    let bundle_id = bundle_record.artifact_id.clone();
270    storage.write(&bundle_record)?;
271
272    Ok(bundle_id)
273}
274
275/// Reconstruct a Record from a DSSE envelope by re-deriving the artifact ID.
276///
277/// `verified_key_id` is the key id whose signature ACTUALLY verified for this
278/// envelope (from the caller's `verify_any` result). It is used verbatim for
279/// the stored `key_id` so a decoy signature prepended ahead of the real one
280/// cannot misattribute the artifact's signer in local metadata (AUD-13). When
281/// None (no verification context), we fall back to the first signature's keyid.
282fn record_from_envelope(
283    envelope: &Envelope,
284    verified_key_id: Option<&str>,
285) -> Result<Record, BundleError> {
286    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
287
288    let payload_bytes = URL_SAFE_NO_PAD
289        .decode(&envelope.payload)
290        .map_err(|e| BundleError::InvalidBundle(format!("bad payload base64: {e}")))?;
291
292    let pae_bytes = crate::attestation::pae(&envelope.payload_type, &payload_bytes);
293    let artifact_id = crate::attestation::artifact_id_from_pae(&pae_bytes);
294    let digest = crate::attestation::digest_from_pae(&pae_bytes);
295
296    // Extract timestamp from the payload if possible.
297    let signed_at = serde_json::from_slice::<serde_json::Value>(&payload_bytes)
298        .ok()
299        .and_then(|v| {
300            v.get("timestamp")
301                .and_then(|t| t.as_str().map(|s| s.to_string()))
302        })
303        .unwrap_or_default();
304
305    // Extract parent_id from the payload if present.
306    let parent_id = serde_json::from_slice::<serde_json::Value>(&payload_bytes)
307        .ok()
308        .and_then(|v| {
309            v.get("parentId")
310                .and_then(|t| t.as_str().map(|s| s.to_string()))
311        });
312
313    // AUD-13: attribute the record to the key that actually verified, not
314    // signatures.first() (which an attacker can prepend a decoy keyid to).
315    let key_id = verified_key_id
316        .map(|s| s.to_string())
317        .or_else(|| envelope.signatures.first().map(|s| s.keyid.clone()))
318        .unwrap_or_default();
319
320    Ok(Record {
321        artifact_id,
322        digest,
323        payload_type: envelope.payload_type.clone(),
324        key_id,
325        signed_at,
326        parent_id,
327        envelope: envelope.clone(),
328        hub_url: None,
329    })
330}
331
332fn now_secs() -> u64 {
333    use std::time::{SystemTime, UNIX_EPOCH};
334    SystemTime::now()
335        .duration_since(UNIX_EPOCH)
336        .unwrap_or_default()
337        .as_secs()
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use crate::attestation::Ed25519Signer;
344    use crate::statements::{ActionStatement, ApprovalStatement};
345
346    fn tmp_store() -> (Store, std::path::PathBuf) {
347        let mut p = std::env::temp_dir();
348        p.push(format!("treeship-bundle-test-{}", {
349            use rand::RngCore;
350            let mut b = [0u8; 4];
351            rand::thread_rng().fill_bytes(&mut b);
352            b.iter().fold(String::new(), |mut s, byte| {
353                s.push_str(&format!("{:02x}", byte));
354                s
355            })
356        }));
357        let store = Store::open(&p).unwrap();
358        (store, p)
359    }
360
361    fn rm(p: std::path::PathBuf) {
362        let _ = std::fs::remove_dir_all(p);
363    }
364
365    fn sign_and_store(
366        store: &Store,
367        signer: &dyn Signer,
368        pt: &str,
369        stmt: &impl serde::Serialize,
370    ) -> String {
371        let result = sign(pt, stmt, signer).unwrap();
372        store
373            .write(&Record {
374                artifact_id: result.artifact_id.clone(),
375                digest: result.digest.clone(),
376                payload_type: pt.to_string(),
377                key_id: signer.key_id().to_string(),
378                signed_at: String::new(),
379                parent_id: None,
380                envelope: result.envelope,
381                hub_url: None,
382            })
383            .unwrap();
384        result.artifact_id
385    }
386
387    #[test]
388    fn create_bundle() {
389        let (store, dir) = tmp_store();
390        let signer = Ed25519Signer::generate("key_test").unwrap();
391
392        let a1 = sign_and_store(
393            &store,
394            &signer,
395            &payload_type("action"),
396            &ActionStatement::new("agent://a", "tool.call"),
397        );
398        let a2 = sign_and_store(
399            &store,
400            &signer,
401            &payload_type("approval"),
402            &ApprovalStatement::new("human://b", "nonce_1"),
403        );
404
405        let result = create(&[&a1, &a2], Some("test-bundle"), None, &store, &signer).unwrap();
406
407        assert!(result.artifact_id.starts_with("art_"));
408        assert_eq!(result.statement.artifacts.len(), 2);
409        assert_eq!(result.statement.tag.as_deref(), Some("test-bundle"));
410
411        // Bundle is stored
412        assert!(store.exists(&result.artifact_id));
413        rm(dir);
414    }
415
416    #[test]
417    fn create_empty_fails() {
418        let (store, dir) = tmp_store();
419        let signer = Ed25519Signer::generate("key_test").unwrap();
420        let err = create(&[], None, None, &store, &signer).unwrap_err();
421        assert!(err.to_string().contains("no artifact IDs"));
422        rm(dir);
423    }
424
425    #[test]
426    fn create_missing_artifact_fails() {
427        let (store, dir) = tmp_store();
428        let signer = Ed25519Signer::generate("key_test").unwrap();
429        let err = create(
430            &["art_doesnotexist1234567890123456"],
431            None,
432            None,
433            &store,
434            &signer,
435        )
436        .unwrap_err();
437        assert!(err.to_string().contains("not found"));
438        rm(dir);
439    }
440
441    #[test]
442    fn export_and_import_roundtrip() {
443        let (store, dir) = tmp_store();
444        let signer = Ed25519Signer::generate("key_test").unwrap();
445        let verifier = crate::attestation::Verifier::from_signer(&signer);
446
447        let a1 = sign_and_store(
448            &store,
449            &signer,
450            &payload_type("action"),
451            &ActionStatement::new("agent://a", "tool.call"),
452        );
453        let a2 = sign_and_store(
454            &store,
455            &signer,
456            &payload_type("action"),
457            &ActionStatement::new("agent://b", "web.fetch"),
458        );
459
460        let bundle = create(&[&a1, &a2], Some("roundtrip"), None, &store, &signer).unwrap();
461
462        // Export
463        let export_path = dir.join("test.treeship");
464        export(&bundle.artifact_id, &export_path, &store).unwrap();
465        assert!(export_path.exists());
466
467        // Read and check the export file structure
468        let bytes = std::fs::read(&export_path).unwrap();
469        let ef: ExportFile = serde_json::from_slice(&bytes).unwrap();
470        assert_eq!(ef.version, EXPORT_VERSION);
471        assert_eq!(ef.artifacts.len(), 2);
472
473        // Import into a fresh store
474        let (store2, dir2) = tmp_store();
475        let imported_id = import(&export_path, &store2, &verifier).unwrap();
476        assert_eq!(imported_id, bundle.artifact_id);
477
478        // All artifacts are now in the new store
479        assert!(store2.exists(&a1));
480        assert!(store2.exists(&a2));
481        assert!(store2.exists(&bundle.artifact_id));
482
483        rm(dir);
484        rm(dir2);
485    }
486
487    #[test]
488    fn export_non_bundle_fails() {
489        let (store, dir) = tmp_store();
490        let signer = Ed25519Signer::generate("key_test").unwrap();
491        let a1 = sign_and_store(
492            &store,
493            &signer,
494            &payload_type("action"),
495            &ActionStatement::new("agent://a", "tool.call"),
496        );
497
498        let export_path = dir.join("bad.treeship");
499        let err = export(&a1, &export_path, &store).unwrap_err();
500        assert!(err.to_string().contains("not a bundle"));
501        rm(dir);
502    }
503
504    #[test]
505    fn import_bad_version_fails() {
506        let (store, dir) = tmp_store();
507        let signer = Ed25519Signer::generate("key_test").unwrap();
508        let verifier = crate::attestation::Verifier::from_signer(&signer);
509        let bad = ExportFile {
510            version: "bad/v99".into(),
511            bundle: Envelope {
512                payload: String::new(),
513                payload_type: String::new(),
514                signatures: vec![],
515            },
516            artifacts: vec![],
517        };
518        let path = dir.join("bad.treeship");
519        std::fs::write(&path, serde_json::to_vec(&bad).unwrap()).unwrap();
520
521        let err = import(&path, &store, &verifier).unwrap_err();
522        assert!(err.to_string().contains("unsupported export version"));
523        rm(dir);
524    }
525
526    #[test]
527    fn import_rejects_envelope_with_invalid_signature() {
528        // P0 #5: before this fix, `import` called `record_from_envelope`
529        // straight from the file with no verification — an attacker could
530        // hand-craft a `.treeship` whose envelopes pointed at any payload
531        // and the import would happily land it in storage. Now every
532        // envelope must verify against the caller's trust root.
533        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
534
535        let (store, dir) = tmp_store();
536        let signer = Ed25519Signer::generate("key_test").unwrap();
537        let verifier = crate::attestation::Verifier::from_signer(&signer);
538
539        // Build a normal bundle so we have a valid export to start from.
540        let a1 = sign_and_store(
541            &store,
542            &signer,
543            &payload_type("action"),
544            &ActionStatement::new("agent://a", "tool.call"),
545        );
546        let bundle = create(&[&a1], Some("tampered"), None, &store, &signer).unwrap();
547        let export_path = dir.join("tampered.treeship");
548        export(&bundle.artifact_id, &export_path, &store).unwrap();
549
550        // Tamper the *first* artifact envelope's signature bytes. The keyid
551        // remains correct (still our trusted key) but the cipher-bytes no
552        // longer verify against the PAE.
553        let raw = std::fs::read(&export_path).unwrap();
554        let mut ef: ExportFile = serde_json::from_slice(&raw).unwrap();
555        // Replace the 64-byte signature with all zeros — well-formed length,
556        // mathematically invalid.
557        ef.artifacts[0].signatures[0].sig = URL_SAFE_NO_PAD.encode([0u8; 64]);
558        std::fs::write(&export_path, serde_json::to_vec(&ef).unwrap()).unwrap();
559
560        // Import into a fresh store. The tamper must be detected and the
561        // import must fail; the fresh store must remain empty of the
562        // artifact and bundle.
563        let (store2, dir2) = tmp_store();
564        let err = import(&export_path, &store2, &verifier).unwrap_err();
565        assert!(
566            matches!(err, BundleError::UnverifiedEnvelope { index: 1, .. }),
567            "expected UnverifiedEnvelope{{index:1, ..}}, got: {err}"
568        );
569        // Verify nothing was written: signatures are checked before any
570        // record is persisted, so the destination store stays clean.
571        assert!(!store2.exists(&a1));
572        assert!(!store2.exists(&bundle.artifact_id));
573
574        rm(dir);
575        rm(dir2);
576    }
577
578    #[test]
579    fn import_attributes_record_to_verified_key_not_decoy() {
580        // AUD-13: a decoy signature prepended ahead of the real one must not
581        // misattribute the stored record's signer. verify_any skips the decoy
582        // and passes on the real sig; the stored key_id must be the key that
583        // actually verified, not signatures.first().
584        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
585
586        let (store, dir) = tmp_store();
587        let signer = Ed25519Signer::generate("key_real").unwrap();
588        let verifier = crate::attestation::Verifier::from_signer(&signer);
589
590        let a1 = sign_and_store(
591            &store,
592            &signer,
593            &payload_type("action"),
594            &ActionStatement::new("agent://a", "tool.call"),
595        );
596        let bundle = create(&[&a1], Some("b"), None, &store, &signer).unwrap();
597        let export_path = dir.join("b.treeship");
598        export(&bundle.artifact_id, &export_path, &store).unwrap();
599
600        // Prepend a decoy signature (attacker keyid, garbage bytes) ahead of
601        // the real one on the first artifact envelope.
602        let raw = std::fs::read(&export_path).unwrap();
603        let mut ef: ExportFile = serde_json::from_slice(&raw).unwrap();
604        let real_sig = ef.artifacts[0].signatures[0].clone();
605        let decoy = crate::attestation::Signature {
606            keyid: "key_ceo".into(),
607            sig: URL_SAFE_NO_PAD.encode([0u8; 64]),
608        };
609        ef.artifacts[0].signatures = vec![decoy, real_sig];
610        std::fs::write(&export_path, serde_json::to_vec(&ef).unwrap()).unwrap();
611
612        // Import into a fresh store: the real sig still verifies via verify_any.
613        let (store2, dir2) = tmp_store();
614        import(&export_path, &store2, &verifier).unwrap();
615
616        let rec = store2.read(&a1).unwrap();
617        assert_eq!(
618            rec.key_id, "key_real",
619            "record must be attributed to the VERIFIED key, not the prepended decoy"
620        );
621
622        rm(dir);
623        rm(dir2);
624    }
625
626    #[test]
627    fn import_rejects_unsigned_envelope() {
628        // Companion to the P0 #4 fix: a bundle file whose envelopes carry
629        // zero signatures must not import. The `Verifier` returns
630        // `NoValidSignature` and `import` propagates it as
631        // `UnverifiedEnvelope`.
632        let (store, dir) = tmp_store();
633        let signer = Ed25519Signer::generate("key_test").unwrap();
634        let verifier = crate::attestation::Verifier::from_signer(&signer);
635
636        let a1 = sign_and_store(
637            &store,
638            &signer,
639            &payload_type("action"),
640            &ActionStatement::new("agent://a", "tool.call"),
641        );
642        let bundle = create(&[&a1], Some("unsigned"), None, &store, &signer).unwrap();
643        let export_path = dir.join("unsigned.treeship");
644        export(&bundle.artifact_id, &export_path, &store).unwrap();
645
646        // Strip every signature off every envelope in the export.
647        let raw = std::fs::read(&export_path).unwrap();
648        let mut ef: ExportFile = serde_json::from_slice(&raw).unwrap();
649        ef.bundle.signatures.clear();
650        for env in &mut ef.artifacts {
651            env.signatures.clear();
652        }
653        std::fs::write(&export_path, serde_json::to_vec(&ef).unwrap()).unwrap();
654
655        let (store2, dir2) = tmp_store();
656        let err = import(&export_path, &store2, &verifier).unwrap_err();
657        assert!(
658            matches!(err, BundleError::UnverifiedEnvelope { index: 0, .. }),
659            "expected UnverifiedEnvelope{{index:0, ..}} (bundle envelope first), got: {err}"
660        );
661
662        rm(dir);
663        rm(dir2);
664    }
665}