mur_common/
fleet_bundle.rs1use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10pub const FLEET_BUNDLE_FORMAT: u32 = 1;
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct BundleEntry {
16 pub path: String,
18 pub sha256: String,
20}
21
22#[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 pub signer_pubkey: String,
30 pub signer_fingerprint: String,
32 pub includes_members: bool,
33 pub members: Vec<String>,
35 pub entries: Vec<BundleEntry>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub sig: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub distribution: Option<String>,
47}
48
49pub fn content_hash(bytes: &[u8]) -> String {
51 let mut h = Sha256::new();
52 h.update(bytes);
53 hex::encode(h.finalize())
54}
55
56pub 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
64pub 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
71pub 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); }
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 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 let mut tampered = m.clone();
134 tampered.entries[0].sha256 = content_hash(b"evil");
135 assert!(!verify_manifest_sig(&tampered, &pubkey));
136
137 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 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 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 m.distribution = None;
203 assert!(!verify_manifest_sig(&m, &pk));
204 }
205}