Skip to main content

wire/
agent_card.rs

1//! Agent card — DID-anchored identity for a wire endpoint.
2//!
3//! An agent card binds:
4//!   - a handle (`paul`)
5//!   - to a DID (`did:wire:paul`)
6//!   - to one or more Ed25519 verify keys
7//!   - with a signature from the canonical key
8//!
9//! Bilateral pairing produces a 6-digit Short Authentication String (SAS) by
10//! HMAC'ing the two sorted public keys. Both peers compute the same digits
11//! independently from their own knowledge of both keys; the operator reads
12//! them aloud out-of-band (the magic-wormhole flow) to confirm.
13
14use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
15use serde_json::{Value, json};
16use sha2::{Digest, Sha256};
17use thiserror::Error;
18
19use crate::canonical::canonical;
20use crate::signing::{b64decode, b64encode, make_key_id};
21
22pub const CARD_SCHEMA_VERSION: &str = "v3.1";
23pub const DID_METHOD: &str = "did:wire";
24
25/// Build a DID from `handle` + `public_key`. Returns
26/// `did:wire:<handle>-<8-hex-of-sha256(public_key)>`. The pubkey suffix
27/// makes the DID uniquely tied to the keypair — two operators picking
28/// the same handle (e.g., both auto-init'ing as `<hostname>` on the same
29/// hostname) get distinct DIDs.
30///
31/// Pass-through for any string already starting with `did:*` (so callers
32/// can be lazy with mixed inputs).
33///
34/// Backward-compat: legacy DIDs of the form `did:wire:<handle>` (no
35/// pubkey suffix) shipped pre-v0.5.7. They still verify because signature
36/// verification reads the pubkey from `verify_keys`, not from the DID
37/// string. They're just non-unique across operators picking the same
38/// handle — the v0.5.7 cohort onward gets uniqueness by construction.
39pub fn did_for_with_key(handle: &str, public_key: &[u8]) -> String {
40    if handle.starts_with("did:") {
41        return handle.to_string();
42    }
43    let suffix = crate::signing::fingerprint(public_key);
44    format!("{DID_METHOD}:{handle}-{suffix}")
45}
46
47/// Legacy DID constructor — DID = `did:wire:<handle>` with no pubkey
48/// suffix. Pre-v0.5.7 model. Kept for backward-compat in code paths
49/// that don't have the pubkey on hand (display helpers, test fixtures)
50/// and for tests that pin specific DID strings. NEW callers should use
51/// `did_for_with_key`.
52pub fn did_for(handle: &str) -> String {
53    if handle.starts_with("did:") {
54        handle.to_string()
55    } else {
56        format!("{DID_METHOD}:{handle}")
57    }
58}
59
60/// Strip the federation suffix (`@relay.example`) from a handle, returning
61/// the bare local-part. This is the canonical on-disk form: outbox/inbox
62/// files are keyed by bare handle (`paul-mac.jsonl`), and the pinned-peers
63/// map in `relay_state.json` is keyed by bare handle.
64///
65/// Why this exists (v0.5.13): `wire send paul-mac@wireup.net "..."` used
66/// to write the outbox to `paul-mac@wireup.net.jsonl`, but `wire push`
67/// only enumerated bare-handle filenames. Events stuck silently for 25
68/// minutes (issue #2). Normalizing here makes the on-disk contract the
69/// single source of truth — accepts both `paul-mac` and `paul-mac@host`,
70/// always writes to `paul-mac.jsonl`.
71pub fn bare_handle(handle: &str) -> &str {
72    handle.split_once('@').map(|(n, _)| n).unwrap_or(handle)
73}
74
75/// Extract the display-friendly handle from a DID. Handles both legacy
76/// (`did:wire:paul`) and v0.5.7+ (`did:wire:paul-abc12345`) forms. The
77/// v0.5.7 trailing `-<8-hex>` suffix is stripped when present.
78pub fn display_handle_from_did(did: &str) -> &str {
79    let stripped = did.strip_prefix("did:wire:").unwrap_or(did);
80    // v0.5.7+ form: `<handle>-<8-hex>`. Detect by trailing exactly 8 hex
81    // chars after a final `-`. Anything else passes through unchanged.
82    if let Some(idx) = stripped.rfind('-') {
83        let suffix = &stripped[idx + 1..];
84        if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
85            return &stripped[..idx];
86        }
87    }
88    stripped
89}
90
91/// Convenience type — at this stage we use serde_json::Value so the wire
92/// shape stays explicit. A typed struct can come in v0.2+.
93pub type AgentCard = Value;
94
95#[derive(Debug, Error)]
96pub enum CardError {
97    #[error("missing field: {0}")]
98    MissingField(&'static str),
99    #[error("verify_keys is empty or malformed")]
100    NoVerifyKeys,
101    #[error("signature decode failed")]
102    BadSignature,
103    #[error("signature did not verify")]
104    SignatureRejected,
105}
106
107/// Build an unsigned agent card for `handle` with one verify key.
108///
109/// Optional overrides:
110///   - `name`: human-friendly display name (defaults to capitalized handle)
111///   - `capabilities`: list of capability strings (defaults to `["wire/v3.1"]`)
112///   - `max_body_kb`: per-message body cap in KB (defaults to 64)
113///
114/// v0.1 deliberately does NOT include `registries`, `onboard_endpoint`,
115/// `wire_raw_url_template`, or `revoked_at`. Those land in v0.2+ along
116/// with the registry feature itself (see ANTI_FEATURES.md).
117pub fn build_agent_card(
118    handle: &str,
119    public_key: &[u8],
120    name: Option<&str>,
121    capabilities: Option<Vec<String>>,
122    max_body_kb: Option<u64>,
123) -> AgentCard {
124    let display_name = name
125        .map(str::to_string)
126        .unwrap_or_else(|| capitalize(handle));
127    let caps = capabilities.unwrap_or_else(|| vec!["wire/v3.1".to_string()]);
128    let body_kb = max_body_kb.unwrap_or(64);
129
130    let key_id = make_key_id(handle, public_key);
131    let key_id_full = format!("ed25519:{key_id}");
132
133    json!({
134        "schema_version": CARD_SCHEMA_VERSION,
135        "did": did_for_with_key(handle, public_key),
136        "handle": handle,
137        "name": display_name,
138        "capabilities": caps,
139        "verify_keys": {
140            key_id_full: {
141                "key": b64encode(public_key),
142                "alg": "ed25519",
143                "active": true,
144            }
145        },
146        "policies": {
147            "max_message_body_kb": body_kb,
148        }
149    })
150}
151
152/// Capitalize the first character of an ASCII handle (`paul` → `Paul`).
153fn capitalize(s: &str) -> String {
154    let mut chars = s.chars();
155    match chars.next() {
156        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
157        None => String::new(),
158    }
159}
160
161/// Canonical bytes of an agent card — strips `signature` before serialization.
162pub fn card_canonical(card: &AgentCard) -> Vec<u8> {
163    canonical(card, false)
164}
165
166/// Sign an agent card with `private_key`. Returns the card with `signature`
167/// field appended (base64 of Ed25519 signature over `card_canonical(card)`).
168pub fn sign_agent_card(card: &AgentCard, private_key: &[u8]) -> AgentCard {
169    let mut sk_bytes = [0u8; 32];
170    sk_bytes.copy_from_slice(&private_key[..32]);
171    let sk = SigningKey::from_bytes(&sk_bytes);
172    let sig = sk.sign(&card_canonical(card));
173    let mut out = card.as_object().cloned().unwrap_or_default();
174    out.insert(
175        "signature".into(),
176        Value::String(b64encode(&sig.to_bytes())),
177    );
178    Value::Object(out)
179}
180
181/// Verify a signed card. Picks the first verify_key, validates the
182/// signature over `card_canonical(card)` (stripped of `signature`).
183pub fn verify_agent_card(card: &AgentCard) -> Result<(), CardError> {
184    let signature_b64 = card
185        .get("signature")
186        .and_then(Value::as_str)
187        .ok_or(CardError::MissingField("signature"))?;
188
189    let verify_keys = card
190        .get("verify_keys")
191        .and_then(Value::as_object)
192        .ok_or(CardError::MissingField("verify_keys"))?;
193
194    let (_kid, key_record) = verify_keys.iter().next().ok_or(CardError::NoVerifyKeys)?;
195    let pk_b64 = key_record
196        .get("key")
197        .and_then(Value::as_str)
198        .ok_or(CardError::MissingField("verify_keys[*].key"))?;
199    let pk_bytes = b64decode(pk_b64).map_err(|_| CardError::BadSignature)?;
200    if pk_bytes.len() != 32 {
201        return Err(CardError::BadSignature);
202    }
203    let mut pk_arr = [0u8; 32];
204    pk_arr.copy_from_slice(&pk_bytes);
205    let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| CardError::BadSignature)?;
206
207    let sig_bytes = b64decode(signature_b64).map_err(|_| CardError::BadSignature)?;
208    if sig_bytes.len() != 64 {
209        return Err(CardError::BadSignature);
210    }
211    let mut sig_arr = [0u8; 64];
212    sig_arr.copy_from_slice(&sig_bytes);
213    let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
214
215    vk.verify(&card_canonical(card), &sig)
216        .map_err(|_| CardError::SignatureRejected)
217}
218
219/// 6-digit bilateral SAS over two raw 32-byte public keys.
220///
221/// `sha256(min(a, b) || max(a, b))` then take the last 6 decimal digits.
222/// Symmetric in `(a, b)` so either operator computes the same digits from
223/// independent knowledge of both keys.
224pub fn compute_sas(public_key_a: &[u8], public_key_b: &[u8]) -> String {
225    let (lo, hi) = if public_key_a <= public_key_b {
226        (public_key_a, public_key_b)
227    } else {
228        (public_key_b, public_key_a)
229    };
230    let mut h = Sha256::new();
231    h.update(lo);
232    h.update(hi);
233    let digest = h.finalize();
234    // Take low 4 bytes -> u32, mod 1_000_000 for 6 digits.
235    let n = u32::from_be_bytes([digest[28], digest[29], digest[30], digest[31]]);
236    format!("{:06}", n % 1_000_000)
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::signing::generate_keypair;
243
244    #[test]
245    fn did_for_handle() {
246        assert_eq!(did_for("paul"), "did:wire:paul");
247    }
248
249    #[test]
250    fn did_for_already_did_passthrough() {
251        assert_eq!(did_for("did:wire:paul"), "did:wire:paul");
252        assert_eq!(did_for("did:key:abc"), "did:key:abc");
253    }
254
255    #[test]
256    fn did_method_constant() {
257        assert_eq!(DID_METHOD, "did:wire");
258    }
259
260    #[test]
261    fn build_minimal_card() {
262        let (_, pk) = generate_keypair();
263        let card = build_agent_card("paul", &pk, None, None, None);
264        assert_eq!(card["schema_version"], CARD_SCHEMA_VERSION);
265        // v0.5.7+: DID is pubkey-suffixed for cross-operator uniqueness.
266        let did = card["did"].as_str().unwrap();
267        assert!(did.starts_with("did:wire:paul-"), "got: {did}");
268        assert_eq!(did.len(), "did:wire:paul-".len() + 8);
269        assert_eq!(card["handle"], "paul");
270        assert_eq!(card["name"], "Paul");
271        let vks = card["verify_keys"].as_object().unwrap();
272        assert_eq!(vks.len(), 1);
273        assert_eq!(card["policies"]["max_message_body_kb"], 64);
274    }
275
276    #[test]
277    fn build_card_with_overrides() {
278        let (_, pk) = generate_keypair();
279        let card = build_agent_card(
280            "carol",
281            &pk,
282            Some("Carol's Agent"),
283            Some(vec!["custom-cap".to_string()]),
284            Some(128),
285        );
286        assert_eq!(card["name"], "Carol's Agent");
287        assert_eq!(card["capabilities"], json!(["custom-cap"]));
288        assert_eq!(card["policies"]["max_message_body_kb"], 128);
289    }
290
291    #[test]
292    fn build_card_does_not_carry_v02_fields() {
293        let (_, pk) = generate_keypair();
294        let card = build_agent_card("paul", &pk, None, None, None);
295        let obj = card.as_object().unwrap();
296        for v02 in [
297            "registries",
298            "onboard_endpoint",
299            "wire_raw_url_template",
300            "revoked_at",
301        ] {
302            assert!(
303                !obj.contains_key(v02),
304                "v0.2+ field {v02} leaked into v0.1 card"
305            );
306        }
307    }
308
309    #[test]
310    fn card_canonical_excludes_signature() {
311        let v = json!({"schema_version": "v3.1", "did": "did:wire:paul", "signature": "sig"});
312        let bytes = card_canonical(&v);
313        assert!(!String::from_utf8_lossy(&bytes).contains("signature"));
314    }
315
316    #[test]
317    fn card_canonical_sort_keys_stable() {
318        let a = json!({"b": 1, "a": 2, "did": "did:wire:paul"});
319        let b = json!({"did": "did:wire:paul", "a": 2, "b": 1});
320        assert_eq!(card_canonical(&a), card_canonical(&b));
321    }
322
323    #[test]
324    fn sign_verify_roundtrip() {
325        let (sk, pk) = generate_keypair();
326        let card = build_agent_card("paul", &pk, None, None, None);
327        let signed = sign_agent_card(&card, &sk);
328        assert!(signed.get("signature").is_some());
329        verify_agent_card(&signed).unwrap();
330    }
331
332    #[test]
333    fn verify_rejects_unsigned_card() {
334        let (_, pk) = generate_keypair();
335        let card = build_agent_card("paul", &pk, None, None, None);
336        let err = verify_agent_card(&card).unwrap_err();
337        assert!(matches!(err, CardError::MissingField("signature")));
338    }
339
340    #[test]
341    fn verify_rejects_tampered_card() {
342        let (sk, pk) = generate_keypair();
343        let mut signed = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
344        signed["name"] = json!("TamperedName");
345        let err = verify_agent_card(&signed).unwrap_err();
346        assert!(matches!(err, CardError::SignatureRejected));
347    }
348
349    #[test]
350    fn verify_rejects_card_with_no_verify_keys() {
351        let (sk, _) = generate_keypair();
352        let card = json!({"schema_version": "v3.1", "did": "did:wire:paul", "verify_keys": {}});
353        let signed = sign_agent_card(&card, &sk);
354        let err = verify_agent_card(&signed).unwrap_err();
355        assert!(matches!(err, CardError::NoVerifyKeys));
356    }
357
358    #[test]
359    fn compute_sas_is_6_digits() {
360        let (_, a) = generate_keypair();
361        let (_, b) = generate_keypair();
362        let sas = compute_sas(&a, &b);
363        assert_eq!(sas.len(), 6);
364        assert!(sas.chars().all(|c| c.is_ascii_digit()));
365    }
366
367    #[test]
368    fn compute_sas_bilateral_symmetric() {
369        let (_, a) = generate_keypair();
370        let (_, b) = generate_keypair();
371        assert_eq!(compute_sas(&a, &b), compute_sas(&b, &a));
372    }
373
374    #[test]
375    fn compute_sas_changes_with_inputs() {
376        let (_, a) = generate_keypair();
377        let (_, b) = generate_keypair();
378        let (_, c) = generate_keypair();
379        assert_ne!(compute_sas(&a, &b), compute_sas(&a, &c));
380    }
381}