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/// Extract the display-friendly handle from a DID. Handles both legacy
61/// (`did:wire:paul`) and v0.5.7+ (`did:wire:paul-abc12345`) forms. The
62/// v0.5.7 trailing `-<8-hex>` suffix is stripped when present.
63pub fn display_handle_from_did(did: &str) -> &str {
64    let stripped = did.strip_prefix("did:wire:").unwrap_or(did);
65    // v0.5.7+ form: `<handle>-<8-hex>`. Detect by trailing exactly 8 hex
66    // chars after a final `-`. Anything else passes through unchanged.
67    if let Some(idx) = stripped.rfind('-') {
68        let suffix = &stripped[idx + 1..];
69        if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
70            return &stripped[..idx];
71        }
72    }
73    stripped
74}
75
76/// Convenience type — at this stage we use serde_json::Value so the wire
77/// shape stays explicit. A typed struct can come in v0.2+.
78pub type AgentCard = Value;
79
80#[derive(Debug, Error)]
81pub enum CardError {
82    #[error("missing field: {0}")]
83    MissingField(&'static str),
84    #[error("verify_keys is empty or malformed")]
85    NoVerifyKeys,
86    #[error("signature decode failed")]
87    BadSignature,
88    #[error("signature did not verify")]
89    SignatureRejected,
90}
91
92/// Build an unsigned agent card for `handle` with one verify key.
93///
94/// Optional overrides:
95///   - `name`: human-friendly display name (defaults to capitalized handle)
96///   - `capabilities`: list of capability strings (defaults to `["wire/v3.1"]`)
97///   - `max_body_kb`: per-message body cap in KB (defaults to 64)
98///
99/// v0.1 deliberately does NOT include `registries`, `onboard_endpoint`,
100/// `wire_raw_url_template`, or `revoked_at`. Those land in v0.2+ along
101/// with the registry feature itself (see ANTI_FEATURES.md).
102pub fn build_agent_card(
103    handle: &str,
104    public_key: &[u8],
105    name: Option<&str>,
106    capabilities: Option<Vec<String>>,
107    max_body_kb: Option<u64>,
108) -> AgentCard {
109    let display_name = name
110        .map(str::to_string)
111        .unwrap_or_else(|| capitalize(handle));
112    let caps = capabilities.unwrap_or_else(|| vec!["wire/v3.1".to_string()]);
113    let body_kb = max_body_kb.unwrap_or(64);
114
115    let key_id = make_key_id(handle, public_key);
116    let key_id_full = format!("ed25519:{key_id}");
117
118    json!({
119        "schema_version": CARD_SCHEMA_VERSION,
120        "did": did_for_with_key(handle, public_key),
121        "handle": handle,
122        "name": display_name,
123        "capabilities": caps,
124        "verify_keys": {
125            key_id_full: {
126                "key": b64encode(public_key),
127                "alg": "ed25519",
128                "active": true,
129            }
130        },
131        "policies": {
132            "max_message_body_kb": body_kb,
133        }
134    })
135}
136
137/// Capitalize the first character of an ASCII handle (`paul` → `Paul`).
138fn capitalize(s: &str) -> String {
139    let mut chars = s.chars();
140    match chars.next() {
141        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
142        None => String::new(),
143    }
144}
145
146/// Canonical bytes of an agent card — strips `signature` before serialization.
147pub fn card_canonical(card: &AgentCard) -> Vec<u8> {
148    canonical(card, false)
149}
150
151/// Sign an agent card with `private_key`. Returns the card with `signature`
152/// field appended (base64 of Ed25519 signature over `card_canonical(card)`).
153pub fn sign_agent_card(card: &AgentCard, private_key: &[u8]) -> AgentCard {
154    let mut sk_bytes = [0u8; 32];
155    sk_bytes.copy_from_slice(&private_key[..32]);
156    let sk = SigningKey::from_bytes(&sk_bytes);
157    let sig = sk.sign(&card_canonical(card));
158    let mut out = card.as_object().cloned().unwrap_or_default();
159    out.insert(
160        "signature".into(),
161        Value::String(b64encode(&sig.to_bytes())),
162    );
163    Value::Object(out)
164}
165
166/// Verify a signed card. Picks the first verify_key, validates the
167/// signature over `card_canonical(card)` (stripped of `signature`).
168pub fn verify_agent_card(card: &AgentCard) -> Result<(), CardError> {
169    let signature_b64 = card
170        .get("signature")
171        .and_then(Value::as_str)
172        .ok_or(CardError::MissingField("signature"))?;
173
174    let verify_keys = card
175        .get("verify_keys")
176        .and_then(Value::as_object)
177        .ok_or(CardError::MissingField("verify_keys"))?;
178
179    let (_kid, key_record) = verify_keys.iter().next().ok_or(CardError::NoVerifyKeys)?;
180    let pk_b64 = key_record
181        .get("key")
182        .and_then(Value::as_str)
183        .ok_or(CardError::MissingField("verify_keys[*].key"))?;
184    let pk_bytes = b64decode(pk_b64).map_err(|_| CardError::BadSignature)?;
185    if pk_bytes.len() != 32 {
186        return Err(CardError::BadSignature);
187    }
188    let mut pk_arr = [0u8; 32];
189    pk_arr.copy_from_slice(&pk_bytes);
190    let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| CardError::BadSignature)?;
191
192    let sig_bytes = b64decode(signature_b64).map_err(|_| CardError::BadSignature)?;
193    if sig_bytes.len() != 64 {
194        return Err(CardError::BadSignature);
195    }
196    let mut sig_arr = [0u8; 64];
197    sig_arr.copy_from_slice(&sig_bytes);
198    let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
199
200    vk.verify(&card_canonical(card), &sig)
201        .map_err(|_| CardError::SignatureRejected)
202}
203
204/// 6-digit bilateral SAS over two raw 32-byte public keys.
205///
206/// `sha256(min(a, b) || max(a, b))` then take the last 6 decimal digits.
207/// Symmetric in `(a, b)` so either operator computes the same digits from
208/// independent knowledge of both keys.
209pub fn compute_sas(public_key_a: &[u8], public_key_b: &[u8]) -> String {
210    let (lo, hi) = if public_key_a <= public_key_b {
211        (public_key_a, public_key_b)
212    } else {
213        (public_key_b, public_key_a)
214    };
215    let mut h = Sha256::new();
216    h.update(lo);
217    h.update(hi);
218    let digest = h.finalize();
219    // Take low 4 bytes -> u32, mod 1_000_000 for 6 digits.
220    let n = u32::from_be_bytes([digest[28], digest[29], digest[30], digest[31]]);
221    format!("{:06}", n % 1_000_000)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::signing::generate_keypair;
228
229    #[test]
230    fn did_for_handle() {
231        assert_eq!(did_for("paul"), "did:wire:paul");
232    }
233
234    #[test]
235    fn did_for_already_did_passthrough() {
236        assert_eq!(did_for("did:wire:paul"), "did:wire:paul");
237        assert_eq!(did_for("did:key:abc"), "did:key:abc");
238    }
239
240    #[test]
241    fn did_method_constant() {
242        assert_eq!(DID_METHOD, "did:wire");
243    }
244
245    #[test]
246    fn build_minimal_card() {
247        let (_, pk) = generate_keypair();
248        let card = build_agent_card("paul", &pk, None, None, None);
249        assert_eq!(card["schema_version"], CARD_SCHEMA_VERSION);
250        // v0.5.7+: DID is pubkey-suffixed for cross-operator uniqueness.
251        let did = card["did"].as_str().unwrap();
252        assert!(did.starts_with("did:wire:paul-"), "got: {did}");
253        assert_eq!(did.len(), "did:wire:paul-".len() + 8);
254        assert_eq!(card["handle"], "paul");
255        assert_eq!(card["name"], "Paul");
256        let vks = card["verify_keys"].as_object().unwrap();
257        assert_eq!(vks.len(), 1);
258        assert_eq!(card["policies"]["max_message_body_kb"], 64);
259    }
260
261    #[test]
262    fn build_card_with_overrides() {
263        let (_, pk) = generate_keypair();
264        let card = build_agent_card(
265            "carol",
266            &pk,
267            Some("Carol's Agent"),
268            Some(vec!["custom-cap".to_string()]),
269            Some(128),
270        );
271        assert_eq!(card["name"], "Carol's Agent");
272        assert_eq!(card["capabilities"], json!(["custom-cap"]));
273        assert_eq!(card["policies"]["max_message_body_kb"], 128);
274    }
275
276    #[test]
277    fn build_card_does_not_carry_v02_fields() {
278        let (_, pk) = generate_keypair();
279        let card = build_agent_card("paul", &pk, None, None, None);
280        let obj = card.as_object().unwrap();
281        for v02 in [
282            "registries",
283            "onboard_endpoint",
284            "wire_raw_url_template",
285            "revoked_at",
286        ] {
287            assert!(
288                !obj.contains_key(v02),
289                "v0.2+ field {v02} leaked into v0.1 card"
290            );
291        }
292    }
293
294    #[test]
295    fn card_canonical_excludes_signature() {
296        let v = json!({"schema_version": "v3.1", "did": "did:wire:paul", "signature": "sig"});
297        let bytes = card_canonical(&v);
298        assert!(!String::from_utf8_lossy(&bytes).contains("signature"));
299    }
300
301    #[test]
302    fn card_canonical_sort_keys_stable() {
303        let a = json!({"b": 1, "a": 2, "did": "did:wire:paul"});
304        let b = json!({"did": "did:wire:paul", "a": 2, "b": 1});
305        assert_eq!(card_canonical(&a), card_canonical(&b));
306    }
307
308    #[test]
309    fn sign_verify_roundtrip() {
310        let (sk, pk) = generate_keypair();
311        let card = build_agent_card("paul", &pk, None, None, None);
312        let signed = sign_agent_card(&card, &sk);
313        assert!(signed.get("signature").is_some());
314        verify_agent_card(&signed).unwrap();
315    }
316
317    #[test]
318    fn verify_rejects_unsigned_card() {
319        let (_, pk) = generate_keypair();
320        let card = build_agent_card("paul", &pk, None, None, None);
321        let err = verify_agent_card(&card).unwrap_err();
322        assert!(matches!(err, CardError::MissingField("signature")));
323    }
324
325    #[test]
326    fn verify_rejects_tampered_card() {
327        let (sk, pk) = generate_keypair();
328        let mut signed = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
329        signed["name"] = json!("TamperedName");
330        let err = verify_agent_card(&signed).unwrap_err();
331        assert!(matches!(err, CardError::SignatureRejected));
332    }
333
334    #[test]
335    fn verify_rejects_card_with_no_verify_keys() {
336        let (sk, _) = generate_keypair();
337        let card = json!({"schema_version": "v3.1", "did": "did:wire:paul", "verify_keys": {}});
338        let signed = sign_agent_card(&card, &sk);
339        let err = verify_agent_card(&signed).unwrap_err();
340        assert!(matches!(err, CardError::NoVerifyKeys));
341    }
342
343    #[test]
344    fn compute_sas_is_6_digits() {
345        let (_, a) = generate_keypair();
346        let (_, b) = generate_keypair();
347        let sas = compute_sas(&a, &b);
348        assert_eq!(sas.len(), 6);
349        assert!(sas.chars().all(|c| c.is_ascii_digit()));
350    }
351
352    #[test]
353    fn compute_sas_bilateral_symmetric() {
354        let (_, a) = generate_keypair();
355        let (_, b) = generate_keypair();
356        assert_eq!(compute_sas(&a, &b), compute_sas(&b, &a));
357    }
358
359    #[test]
360    fn compute_sas_changes_with_inputs() {
361        let (_, a) = generate_keypair();
362        let (_, b) = generate_keypair();
363        let (_, c) = generate_keypair();
364        assert_ne!(compute_sas(&a, &b), compute_sas(&a, &c));
365    }
366}