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}
42
43pub fn content_hash(bytes: &[u8]) -> String {
45 let mut h = Sha256::new();
46 h.update(bytes);
47 hex::encode(h.finalize())
48}
49
50pub 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
58pub 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
65pub 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); }
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 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 let mut tampered = m.clone();
127 tampered.entries[0].sha256 = content_hash(b"evil");
128 assert!(!verify_manifest_sig(&tampered, &pubkey));
129
130 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 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}