Skip to main content

agentmux_srv/backend/
widget_signature.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Signed widget packages (docs/specs/SPEC_WIDGET_SHARING_2026_10_10.md §2).
5//!
6//! A package may carry `widget.sig`: an Ed25519 signature by its publisher
7//! over its id, version and content hash. The content hash leaves
8//! `widget.sig` out, so signing doesn't change what was approved. srv pins
9//! each publisher (the part of the id before the dot) to the first key the
10//! user approved for it, and reports any later package of that publisher
11//! signed by another key, or not signed, as `key_changed`.
12
13use 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
34/// The bytes a publisher signs.
35pub 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
45/// `widget.sig` in `dir`: `None` when there is none, the publisher's public
46/// key (base64) when it verifies, or why it doesn't.
47pub 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
81/// A key as people compare it: the first 10 bytes of its SHA-256, base32,
82/// in groups of four (`K7Q2-MZ4D-PX3A-9TWE`).
83pub 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
92/// RFC 4648 base32, no padding.
93fn 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
111/// The publisher part of a package id (`acme` of `acme.pr-dashboard`).
112pub fn publisher_of(id: &str) -> &str {
113    id.split_once('.').map_or(id, |(p, _)| p)
114}
115
116// ── What the UI sees ────────────────────────────────────────────────────────
117
118#[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 by the key this publisher is pinned to.
123    Signed,
124    /// Signed; the first package of this publisher here.
125    SignedNew,
126    Unsigned,
127    /// Signed by another key than the pinned one, or not signed while the
128    /// publisher is pinned.
129    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    /// The fingerprint of the key that signed it.
138    pub fingerprint: Option<String>,
139    /// The fingerprint of the key this publisher is pinned to.
140    pub pinned: Option<String>,
141}
142
143/// A publisher pinned to a key, for Settings.
144#[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
151/// publisher → public key (base64).
152pub 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    /// A `widget.sig` for `id`/`version`/`hash`, signed by the key from `seed`.
185    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    /// The fixture the SDK's `agentmux-widget sign` signed with the same test
218    /// seed: both sides must agree on its hash, its signature and its
219    /// fingerprint (frontend/app/block/widget-sign.test.ts checks the SDK's).
220    #[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}