1use std::collections::BTreeMap;
14use std::path::Path;
15
16use base64::Engine;
17use ed25519_dalek::{Signature, Verifier, VerifyingKey};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20
21pub const SIG_FILE: &str = "widget.sig";
22const SIG_VERSION: u32 = 1;
23const CONTEXT: &[u8] = b"agentmux-widget-sig-v1\0";
24
25#[derive(Debug, Deserialize)]
26#[serde(rename_all = "camelCase")]
27struct SigFile {
28 sig_version: u32,
29 algorithm: String,
30 public_key: String,
31 signature: String,
32}
33
34pub fn signed_message(id: &str, version: &str, hash: &str) -> Vec<u8> {
36 let mut m = CONTEXT.to_vec();
37 m.extend_from_slice(id.as_bytes());
38 m.push(0);
39 m.extend_from_slice(version.as_bytes());
40 m.push(0);
41 m.extend_from_slice(hash.as_bytes());
42 m
43}
44
45pub fn check(dir: &Path, id: &str, version: &str, hash: &str) -> Option<Result<String, String>> {
48 let text = match std::fs::read_to_string(dir.join(SIG_FILE)) {
49 Ok(t) => t,
50 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
51 Err(e) => return Some(Err(format!("can't read {SIG_FILE}: {e}"))),
52 };
53 Some(verify(&text, id, version, hash))
54}
55
56fn verify(text: &str, id: &str, version: &str, hash: &str) -> Result<String, String> {
57 let sig: SigFile = serde_json::from_str(text).map_err(|e| format!("{SIG_FILE}: {e}"))?;
58 if sig.sig_version != SIG_VERSION {
59 return Err(format!("{SIG_FILE} is version {}, this AgentMux reads {SIG_VERSION}", sig.sig_version));
60 }
61 if sig.algorithm != "ed25519" {
62 return Err(format!("{SIG_FILE} uses {:?}, this AgentMux reads ed25519", sig.algorithm));
63 }
64 let b64 = base64::engine::general_purpose::STANDARD;
65 let key: [u8; 32] = b64
66 .decode(sig.public_key.trim())
67 .ok()
68 .and_then(|k| k.try_into().ok())
69 .ok_or("publicKey isn't a 32-byte base64 key")?;
70 let signature: [u8; 64] = b64
71 .decode(sig.signature.trim())
72 .ok()
73 .and_then(|s| s.try_into().ok())
74 .ok_or("signature isn't 64 bytes of base64")?;
75 let key = VerifyingKey::from_bytes(&key).map_err(|_| "publicKey isn't a valid Ed25519 key")?;
76 key.verify(&signed_message(id, version, hash), &Signature::from_bytes(&signature))
77 .map_err(|_| "its signature doesn't match its files".to_string())?;
78 Ok(b64.encode(key.as_bytes()))
79}
80
81pub fn fingerprint(public_key_b64: &str) -> String {
84 let Ok(key) = base64::engine::general_purpose::STANDARD.decode(public_key_b64) else {
85 return String::new();
86 };
87 let digest = Sha256::digest(&key);
88 let text = base32(&digest[..10]);
89 text.as_bytes().chunks(4).map(|c| String::from_utf8_lossy(c).into_owned()).collect::<Vec<_>>().join("-")
90}
91
92fn base32(bytes: &[u8]) -> String {
94 const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
95 let mut out = String::new();
96 let (mut buf, mut bits) = (0u32, 0u32);
97 for &b in bytes {
98 buf = (buf << 8) | u32::from(b);
99 bits += 8;
100 while bits >= 5 {
101 bits -= 5;
102 out.push(ALPHABET[((buf >> bits) & 31) as usize] as char);
103 }
104 }
105 if bits > 0 {
106 out.push(ALPHABET[((buf << (5 - bits)) & 31) as usize] as char);
107 }
108 out
109}
110
111pub fn publisher_of(id: &str) -> &str {
113 id.split_once('.').map_or(id, |(p, _)| p)
114}
115
116#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, ts_rs::TS)]
119#[serde(rename_all = "snake_case")]
120#[ts(export, export_to = "../../../frontend/types/rpc/")]
121pub enum SignatureState {
122 Signed,
124 SignedNew,
126 Unsigned,
127 KeyChanged,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
133#[ts(export, export_to = "../../../frontend/types/rpc/")]
134pub struct WidgetSignatureInfo {
135 pub state: SignatureState,
136 pub publisher: String,
137 pub fingerprint: Option<String>,
139 pub pinned: Option<String>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
145#[ts(export, export_to = "../../../frontend/types/rpc/")]
146pub struct WidgetPublisherPin {
147 pub publisher: String,
148 pub fingerprint: String,
149}
150
151pub type Pins = BTreeMap<String, String>;
153
154pub fn describe(id: &str, signer: Option<&str>, pins: &Pins) -> WidgetSignatureInfo {
155 let publisher = publisher_of(id).to_string();
156 let pinned = pins.get(&publisher);
157 let state = match (signer, pinned) {
158 (Some(k), Some(p)) if k == p => SignatureState::Signed,
159 (Some(_), Some(_)) | (None, Some(_)) => SignatureState::KeyChanged,
160 (Some(_), None) => SignatureState::SignedNew,
161 (None, None) => SignatureState::Unsigned,
162 };
163 WidgetSignatureInfo { state, publisher, fingerprint: signer.map(fingerprint), pinned: pinned.map(|p| fingerprint(p)) }
164}
165
166pub fn read_pins(path: &Path) -> Pins {
167 std::fs::read_to_string(path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
168}
169
170pub fn write_pins(path: &Path, pins: &Pins) -> Result<(), String> {
171 if let Some(dir) = path.parent() {
172 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
173 }
174 let tmp = path.with_extension("json.tmp");
175 std::fs::write(&tmp, serde_json::to_vec_pretty(pins).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
176 std::fs::rename(&tmp, path).map_err(|e| e.to_string())
177}
178
179#[cfg(test)]
180pub mod test_keys {
181 use base64::Engine;
182 use ed25519_dalek::{Signer, SigningKey};
183
184 pub fn sig_file(seed: u8, id: &str, version: &str, hash: &str) -> String {
186 let key = SigningKey::from_bytes(&[seed; 32]);
187 let b64 = base64::engine::general_purpose::STANDARD;
188 let sig = key.sign(&super::signed_message(id, version, hash));
189 serde_json::json!({
190 "sigVersion": 1,
191 "algorithm": "ed25519",
192 "publicKey": b64.encode(key.verifying_key().as_bytes()),
193 "signature": b64.encode(sig.to_bytes()),
194 })
195 .to_string()
196 }
197
198 pub fn public_key(seed: u8) -> String {
199 base64::engine::general_purpose::STANDARD.encode(SigningKey::from_bytes(&[seed; 32]).verifying_key().as_bytes())
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn a_signature_verifies_only_for_its_own_id_version_and_hash() {
209 let sig = test_keys::sig_file(7, "acme.x", "1.0.0", "abc");
210 assert_eq!(verify(&sig, "acme.x", "1.0.0", "abc").unwrap(), test_keys::public_key(7));
211 assert!(verify(&sig, "acme.x", "1.0.1", "abc").is_err());
212 assert!(verify(&sig, "acme.x", "1.0.0", "abd").is_err());
213 assert!(verify(&sig, "evil.x", "1.0.0", "abc").is_err());
214 assert!(verify("{}", "acme.x", "1.0.0", "abc").is_err());
215 }
216
217 #[test]
221 fn the_sdk_signed_fixture_verifies_here() {
222 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../sdk/widget-sdk/fixtures/acme.fixture");
223 let files = crate::backend::widget_packages::hash_files(&dir).unwrap();
224 assert!(!files.contains_key(SIG_FILE));
225 let hash = crate::backend::widget_packages::package_hash(&files);
226 assert_eq!(hash, "2f480223aad83d3e220179f1e67c8bd660d5bec197cd334b71f514661018b202");
227 assert_eq!(check(&dir, "acme.fixture", "1.0.0", &hash).unwrap().unwrap(), test_keys::public_key(7));
228 assert_eq!(fingerprint(&test_keys::public_key(7)), "72AS-YEXT-VNGO-NLC5");
229 }
230
231 #[test]
232 fn fingerprints_are_grouped_base32() {
233 let f = fingerprint(&test_keys::public_key(7));
234 assert_eq!(f.len(), 19);
235 assert_eq!(f.matches('-').count(), 3);
236 assert!(f.chars().all(|c| c == '-' || c.is_ascii_uppercase() || ('2'..='7').contains(&c)));
237 assert_ne!(f, fingerprint(&test_keys::public_key(8)));
238 assert_eq!(base32(b"foobar"), "MZXW6YTBOI");
239 }
240
241 #[test]
242 fn a_pinned_publisher_flags_another_key_or_no_signature() {
243 let (k1, k2) = (test_keys::public_key(1), test_keys::public_key(2));
244 let mut pins = Pins::new();
245 assert_eq!(describe("acme.x", Some(&k1), &pins).state, SignatureState::SignedNew);
246 assert_eq!(describe("acme.x", None, &pins).state, SignatureState::Unsigned);
247 pins.insert("acme".into(), k1.clone());
248 assert_eq!(describe("acme.y", Some(&k1), &pins).state, SignatureState::Signed);
249 assert_eq!(describe("acme.y", Some(&k2), &pins).state, SignatureState::KeyChanged);
250 assert_eq!(describe("acme.y", None, &pins).state, SignatureState::KeyChanged);
251 assert_eq!(describe("other.y", None, &pins).state, SignatureState::Unsigned);
252 }
253}