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.2";
23pub const DID_METHOD: &str = "did:wire";
24
25/// DID method prefix for operator anchor (RFC-001 §1). Distinct from
26/// `did:wire:` session DIDs so a session DID and an operator DID can
27/// never be confused at parse time.
28pub const DID_METHOD_OP: &str = "did:wire:op";
29
30/// DID method prefix for organization anchor (RFC-001 §1).
31pub const DID_METHOD_ORG: &str = "did:wire:org";
32
33/// Length of the hex tail on op_did / org_did (RFC-001 §1). 32 hex
34/// (128 bits) makes collision search 2^128, much harder than session
35/// DID's 2^32 — appropriate for long-lived identities that anchor
36/// trust scopes rather than ephemeral sessions.
37pub const LONG_FINGERPRINT_HEX_LEN: usize = 32;
38
39/// Build a DID from `handle` + `public_key`. Returns
40/// `did:wire:<handle>-<8-hex-of-sha256(public_key)>`. The pubkey suffix
41/// makes the DID uniquely tied to the keypair — two operators picking
42/// the same handle (e.g., both auto-init'ing as `<hostname>` on the same
43/// hostname) get distinct DIDs.
44///
45/// Pass-through for any string already starting with `did:*` (so callers
46/// can be lazy with mixed inputs).
47pub fn did_for_with_key(handle: &str, public_key: &[u8]) -> String {
48    if handle.starts_with("did:") {
49        return handle.to_string();
50    }
51    let suffix = crate::signing::fingerprint(public_key);
52    format!("{DID_METHOD}:{handle}-{suffix}")
53}
54
55/// Build an operator DID (`did:wire:op:<handle>-<32hex>`). RFC-001
56/// §1 calls for a 32-hex tail (16 bytes of sha256(pubkey)) so the
57/// long-lived operator anchor is collision-resistant at 2^128.
58///
59/// Pass-through for any string already starting with `did:wire:op:`
60/// so callers can be lazy with mixed inputs.
61pub fn did_for_op(handle: &str, public_key: &[u8]) -> String {
62    if handle.starts_with("did:wire:op:") {
63        return handle.to_string();
64    }
65    let suffix = long_fingerprint(public_key);
66    format!("{DID_METHOD_OP}:{handle}-{suffix}")
67}
68
69/// Build an organization DID (`did:wire:org:<handle>-<32hex>`). Same
70/// construction as `did_for_op` but under the org prefix; org_dids
71/// gate the eased-pair surface, so they share the longer hex tail.
72pub fn did_for_org(handle: &str, public_key: &[u8]) -> String {
73    if handle.starts_with("did:wire:org:") {
74        return handle.to_string();
75    }
76    let suffix = long_fingerprint(public_key);
77    format!("{DID_METHOD_ORG}:{handle}-{suffix}")
78}
79
80/// 32-hex (16-byte) fingerprint over the public key for op/org DIDs.
81/// Wider than `signing::fingerprint` (which returns 8 hex / 4 bytes)
82/// because op/org identities are long-lived and grant trust scope.
83pub fn long_fingerprint(public_key: &[u8]) -> String {
84    let digest = Sha256::digest(public_key);
85    hex::encode(&digest[..16])
86}
87
88/// True iff `did` is a well-formed `did:wire:op:<handle>-<32hex>`.
89/// Used at card-validation time to refuse a `did:wire:` session DID
90/// mistakenly placed in the `op_did` slot (and vice versa).
91pub fn is_op_did(did: &str) -> bool {
92    let Some(rest) = did.strip_prefix("did:wire:op:") else {
93        return false;
94    };
95    has_long_hex_suffix(rest)
96}
97
98/// True iff `did` is a well-formed `did:wire:org:<handle>-<32hex>`.
99pub fn is_org_did(did: &str) -> bool {
100    let Some(rest) = did.strip_prefix("did:wire:org:") else {
101        return false;
102    };
103    has_long_hex_suffix(rest)
104}
105
106fn has_long_hex_suffix(s: &str) -> bool {
107    let Some(idx) = s.rfind('-') else {
108        return false;
109    };
110    let suffix = &s[idx + 1..];
111    suffix.len() == LONG_FINGERPRINT_HEX_LEN && suffix.chars().all(|c| c.is_ascii_hexdigit())
112}
113
114/// True iff a session `did:wire:<handle>-<8hex>` actually commits to
115/// `public_key` — i.e. its trailing 8-hex fingerprint equals
116/// `signing::fingerprint(public_key)`.
117///
118/// This is the binding that makes a session DID self-certifying: without
119/// it, `verify_agent_card` only proves "this card is self-signed by
120/// SOME key", not "this card's DID belongs to that key". An attacker
121/// could otherwise self-sign a card claiming any victim's DID with an
122/// attacker-controlled key (the 32-bit suffix is brute-forceable for a
123/// targeted second-preimage; this check raises forgery from "free" to
124/// "must collide the fingerprint"). op/org DIDs use the wider
125/// `commits_to` (org_membership.rs); this is the session-DID analog.
126pub fn did_commits_to_key(did: &str, public_key: &[u8]) -> bool {
127    // Session DIDs are `did:wire:<handle>-<8hex>`. Reject op/org method
128    // prefixes here — those carry a 32-hex suffix and are bound elsewhere.
129    let Some(rest) = did.strip_prefix(&format!("{DID_METHOD}:")) else {
130        return false;
131    };
132    if rest.starts_with("op:") || rest.starts_with("org:") {
133        return false;
134    }
135    let Some(idx) = rest.rfind('-') else {
136        return false;
137    };
138    let suffix = &rest[idx + 1..];
139    suffix == crate::signing::fingerprint(public_key)
140}
141
142/// Strip the federation suffix (`@relay.example`) from a handle, returning
143/// the bare local-part. This is the canonical on-disk form: outbox/inbox
144/// files are keyed by bare handle (`paul-mac.jsonl`), and the pinned-peers
145/// map in `relay_state.json` is keyed by bare handle.
146///
147/// Why this exists (v0.5.13): `wire send paul-mac@wireup.net "..."` used
148/// to write the outbox to `paul-mac@wireup.net.jsonl`, but `wire push`
149/// only enumerated bare-handle filenames. Events stuck silently for 25
150/// minutes (issue #2). Normalizing here makes the on-disk contract the
151/// single source of truth — accepts both `paul-mac` and `paul-mac@host`,
152/// always writes to `paul-mac.jsonl`.
153pub fn bare_handle(handle: &str) -> &str {
154    handle.split_once('@').map(|(n, _)| n).unwrap_or(handle)
155}
156
157/// Extract the display-friendly handle from a DID. Handles both legacy
158/// (`did:wire:paul`) and v0.5.7+ (`did:wire:paul-abc12345`) forms. The
159/// v0.5.7 trailing `-<8-hex>` suffix is stripped when present.
160pub fn display_handle_from_did(did: &str) -> &str {
161    let stripped = did.strip_prefix("did:wire:").unwrap_or(did);
162    // v0.5.7+ form: `<handle>-<8-hex>`. Detect by trailing exactly 8 hex
163    // chars after a final `-`. Anything else passes through unchanged.
164    if let Some(idx) = stripped.rfind('-') {
165        let suffix = &stripped[idx + 1..];
166        if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
167            return &stripped[..idx];
168        }
169    }
170    stripped
171}
172
173/// Convenience type — at this stage we use serde_json::Value so the wire
174/// shape stays explicit. A typed struct can come in v0.2+.
175pub type AgentCard = Value;
176
177#[derive(Debug, Error)]
178pub enum CardError {
179    #[error("missing field: {0}")]
180    MissingField(&'static str),
181    #[error("verify_keys is empty or malformed")]
182    NoVerifyKeys,
183    #[error("signature decode failed")]
184    BadSignature,
185    #[error("signature did not verify")]
186    SignatureRejected,
187    #[error("card DID does not commit to its verify key (suffix mismatch)")]
188    DidKeyMismatch,
189}
190
191/// Build an unsigned agent card for `handle` with one verify key.
192///
193/// Optional overrides:
194///   - `name`: human-friendly display name (defaults to capitalized handle)
195///   - `capabilities`: list of capability strings (defaults to `["wire/v3.2"]`)
196///   - `max_body_kb`: per-message body cap in KB (defaults to 64)
197///
198/// v0.1 deliberately does NOT include `registries`, `onboard_endpoint`,
199/// `wire_raw_url_template`, or `revoked_at`. Those land in v0.2+ along
200/// with the registry feature itself (see ANTI_FEATURES.md).
201pub fn build_agent_card(
202    handle: &str,
203    public_key: &[u8],
204    name: Option<&str>,
205    capabilities: Option<Vec<String>>,
206    max_body_kb: Option<u64>,
207) -> AgentCard {
208    let display_name = name
209        .map(str::to_string)
210        .unwrap_or_else(|| capitalize(handle));
211    let caps = capabilities.unwrap_or_else(|| vec!["wire/v3.2".to_string()]);
212    let body_kb = max_body_kb.unwrap_or(64);
213
214    let key_id = make_key_id(handle, public_key);
215    let key_id_full = format!("ed25519:{key_id}");
216
217    json!({
218        "schema_version": CARD_SCHEMA_VERSION,
219        "did": did_for_with_key(handle, public_key),
220        "handle": handle,
221        "name": display_name,
222        "capabilities": caps,
223        "verify_keys": {
224            key_id_full: {
225                "key": b64encode(public_key),
226                "alg": "ed25519",
227                "active": true,
228            }
229        },
230        "policies": {
231            "max_message_body_kb": body_kb,
232        }
233    })
234}
235
236/// Capitalize the first character of an ASCII handle (`paul` → `Paul`).
237fn capitalize(s: &str) -> String {
238    let mut chars = s.chars();
239    match chars.next() {
240        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
241        None => String::new(),
242    }
243}
244
245// ─── RFC-001 §1: identity claims (operator / organization / project) ───────
246//
247// Optional, orthogonal claims layered onto the agent card. Cards without
248// any of these verify and route exactly as before — the additions are
249// strictly additive. v3.1 cards remain readable; v3.2 cards may carry
250// any subset of these fields.
251
252/// One entry in `org_memberships[]` (RFC-001 §1). `member_cert` is the
253/// org's signature over the operator's `op_did` UTF-8 bytes. A peer
254/// verifies the cert by looking up the org's pubkey (from a roster
255/// pull or a previously-pinned org) and calling
256/// `identity::verify_member_cert`.
257#[derive(Debug, Clone)]
258pub struct OrgMembership {
259    pub org_did: String,
260    /// Base64 Ed25519 public key of the org, carried inline so a receiver
261    /// verifies the vouch fully offline — `org_did` commits to this key
262    /// (`did:wire:org:<h>-<32hex sha256(org_pubkey)>`) and `member_cert` is
263    /// checked against it (RFC-001 Phase 1, `org_membership::evaluate_card_membership`).
264    pub org_pubkey: String,
265    /// Base64 Ed25519 signature by the org's key over `op_did` UTF-8 bytes.
266    pub member_cert: String,
267}
268
269/// Identity claims that may be layered onto an agent card. Each field
270/// is independently optional — a card may declare an operator anchor
271/// without an org membership, or an org membership without a project
272/// tag. The fields are orthogonal axes per RFC-001.
273#[derive(Debug, Clone, Default)]
274pub struct IdentityClaims {
275    /// Operator DID — `did:wire:op:<handle>-<32hex>`. Must satisfy
276    /// `is_op_did(...)`. The operator's root key separately signs
277    /// `op_cert` over the *session* DID this card belongs to, anchoring
278    /// the session under the operator.
279    pub op_did: Option<String>,
280    /// Base64 Ed25519 signature by the operator's key over this card's
281    /// session DID (UTF-8 bytes). Verifiable with `identity::verify_op_cert`.
282    /// Meaningful only when `op_did` is set.
283    pub op_cert: Option<String>,
284    /// Base64 Ed25519 operator root public key, carried inline so the operator
285    /// binding verifies offline — `op_did` commits to this key and `op_cert` is
286    /// checked against it. Set whenever `op_did` is set; without it the operator
287    /// claim is unverifiable and a receiver fails it closed (RFC-001 Phase 1).
288    pub op_pubkey: Option<String>,
289    /// Zero or more org membership entries. An operator may sit in
290    /// multiple orgs simultaneously; each entry stands on its own.
291    pub org_memberships: Vec<OrgMembership>,
292    /// Opaque routing tag — NEVER trust-bearing. RFC-001 §6.
293    pub project: Option<String>,
294    /// Same-machine attestation (RFC-001 amendment #182): `(machine_fingerprint
295    /// b64, signature b64)`. Present only when this session is op-enrolled AND
296    /// the local machine fingerprint is readable. Field-additive: pre-v0.15
297    /// receivers tolerate it as an opaque extra (AC-SM5). Built + verified by
298    /// `same_machine`.
299    pub same_machine_attestation: Option<(String, String)>,
300}
301
302/// Layer identity claims onto an existing (unsigned) card. The returned
303/// card is unsigned; the caller signs it with `sign_agent_card` after
304/// all claims are attached. Fields with `None`/empty values are not
305/// added to the JSON, keeping the canonical bytes minimal for v3.1-only
306/// peers and making round-trip semantics deterministic.
307///
308/// Returns `Err(ClaimError::InvalidOpDid)` if `op_did` is set but does
309/// not parse as `did:wire:op:<handle>-<32hex>`; same shape for
310/// `InvalidOrgDid`. The check is structural — cryptographic verification
311/// of `op_cert` / `member_cert` happens in `identity::verify_*`, which
312/// needs the pubkeys those certs are signed by.
313pub fn with_identity_claims(
314    card: &AgentCard,
315    claims: &IdentityClaims,
316) -> Result<AgentCard, ClaimError> {
317    if let Some(op_did) = &claims.op_did
318        && !is_op_did(op_did)
319    {
320        return Err(ClaimError::InvalidOpDid(op_did.clone()));
321    }
322    for m in &claims.org_memberships {
323        if !is_org_did(&m.org_did) {
324            return Err(ClaimError::InvalidOrgDid(m.org_did.clone()));
325        }
326    }
327
328    let mut out = card.as_object().cloned().unwrap_or_default();
329
330    if let Some(op_did) = &claims.op_did {
331        out.insert("op_did".into(), Value::String(op_did.clone()));
332    }
333    if let Some(op_cert) = &claims.op_cert {
334        out.insert("op_cert".into(), Value::String(op_cert.clone()));
335    }
336    if let Some(op_pubkey) = &claims.op_pubkey {
337        out.insert("op_pubkey".into(), Value::String(op_pubkey.clone()));
338    }
339    if !claims.org_memberships.is_empty() {
340        let arr: Vec<Value> = claims
341            .org_memberships
342            .iter()
343            .map(|m| {
344                json!({
345                    "org_did": m.org_did,
346                    "org_pubkey": m.org_pubkey,
347                    "member_cert": m.member_cert,
348                })
349            })
350            .collect();
351        out.insert("org_memberships".into(), Value::Array(arr));
352    }
353    if let Some(project) = &claims.project {
354        out.insert("project".into(), Value::String(project.clone()));
355    }
356    if let Some((fingerprint, signature)) = &claims.same_machine_attestation {
357        out.insert(
358            "same_machine_attestation".into(),
359            json!({
360                "machine_fingerprint": fingerprint,
361                "signature": signature,
362            }),
363        );
364    }
365
366    // v0.14.x retro-fix: when ANY RFC-001 op claim lands on the card,
367    // bump `schema_version` to at least `CARD_SCHEMA_VERSION` (currently
368    // "v3.2"). Existing cards minted at v3.1 keep their version field
369    // until republish hits this path — at which point the version
370    // matches the inline-fields shape. Monotonic (never downgrades): a
371    // card already at >= v3.2 is unchanged. Readers that key off
372    // `schema_version >= "v3.2"` to discriminate "carries op claims"
373    // now have a truthful signal. (The bug it closes: v0.14 stored
374    // op_did but kept emitting `schema_version: "v3.1"` — readers
375    // couldn't tell from the version alone whether the card had
376    // op claims; they had to probe the inline fields directly.)
377    let has_any_op_claim = claims.op_did.is_some()
378        || claims.op_cert.is_some()
379        || claims.op_pubkey.is_some()
380        || !claims.org_memberships.is_empty();
381    if has_any_op_claim {
382        let current = out
383            .get("schema_version")
384            .and_then(Value::as_str)
385            .unwrap_or("v3.0");
386        let target = max_schema_version(current, CARD_SCHEMA_VERSION);
387        out.insert("schema_version".into(), Value::String(target.to_string()));
388    }
389
390    Ok(Value::Object(out))
391}
392
393/// Compare two `vX.Y` schema-version strings as `(major, minor)` integer
394/// tuples and return the higher. Defensive: unparseable inputs fall back
395/// to the OTHER argument (so a malformed stored card doesn't poison the
396/// republish). `v3.10` correctly compares as > `v3.2`.
397fn max_schema_version<'a>(a: &'a str, b: &'a str) -> &'a str {
398    fn parse(s: &str) -> Option<(u32, u32)> {
399        let rest = s.strip_prefix('v')?;
400        let (maj, min) = rest.split_once('.')?;
401        Some((maj.parse().ok()?, min.parse().ok()?))
402    }
403    match (parse(a), parse(b)) {
404        (Some(pa), Some(pb)) => {
405            if pa >= pb {
406                a
407            } else {
408                b
409            }
410        }
411        // Bias toward the parseable one; if neither parses, keep `a`.
412        (Some(_), None) => a,
413        (None, Some(_)) => b,
414        (None, None) => a,
415    }
416}
417
418#[derive(Debug, Error)]
419pub enum ClaimError {
420    #[error("op_did is not a well-formed did:wire:op:<handle>-<32hex>: {0}")]
421    InvalidOpDid(String),
422    #[error("org_did is not a well-formed did:wire:org:<handle>-<32hex>: {0}")]
423    InvalidOrgDid(String),
424}
425
426/// Read `op_did` from a card. Returns `None` if absent or malformed.
427pub fn card_op_did(card: &AgentCard) -> Option<&str> {
428    card.get("op_did").and_then(Value::as_str)
429}
430
431/// Read `op_cert` from a card. Returns `None` if absent.
432pub fn card_op_cert(card: &AgentCard) -> Option<&str> {
433    card.get("op_cert").and_then(Value::as_str)
434}
435
436/// Read `project` routing tag from a card.
437pub fn card_project(card: &AgentCard) -> Option<&str> {
438    card.get("project").and_then(Value::as_str)
439}
440
441/// Read `org_memberships[]` from a card as a list of `(org_did,
442/// member_cert)` borrowed pairs. Returns empty if absent or malformed.
443pub fn card_org_memberships(card: &AgentCard) -> Vec<(&str, &str)> {
444    card.get("org_memberships")
445        .and_then(Value::as_array)
446        .map(|arr| {
447            arr.iter()
448                .filter_map(|entry| {
449                    let org = entry.get("org_did").and_then(Value::as_str)?;
450                    let cert = entry.get("member_cert").and_then(Value::as_str)?;
451                    Some((org, cert))
452                })
453                .collect()
454        })
455        .unwrap_or_default()
456}
457
458/// Canonical bytes of an agent card — strips `signature` before serialization.
459pub fn card_canonical(card: &AgentCard) -> Vec<u8> {
460    canonical(card, false)
461}
462
463/// Sign an agent card with `private_key`. Returns the card with `signature`
464/// field appended (base64 of Ed25519 signature over `card_canonical(card)`).
465pub fn sign_agent_card(card: &AgentCard, private_key: &[u8]) -> AgentCard {
466    let mut sk_bytes = [0u8; 32];
467    sk_bytes.copy_from_slice(&private_key[..32]);
468    let sk = SigningKey::from_bytes(&sk_bytes);
469    // D1 (RFC-006): attach the X25519 `dh_pubkey` derived from THIS signing seed
470    // before canonicalizing, so the self-signature covers it. Single chokepoint
471    // guaranteeing every signed card carries dh_pubkey; a stripped/substituted
472    // value breaks the signature (caught by verify_agent_card on the receiver).
473    let mut card_obj = card.as_object().cloned().unwrap_or_default();
474    card_obj.insert(
475        "dh_pubkey".into(),
476        Value::String(crate::enc::wire_x25519::self_dh_pubkey_b64(&sk_bytes)),
477    );
478    let card_with_dh = Value::Object(card_obj);
479    let sig = sk.sign(&card_canonical(&card_with_dh));
480    let mut out = card_with_dh.as_object().cloned().unwrap_or_default();
481    out.insert(
482        "signature".into(),
483        Value::String(b64encode(&sig.to_bytes())),
484    );
485    Value::Object(out)
486}
487
488/// Read `dh_pubkey` (base64 X25519) from a card. `None` ⇒ pre-D1/unsigned card.
489pub fn card_dh_pubkey(card: &AgentCard) -> Option<&str> {
490    card.get("dh_pubkey").and_then(Value::as_str)
491}
492
493/// Verify a signed card. Picks the first verify_key, validates the
494/// signature over `card_canonical(card)` (stripped of `signature`).
495pub fn verify_agent_card(card: &AgentCard) -> Result<(), CardError> {
496    let signature_b64 = card
497        .get("signature")
498        .and_then(Value::as_str)
499        .ok_or(CardError::MissingField("signature"))?;
500
501    let verify_keys = card
502        .get("verify_keys")
503        .and_then(Value::as_object)
504        .ok_or(CardError::MissingField("verify_keys"))?;
505
506    let (_kid, key_record) = verify_keys.iter().next().ok_or(CardError::NoVerifyKeys)?;
507    let pk_b64 = key_record
508        .get("key")
509        .and_then(Value::as_str)
510        .ok_or(CardError::MissingField("verify_keys[*].key"))?;
511    let pk_bytes = b64decode(pk_b64).map_err(|_| CardError::BadSignature)?;
512    if pk_bytes.len() != 32 {
513        return Err(CardError::BadSignature);
514    }
515    let mut pk_arr = [0u8; 32];
516    pk_arr.copy_from_slice(&pk_bytes);
517    let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| CardError::BadSignature)?;
518
519    let sig_bytes = b64decode(signature_b64).map_err(|_| CardError::BadSignature)?;
520    if sig_bytes.len() != 64 {
521        return Err(CardError::BadSignature);
522    }
523    let mut sig_arr = [0u8; 64];
524    sig_arr.copy_from_slice(&sig_bytes);
525    let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
526
527    vk.verify(&card_canonical(card), &sig)
528        .map_err(|_| CardError::SignatureRejected)?;
529
530    // Binding: the card's session DID must commit to the key we just
531    // verified the signature with. A valid self-signature alone only
532    // proves the card was signed by SOME key — without this, an attacker
533    // can self-sign a card claiming any victim's DID under their own key.
534    // (op/org DIDs are bound separately via org_membership::commits_to;
535    // `did_commits_to_key` returns false for those prefixes, so a card
536    // whose top-level `did` is an op/org DID is correctly rejected here.)
537    let did = card
538        .get("did")
539        .and_then(Value::as_str)
540        .ok_or(CardError::MissingField("did"))?;
541    if !did_commits_to_key(did, &pk_arr) {
542        return Err(CardError::DidKeyMismatch);
543    }
544    Ok(())
545}
546
547/// 6-digit bilateral SAS over two raw 32-byte public keys.
548///
549/// `sha256(min(a, b) || max(a, b))` then take the last 6 decimal digits.
550/// Symmetric in `(a, b)` so either operator computes the same digits from
551/// independent knowledge of both keys.
552pub fn compute_sas(public_key_a: &[u8], public_key_b: &[u8]) -> String {
553    let (lo, hi) = if public_key_a <= public_key_b {
554        (public_key_a, public_key_b)
555    } else {
556        (public_key_b, public_key_a)
557    };
558    let mut h = Sha256::new();
559    h.update(lo);
560    h.update(hi);
561    let digest = h.finalize();
562    // Take low 4 bytes -> u32, mod 1_000_000 for 6 digits.
563    let n = u32::from_be_bytes([digest[28], digest[29], digest[30], digest[31]]);
564    format!("{:06}", n % 1_000_000)
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use crate::signing::generate_keypair;
571
572    #[test]
573    fn did_method_constant() {
574        assert_eq!(DID_METHOD, "did:wire");
575    }
576
577    #[test]
578    fn build_minimal_card() {
579        let (_, pk) = generate_keypair();
580        let card = build_agent_card("paul", &pk, None, None, None);
581        assert_eq!(card["schema_version"], CARD_SCHEMA_VERSION);
582        // v0.5.7+: DID is pubkey-suffixed for cross-operator uniqueness.
583        let did = card["did"].as_str().unwrap();
584        assert!(did.starts_with("did:wire:paul-"), "got: {did}");
585        assert_eq!(did.len(), "did:wire:paul-".len() + 8);
586        assert_eq!(card["handle"], "paul");
587        assert_eq!(card["name"], "Paul");
588        let vks = card["verify_keys"].as_object().unwrap();
589        assert_eq!(vks.len(), 1);
590        assert_eq!(card["policies"]["max_message_body_kb"], 64);
591    }
592
593    #[test]
594    fn build_card_with_overrides() {
595        let (_, pk) = generate_keypair();
596        let card = build_agent_card(
597            "carol",
598            &pk,
599            Some("Carol's Agent"),
600            Some(vec!["custom-cap".to_string()]),
601            Some(128),
602        );
603        assert_eq!(card["name"], "Carol's Agent");
604        assert_eq!(card["capabilities"], json!(["custom-cap"]));
605        assert_eq!(card["policies"]["max_message_body_kb"], 128);
606    }
607
608    #[test]
609    fn build_card_does_not_carry_v02_fields() {
610        let (_, pk) = generate_keypair();
611        let card = build_agent_card("paul", &pk, None, None, None);
612        let obj = card.as_object().unwrap();
613        for v02 in [
614            "registries",
615            "onboard_endpoint",
616            "wire_raw_url_template",
617            "revoked_at",
618        ] {
619            assert!(
620                !obj.contains_key(v02),
621                "v0.2+ field {v02} leaked into v0.1 card"
622            );
623        }
624    }
625
626    #[test]
627    fn card_canonical_excludes_signature() {
628        let v = json!({"schema_version": "v3.1", "did": "did:wire:paul", "signature": "sig"});
629        let bytes = card_canonical(&v);
630        assert!(!String::from_utf8_lossy(&bytes).contains("signature"));
631    }
632
633    #[test]
634    fn card_canonical_sort_keys_stable() {
635        let a = json!({"b": 1, "a": 2, "did": "did:wire:paul"});
636        let b = json!({"did": "did:wire:paul", "a": 2, "b": 1});
637        assert_eq!(card_canonical(&a), card_canonical(&b));
638    }
639
640    #[test]
641    fn sign_verify_roundtrip() {
642        let (sk, pk) = generate_keypair();
643        let card = build_agent_card("paul", &pk, None, None, None);
644        let signed = sign_agent_card(&card, &sk);
645        assert!(signed.get("signature").is_some());
646        verify_agent_card(&signed).unwrap();
647    }
648
649    #[test]
650    fn verify_rejects_unsigned_card() {
651        let (_, pk) = generate_keypair();
652        let card = build_agent_card("paul", &pk, None, None, None);
653        let err = verify_agent_card(&card).unwrap_err();
654        assert!(matches!(err, CardError::MissingField("signature")));
655    }
656
657    #[test]
658    fn verify_rejects_tampered_card() {
659        let (sk, pk) = generate_keypair();
660        let mut signed = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
661        signed["name"] = json!("TamperedName");
662        let err = verify_agent_card(&signed).unwrap_err();
663        assert!(matches!(err, CardError::SignatureRejected));
664    }
665
666    #[test]
667    fn verify_rejects_card_with_no_verify_keys() {
668        let (sk, _) = generate_keypair();
669        let card = json!({"schema_version": "v3.1", "did": "did:wire:paul", "verify_keys": {}});
670        let signed = sign_agent_card(&card, &sk);
671        let err = verify_agent_card(&signed).unwrap_err();
672        assert!(matches!(err, CardError::NoVerifyKeys));
673    }
674
675    #[test]
676    fn verify_rejects_did_claiming_foreign_key() {
677        // Attacker self-signs a card with their OWN key but claims a DID
678        // whose fingerprint suffix belongs to a victim's key. The
679        // signature verifies (it's the attacker's key over their own
680        // bytes) but the DID no longer commits to that key → reject.
681        let (victim_sk, victim_pk) = generate_keypair();
682        let (attacker_sk, attacker_pk) = generate_keypair();
683        assert_ne!(victim_pk, attacker_pk);
684        let victim_did = did_for_with_key("paul", &victim_pk);
685        // Build the attacker's card (their key in verify_keys) then
686        // overwrite the DID to claim the victim's, and re-sign so the
687        // self-signature is valid over the tampered bytes.
688        let mut card = build_agent_card("paul", &attacker_pk, None, None, None);
689        card["did"] = json!(victim_did);
690        let signed = sign_agent_card(&card, &attacker_sk);
691        // Sanity: the signature itself is valid (attacker signed it).
692        let err = verify_agent_card(&signed).unwrap_err();
693        assert!(
694            matches!(err, CardError::DidKeyMismatch),
695            "expected DidKeyMismatch, got {err:?}"
696        );
697        // And the genuine victim card (DID bound to victim key) verifies.
698        let real = sign_agent_card(
699            &build_agent_card("paul", &victim_pk, None, None, None),
700            &victim_sk,
701        );
702        verify_agent_card(&real).unwrap();
703    }
704
705    #[test]
706    fn did_commits_to_key_basic() {
707        let (_, pk) = generate_keypair();
708        let (_, other) = generate_keypair();
709        let did = did_for_with_key("alice", &pk);
710        assert!(did_commits_to_key(&did, &pk));
711        assert!(!did_commits_to_key(&did, &other));
712        // Handles containing hyphens still bind on the final segment.
713        let hdid = did_for_with_key("alice-bob", &pk);
714        assert!(did_commits_to_key(&hdid, &pk));
715        // op/org DIDs are bound elsewhere → not accepted by this helper.
716        assert!(!did_commits_to_key(&did_for_op("acme", &pk), &pk));
717        assert!(!did_commits_to_key(&did_for_org("acme", &pk), &pk));
718        // Suffix-less legacy DID → no binding.
719        assert!(!did_commits_to_key("did:wire:alice", &pk));
720    }
721
722    #[test]
723    fn compute_sas_is_6_digits() {
724        let (_, a) = generate_keypair();
725        let (_, b) = generate_keypair();
726        let sas = compute_sas(&a, &b);
727        assert_eq!(sas.len(), 6);
728        assert!(sas.chars().all(|c| c.is_ascii_digit()));
729    }
730
731    #[test]
732    fn compute_sas_bilateral_symmetric() {
733        let (_, a) = generate_keypair();
734        let (_, b) = generate_keypair();
735        assert_eq!(compute_sas(&a, &b), compute_sas(&b, &a));
736    }
737
738    #[test]
739    fn compute_sas_changes_with_inputs() {
740        let (_, a) = generate_keypair();
741        let (_, b) = generate_keypair();
742        let (_, c) = generate_keypair();
743        assert_ne!(compute_sas(&a, &b), compute_sas(&a, &c));
744    }
745
746    // ─── RFC-001 §1: identity claims ───────────────────────────────────────
747
748    fn op_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
749        let (sk, pk) = generate_keypair();
750        (did_for_op(handle, &pk), sk.to_vec(), pk.to_vec())
751    }
752
753    fn org_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
754        let (sk, pk) = generate_keypair();
755        (did_for_org(handle, &pk), sk.to_vec(), pk.to_vec())
756    }
757
758    #[test]
759    fn schema_version_is_v3_2() {
760        assert_eq!(CARD_SCHEMA_VERSION, "v3.2");
761    }
762
763    #[test]
764    fn op_did_has_long_hex_suffix_and_method_prefix() {
765        let (did, _, _) = op_did_for_test("darby");
766        assert!(did.starts_with("did:wire:op:darby-"), "got: {did}");
767        let tail = did.rsplit('-').next().unwrap();
768        assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
769        assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
770    }
771
772    #[test]
773    fn org_did_has_long_hex_suffix_and_method_prefix() {
774        let (did, _, _) = org_did_for_test("slanchaai");
775        assert!(did.starts_with("did:wire:org:slanchaai-"), "got: {did}");
776        let tail = did.rsplit('-').next().unwrap();
777        assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
778        assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
779    }
780
781    #[test]
782    fn op_did_passthrough_when_already_op_did() {
783        // Passing a fully-formed op_did back through `did_for_op` is a no-op;
784        // protects callers that mix raw handles + already-built DIDs.
785        let (_, pk) = generate_keypair();
786        let did = did_for_op("darby", &pk);
787        let again = did_for_op(&did, &pk);
788        assert_eq!(did, again);
789    }
790
791    #[test]
792    fn is_op_did_rejects_session_did() {
793        // The classification check exists precisely to refuse this confusion.
794        let (_, pk) = generate_keypair();
795        let session_did = did_for_with_key("darby", &pk);
796        assert!(!is_op_did(&session_did));
797        assert!(!is_org_did(&session_did));
798    }
799
800    #[test]
801    fn is_op_did_rejects_org_did_and_vice_versa() {
802        // Disjoint namespaces — an org_did is not an op_did even though both
803        // share the long-hex suffix shape.
804        let (op, _, _) = op_did_for_test("darby");
805        let (org, _, _) = org_did_for_test("slanchaai");
806        assert!(is_op_did(&op) && !is_org_did(&op));
807        assert!(is_org_did(&org) && !is_op_did(&org));
808    }
809
810    #[test]
811    fn is_op_did_rejects_short_hex_suffix() {
812        // An 8-hex tail (session-DID shape) under the op prefix would be a
813        // namespace squat. Refuse on syntax alone.
814        assert!(!is_op_did("did:wire:op:darby-deadbeef"));
815        assert!(!is_org_did("did:wire:org:slanchaai-deadbeef"));
816    }
817
818    #[test]
819    fn is_op_did_rejects_non_hex_suffix() {
820        let bad = format!("did:wire:op:darby-{}", "z".repeat(LONG_FINGERPRINT_HEX_LEN));
821        assert!(!is_op_did(&bad));
822    }
823
824    #[test]
825    fn with_identity_claims_attaches_all_fields() {
826        let (sk, pk) = generate_keypair();
827        let card = build_agent_card("vesper-valley", &pk, None, None, None);
828        let (op_did, _, op_pk) = op_did_for_test("darby");
829        let (org_did, _, org_pk) = org_did_for_test("slanchaai");
830        let op_pubkey = crate::signing::b64encode(&op_pk);
831        let org_pubkey = crate::signing::b64encode(&org_pk);
832        let claims = IdentityClaims {
833            op_did: Some(op_did.clone()),
834            op_cert: Some("AAAA".into()),
835            op_pubkey: Some(op_pubkey.clone()),
836            org_memberships: vec![OrgMembership {
837                org_did: org_did.clone(),
838                org_pubkey: org_pubkey.clone(),
839                member_cert: "BBBB".into(),
840            }],
841            project: Some("wire-codex-integration".into()),
842            same_machine_attestation: Some(("ZmluZ2VycHJpbnQ=".into(), "c2ln".into())),
843        };
844        let with = with_identity_claims(&card, &claims).unwrap();
845        assert_eq!(card_op_did(&with), Some(op_did.as_str()));
846        assert_eq!(card_op_cert(&with), Some("AAAA"));
847        // #182: the attestation lands as a nested object with both fields.
848        assert_eq!(
849            with.pointer("/same_machine_attestation/machine_fingerprint")
850                .and_then(|v| v.as_str()),
851            Some("ZmluZ2VycHJpbnQ=")
852        );
853        assert_eq!(
854            with.pointer("/same_machine_attestation/signature")
855                .and_then(|v| v.as_str()),
856            Some("c2ln")
857        );
858        assert_eq!(
859            with.get("op_pubkey").and_then(|v| v.as_str()),
860            Some(op_pubkey.as_str())
861        );
862        assert_eq!(card_project(&with), Some("wire-codex-integration"));
863        let orgs = card_org_memberships(&with);
864        assert_eq!(orgs.len(), 1);
865        assert_eq!(orgs[0], (org_did.as_str(), "BBBB"));
866        assert_eq!(
867            with.get("org_memberships").unwrap()[0]
868                .get("org_pubkey")
869                .and_then(|v| v.as_str()),
870            Some(org_pubkey.as_str())
871        );
872        // Card still signs + verifies after identity claims are layered.
873        let signed = sign_agent_card(&with, &sk);
874        verify_agent_card(&signed).unwrap();
875    }
876
877    #[test]
878    fn with_identity_claims_skips_absent_fields() {
879        // A card with no claims must not gain empty `op_did`/`project`/etc.
880        // entries — keeps canonical bytes minimal and v3.1-peer-friendly.
881        let (_, pk) = generate_keypair();
882        let card = build_agent_card("vesper-valley", &pk, None, None, None);
883        let with = with_identity_claims(&card, &IdentityClaims::default()).unwrap();
884        let obj = with.as_object().unwrap();
885        for field in ["op_did", "op_cert", "org_memberships", "project"] {
886            assert!(
887                !obj.contains_key(field),
888                "{field} leaked into claim-less card"
889            );
890        }
891    }
892
893    #[test]
894    fn with_identity_claims_rejects_malformed_op_did() {
895        let (_, pk) = generate_keypair();
896        let card = build_agent_card("vesper-valley", &pk, None, None, None);
897        let claims = IdentityClaims {
898            // Session-DID shape under op prefix → namespace confusion.
899            op_did: Some("did:wire:op:darby-deadbeef".into()),
900            ..Default::default()
901        };
902        let err = with_identity_claims(&card, &claims).unwrap_err();
903        assert!(matches!(err, ClaimError::InvalidOpDid(_)));
904    }
905
906    #[test]
907    fn with_identity_claims_rejects_malformed_org_did() {
908        let (_, pk) = generate_keypair();
909        let card = build_agent_card("vesper-valley", &pk, None, None, None);
910        let claims = IdentityClaims {
911            org_memberships: vec![OrgMembership {
912                org_did: "did:wire:slanchaai".into(),
913                org_pubkey: "AAAA".into(),
914                member_cert: "BBBB".into(),
915            }],
916            ..Default::default()
917        };
918        let err = with_identity_claims(&card, &claims).unwrap_err();
919        assert!(matches!(err, ClaimError::InvalidOrgDid(_)));
920    }
921
922    #[test]
923    fn build_agent_card_default_capability_advertises_v3_2() {
924        let (_, pk) = generate_keypair();
925        let card = build_agent_card("paul", &pk, None, None, None);
926        let caps = card["capabilities"].as_array().unwrap();
927        let has_v32 = caps.iter().any(|v| v.as_str() == Some("wire/v3.2"));
928        assert!(has_v32, "default caps should advertise wire/v3.2: {caps:?}");
929    }
930
931    // v0.14.x retro-fix tests: when op claims are attached, the card's
932    // `schema_version` field bumps to at least `CARD_SCHEMA_VERSION`. The
933    // bump is monotonic (never downgrades), conditional (claim-less
934    // attach leaves the field alone), and version-numeric (v3.10 > v3.2,
935    // not lexicographic).
936
937    #[test]
938    fn with_identity_claims_bumps_schema_version_when_op_did_attached() {
939        // A card that was minted at v3.1 (the pre-v0.14 emit version)
940        // must surface as >= v3.2 once op claims are attached — readers
941        // discriminate "card carries op_*" off the version field.
942        let (_, pk) = generate_keypair();
943        let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
944        // Roll back to v3.1 to simulate a pre-v0.14 stored card.
945        card.as_object_mut()
946            .unwrap()
947            .insert("schema_version".into(), json!("v3.1"));
948        let (op_did, _, op_pk) = op_did_for_test("darby");
949        let claims = IdentityClaims {
950            op_did: Some(op_did),
951            op_pubkey: Some(crate::signing::b64encode(&op_pk)),
952            op_cert: Some("AAAA".into()),
953            ..Default::default()
954        };
955        let with = with_identity_claims(&card, &claims).unwrap();
956        assert_eq!(
957            with.get("schema_version").and_then(|v| v.as_str()),
958            Some(CARD_SCHEMA_VERSION),
959            "post-attach schema_version must bump to {CARD_SCHEMA_VERSION}",
960        );
961    }
962
963    #[test]
964    fn with_identity_claims_does_not_touch_schema_version_when_no_claims() {
965        // Claim-less attach (e.g. an unenrolled operator's republish)
966        // leaves the version field exactly as it was — no spurious bump
967        // for a v3.1 peer that has zero op_* fields to surface.
968        let (_, pk) = generate_keypair();
969        let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
970        card.as_object_mut()
971            .unwrap()
972            .insert("schema_version".into(), json!("v3.1"));
973        let with = with_identity_claims(&card, &IdentityClaims::default()).unwrap();
974        assert_eq!(
975            with.get("schema_version").and_then(|v| v.as_str()),
976            Some("v3.1"),
977            "claim-less attach must NOT bump",
978        );
979    }
980
981    #[test]
982    fn with_identity_claims_never_downgrades_schema_version() {
983        // A hypothetical v3.5 card (future extension peer) attaching op
984        // claims via an older `CARD_SCHEMA_VERSION` build must NOT lose
985        // its higher version. Monotonic invariant.
986        let (_, pk) = generate_keypair();
987        let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
988        card.as_object_mut()
989            .unwrap()
990            .insert("schema_version".into(), json!("v3.5"));
991        let (op_did, _, op_pk) = op_did_for_test("darby");
992        let claims = IdentityClaims {
993            op_did: Some(op_did),
994            op_pubkey: Some(crate::signing::b64encode(&op_pk)),
995            op_cert: Some("AAAA".into()),
996            ..Default::default()
997        };
998        let with = with_identity_claims(&card, &claims).unwrap();
999        assert_eq!(
1000            with.get("schema_version").and_then(|v| v.as_str()),
1001            Some("v3.5"),
1002            "monotonic bump must not downgrade v3.5 to {CARD_SCHEMA_VERSION}",
1003        );
1004    }
1005
1006    #[test]
1007    fn max_schema_version_compares_numerically_not_lexicographically() {
1008        // Lexicographic compare would call "v3.10" < "v3.2" because '1' <
1009        // '2'. The helper parses to (major, minor) ints so v3.10 > v3.2.
1010        assert_eq!(max_schema_version("v3.10", "v3.2"), "v3.10");
1011        assert_eq!(max_schema_version("v3.2", "v3.10"), "v3.10");
1012        assert_eq!(max_schema_version("v3.2", "v3.2"), "v3.2");
1013        assert_eq!(max_schema_version("v4.0", "v3.99"), "v4.0");
1014    }
1015
1016    #[test]
1017    fn max_schema_version_biases_to_parseable_on_malformed_input() {
1018        // A malformed stored card must not poison the republish: parseable
1019        // wins, both-malformed keeps `a` (deterministic, no panic).
1020        assert_eq!(max_schema_version("garbage", "v3.2"), "v3.2");
1021        assert_eq!(max_schema_version("v3.2", "garbage"), "v3.2");
1022        assert_eq!(max_schema_version("garbage1", "garbage2"), "garbage1");
1023    }
1024}