Skip to main content

mur_common/
fleet_bundle.rs

1//! `.fleet` bundle manifest types + signing primitives (pure; no I/O).
2//!
3//! The MANIFEST is the only signed object: it pins every bundled file by
4//! SHA-256, so the archive container need not be byte-deterministic — verifying
5//! each file's hash against the manifest plus the manifest signature suffices.
6
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10/// Bundle wire-format version. Bump on any breaking manifest change.
11pub const FLEET_BUNDLE_FORMAT: u32 = 1;
12
13/// One file in a bundle, pinned by content hash.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct BundleEntry {
16    /// Bundle-relative path, e.g. `fleet.yaml`, `skills/triage/skill.yaml`.
17    pub path: String,
18    /// Lowercase hex SHA-256 of the file's bytes.
19    pub sha256: String,
20}
21
22/// The signed manifest at `bundle.yaml`.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct BundleManifest {
25    pub format_version: u32,
26    pub fleet_name: String,
27    pub created_at: String,
28    /// Exporter's concierge identity public key (multibase).
29    pub signer_pubkey: String,
30    /// Short, human-checkable fingerprint of `signer_pubkey`.
31    pub signer_fingerprint: String,
32    pub includes_members: bool,
33    /// Declared member names (always listed, even when not bundled).
34    pub members: Vec<String>,
35    /// Every bundled file pinned by hash.
36    pub entries: Vec<BundleEntry>,
37    /// Multibase Ed25519 signature over `manifest_sign_input` (this manifest
38    /// with `sig=None`). `None` only while building, before signing.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub sig: Option<String>,
41}
42
43/// Lowercase hex SHA-256 of `bytes`.
44pub fn content_hash(bytes: &[u8]) -> String {
45    let mut h = Sha256::new();
46    h.update(bytes);
47    hex::encode(h.finalize())
48}
49
50/// Canonical signing input: the manifest serialized with `sig` cleared. Struct
51/// field order is fixed, so this is deterministic without a custom canonicalizer.
52pub fn manifest_sign_input(m: &BundleManifest) -> Vec<u8> {
53    let mut unsigned = m.clone();
54    unsigned.sig = None;
55    serde_json::to_vec(&unsigned).expect("manifest serializes")
56}
57
58/// Short fingerprint of a multibase pubkey: first 8 hex chars of its SHA-256,
59/// hyphen-grouped (e.g. `ab12-cd34`) for human out-of-band comparison.
60pub fn signer_fingerprint(signer_pubkey_multibase: &str) -> String {
61    let h = content_hash(signer_pubkey_multibase.as_bytes());
62    format!("{}-{}", &h[0..4], &h[4..8])
63}
64
65/// Verify the manifest signature against `pubkey`. Fail-closed: no `sig` → false.
66pub fn verify_manifest_sig(m: &BundleManifest, pubkey: &[u8; 32]) -> bool {
67    let Some(sig) = m.sig.as_deref() else {
68        return false;
69    };
70    crate::identity::verify_bytes(pubkey, &manifest_sign_input(m), sig)
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::identity::AgentIdentity;
77
78    fn sample_manifest() -> BundleManifest {
79        BundleManifest {
80            format_version: FLEET_BUNDLE_FORMAT,
81            fleet_name: "devteam".into(),
82            created_at: "2026-06-20T00:00:00Z".into(),
83            signer_pubkey: String::new(),
84            signer_fingerprint: String::new(),
85            includes_members: false,
86            members: vec!["pm".into(), "qa".into()],
87            entries: vec![BundleEntry {
88                path: "fleet.yaml".into(),
89                sha256: content_hash(b"name: devteam\n"),
90            }],
91            sig: None,
92        }
93    }
94
95    #[test]
96    fn content_hash_is_deterministic_hex() {
97        assert_eq!(content_hash(b"abc"), content_hash(b"abc"));
98        assert_ne!(content_hash(b"abc"), content_hash(b"abd"));
99        assert_eq!(content_hash(b"").len(), 64); // sha256 hex
100    }
101
102    #[test]
103    fn manifest_roundtrips_yaml() {
104        let m = sample_manifest();
105        let y = serde_yaml::to_string(&m).unwrap();
106        let back: BundleManifest = serde_yaml::from_str(&y).unwrap();
107        assert_eq!(m, back);
108    }
109
110    #[test]
111    fn sign_then_verify_roundtrip_and_tamper_fails() {
112        let id = AgentIdentity::generate();
113        let mut m = sample_manifest();
114        m.signer_pubkey = id.public_key_multibase();
115        m.signer_fingerprint = signer_fingerprint(&m.signer_pubkey);
116        // sign canonical input (sig must be None during signing)
117        let input = manifest_sign_input(&m);
118        m.sig = Some(multibase::encode(
119            multibase::Base::Base58Btc,
120            id.sign_bytes(&input),
121        ));
122        let pubkey = id.verifying_key_bytes();
123        assert!(verify_manifest_sig(&m, &pubkey));
124
125        // tamper an entry hash → verify fails
126        let mut tampered = m.clone();
127        tampered.entries[0].sha256 = content_hash(b"evil");
128        assert!(!verify_manifest_sig(&tampered, &pubkey));
129
130        // flip the signature → verify fails (fail-closed)
131        let mut badsig = m.clone();
132        badsig.sig = Some(multibase::encode(multibase::Base::Base58Btc, [0u8; 64]));
133        assert!(!verify_manifest_sig(&badsig, &pubkey));
134
135        // missing signature → false
136        let mut unsigned = m.clone();
137        unsigned.sig = None;
138        assert!(!verify_manifest_sig(&unsigned, &pubkey));
139    }
140
141    #[test]
142    fn fingerprint_is_short_and_stable() {
143        let fp = signer_fingerprint("zSomePubKey");
144        assert_eq!(fp, signer_fingerprint("zSomePubKey"));
145        assert!(fp.len() <= 12 && !fp.is_empty());
146    }
147}