Skip to main content

treeship_core/bundle/
mod.rs

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