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    /// `Some("official")` for bundles published from the official catalog.
42    /// Inside the signed payload: stripping it invalidates the signature.
43    /// Absent on user-created bundles (and skipped in serialization, so
44    /// pre-existing signed bundles keep verifying byte-for-byte).
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub distribution: Option<String>,
47}
48
49/// Lowercase hex SHA-256 of `bytes`.
50pub fn content_hash(bytes: &[u8]) -> String {
51    let mut h = Sha256::new();
52    h.update(bytes);
53    hex::encode(h.finalize())
54}
55
56/// Canonical signing input: the manifest serialized with `sig` cleared. Struct
57/// field order is fixed, so this is deterministic without a custom canonicalizer.
58pub fn manifest_sign_input(m: &BundleManifest) -> Vec<u8> {
59    let mut unsigned = m.clone();
60    unsigned.sig = None;
61    serde_json::to_vec(&unsigned).expect("manifest serializes")
62}
63
64/// Short fingerprint of a multibase pubkey: first 8 hex chars of its SHA-256,
65/// hyphen-grouped (e.g. `ab12-cd34`) for human out-of-band comparison.
66pub fn signer_fingerprint(signer_pubkey_multibase: &str) -> String {
67    let h = content_hash(signer_pubkey_multibase.as_bytes());
68    format!("{}-{}", &h[0..4], &h[4..8])
69}
70
71/// Verify the manifest signature against `pubkey`. Fail-closed: no `sig` → false.
72pub fn verify_manifest_sig(m: &BundleManifest, pubkey: &[u8; 32]) -> bool {
73    let Some(sig) = m.sig.as_deref() else {
74        return false;
75    };
76    crate::identity::verify_bytes(pubkey, &manifest_sign_input(m), sig)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::identity::AgentIdentity;
83
84    fn sample_manifest() -> BundleManifest {
85        BundleManifest {
86            format_version: FLEET_BUNDLE_FORMAT,
87            fleet_name: "devteam".into(),
88            created_at: "2026-06-20T00:00:00Z".into(),
89            signer_pubkey: String::new(),
90            signer_fingerprint: String::new(),
91            includes_members: false,
92            members: vec!["pm".into(), "qa".into()],
93            entries: vec![BundleEntry {
94                path: "fleet.yaml".into(),
95                sha256: content_hash(b"name: devteam\n"),
96            }],
97            sig: None,
98            distribution: None,
99        }
100    }
101
102    #[test]
103    fn content_hash_is_deterministic_hex() {
104        assert_eq!(content_hash(b"abc"), content_hash(b"abc"));
105        assert_ne!(content_hash(b"abc"), content_hash(b"abd"));
106        assert_eq!(content_hash(b"").len(), 64); // sha256 hex
107    }
108
109    #[test]
110    fn manifest_roundtrips_yaml() {
111        let m = sample_manifest();
112        let y = serde_yaml::to_string(&m).unwrap();
113        let back: BundleManifest = serde_yaml::from_str(&y).unwrap();
114        assert_eq!(m, back);
115    }
116
117    #[test]
118    fn sign_then_verify_roundtrip_and_tamper_fails() {
119        let id = AgentIdentity::generate();
120        let mut m = sample_manifest();
121        m.signer_pubkey = id.public_key_multibase();
122        m.signer_fingerprint = signer_fingerprint(&m.signer_pubkey);
123        // sign canonical input (sig must be None during signing)
124        let input = manifest_sign_input(&m);
125        m.sig = Some(multibase::encode(
126            multibase::Base::Base58Btc,
127            id.sign_bytes(&input),
128        ));
129        let pubkey = id.verifying_key_bytes();
130        assert!(verify_manifest_sig(&m, &pubkey));
131
132        // tamper an entry hash → verify fails
133        let mut tampered = m.clone();
134        tampered.entries[0].sha256 = content_hash(b"evil");
135        assert!(!verify_manifest_sig(&tampered, &pubkey));
136
137        // flip the signature → verify fails (fail-closed)
138        let mut badsig = m.clone();
139        badsig.sig = Some(multibase::encode(multibase::Base::Base58Btc, [0u8; 64]));
140        assert!(!verify_manifest_sig(&badsig, &pubkey));
141
142        // missing signature → false
143        let mut unsigned = m.clone();
144        unsigned.sig = None;
145        assert!(!verify_manifest_sig(&unsigned, &pubkey));
146    }
147
148    #[test]
149    fn fingerprint_is_short_and_stable() {
150        let fp = signer_fingerprint("zSomePubKey");
151        assert_eq!(fp, signer_fingerprint("zSomePubKey"));
152        assert!(fp.len() <= 12 && !fp.is_empty());
153    }
154
155    #[test]
156    fn manifest_without_distribution_keeps_legacy_sign_input() {
157        // A manifest with distribution=None must produce byte-identical sign
158        // input to one that predates the field (skip_serializing_if).
159        let m = BundleManifest {
160            format_version: FLEET_BUNDLE_FORMAT,
161            fleet_name: "f".into(),
162            created_at: "2026-01-01T00:00:00Z".into(),
163            signer_pubkey: "z".into(),
164            signer_fingerprint: "aa11-bb22".into(),
165            includes_members: false,
166            members: vec![],
167            entries: vec![],
168            sig: None,
169            distribution: None,
170        };
171        let json = String::from_utf8(manifest_sign_input(&m)).unwrap();
172        assert!(!json.contains("distribution"));
173    }
174
175    #[test]
176    fn stripping_distribution_breaks_signature() {
177        use ed25519_dalek::{Signer, SigningKey};
178        let key = SigningKey::from_bytes(&[9u8; 32]);
179        let mut m = BundleManifest {
180            format_version: FLEET_BUNDLE_FORMAT,
181            fleet_name: "f".into(),
182            created_at: "2026-01-01T00:00:00Z".into(),
183            signer_pubkey: multibase::encode(
184                multibase::Base::Base58Btc,
185                key.verifying_key().as_bytes(),
186            ),
187            signer_fingerprint: "aa11-bb22".into(),
188            includes_members: false,
189            members: vec![],
190            entries: vec![],
191            sig: None,
192            distribution: Some(crate::official::DISTRIBUTION_OFFICIAL.into()),
193        };
194        let sig = key.sign(&manifest_sign_input(&m));
195        m.sig = Some(multibase::encode(
196            multibase::Base::Base58Btc,
197            sig.to_bytes(),
198        ));
199        let pk = key.verifying_key().to_bytes();
200        assert!(verify_manifest_sig(&m, &pk));
201        // Strip the marker → signature must fail.
202        m.distribution = None;
203        assert!(!verify_manifest_sig(&m, &pk));
204    }
205}