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).
47///
48/// Backward-compat: legacy DIDs of the form `did:wire:<handle>` (no
49/// pubkey suffix) shipped pre-v0.5.7. They still verify because signature
50/// verification reads the pubkey from `verify_keys`, not from the DID
51/// string. They're just non-unique across operators picking the same
52/// handle — the v0.5.7 cohort onward gets uniqueness by construction.
53pub fn did_for_with_key(handle: &str, public_key: &[u8]) -> String {
54    if handle.starts_with("did:") {
55        return handle.to_string();
56    }
57    let suffix = crate::signing::fingerprint(public_key);
58    format!("{DID_METHOD}:{handle}-{suffix}")
59}
60
61/// Build an operator DID (`did:wire:op:<handle>-<32hex>`). RFC-001
62/// §1 calls for a 32-hex tail (16 bytes of sha256(pubkey)) so the
63/// long-lived operator anchor is collision-resistant at 2^128.
64///
65/// Pass-through for any string already starting with `did:wire:op:`
66/// so callers can be lazy with mixed inputs.
67pub fn did_for_op(handle: &str, public_key: &[u8]) -> String {
68    if handle.starts_with("did:wire:op:") {
69        return handle.to_string();
70    }
71    let suffix = long_fingerprint(public_key);
72    format!("{DID_METHOD_OP}:{handle}-{suffix}")
73}
74
75/// Build an organization DID (`did:wire:org:<handle>-<32hex>`). Same
76/// construction as `did_for_op` but under the org prefix; org_dids
77/// gate the eased-pair surface, so they share the longer hex tail.
78pub fn did_for_org(handle: &str, public_key: &[u8]) -> String {
79    if handle.starts_with("did:wire:org:") {
80        return handle.to_string();
81    }
82    let suffix = long_fingerprint(public_key);
83    format!("{DID_METHOD_ORG}:{handle}-{suffix}")
84}
85
86/// 32-hex (16-byte) fingerprint over the public key for op/org DIDs.
87/// Wider than `signing::fingerprint` (which returns 8 hex / 4 bytes)
88/// because op/org identities are long-lived and grant trust scope.
89pub fn long_fingerprint(public_key: &[u8]) -> String {
90    let digest = Sha256::digest(public_key);
91    hex::encode(&digest[..16])
92}
93
94/// True iff `did` is a well-formed `did:wire:op:<handle>-<32hex>`.
95/// Used at card-validation time to refuse a `did:wire:` session DID
96/// mistakenly placed in the `op_did` slot (and vice versa).
97pub fn is_op_did(did: &str) -> bool {
98    let Some(rest) = did.strip_prefix("did:wire:op:") else {
99        return false;
100    };
101    has_long_hex_suffix(rest)
102}
103
104/// True iff `did` is a well-formed `did:wire:org:<handle>-<32hex>`.
105pub fn is_org_did(did: &str) -> bool {
106    let Some(rest) = did.strip_prefix("did:wire:org:") else {
107        return false;
108    };
109    has_long_hex_suffix(rest)
110}
111
112fn has_long_hex_suffix(s: &str) -> bool {
113    let Some(idx) = s.rfind('-') else {
114        return false;
115    };
116    let suffix = &s[idx + 1..];
117    suffix.len() == LONG_FINGERPRINT_HEX_LEN && suffix.chars().all(|c| c.is_ascii_hexdigit())
118}
119
120/// suffix. Pre-v0.5.7 model. Kept for backward-compat in code paths
121/// that don't have the pubkey on hand (display helpers, test fixtures)
122/// and for tests that pin specific DID strings. NEW callers should use
123/// `did_for_with_key`.
124pub fn did_for(handle: &str) -> String {
125    if handle.starts_with("did:") {
126        handle.to_string()
127    } else {
128        format!("{DID_METHOD}:{handle}")
129    }
130}
131
132/// Strip the federation suffix (`@relay.example`) from a handle, returning
133/// the bare local-part. This is the canonical on-disk form: outbox/inbox
134/// files are keyed by bare handle (`paul-mac.jsonl`), and the pinned-peers
135/// map in `relay_state.json` is keyed by bare handle.
136///
137/// Why this exists (v0.5.13): `wire send paul-mac@wireup.net "..."` used
138/// to write the outbox to `paul-mac@wireup.net.jsonl`, but `wire push`
139/// only enumerated bare-handle filenames. Events stuck silently for 25
140/// minutes (issue #2). Normalizing here makes the on-disk contract the
141/// single source of truth — accepts both `paul-mac` and `paul-mac@host`,
142/// always writes to `paul-mac.jsonl`.
143pub fn bare_handle(handle: &str) -> &str {
144    handle.split_once('@').map(|(n, _)| n).unwrap_or(handle)
145}
146
147/// Extract the display-friendly handle from a DID. Handles both legacy
148/// (`did:wire:paul`) and v0.5.7+ (`did:wire:paul-abc12345`) forms. The
149/// v0.5.7 trailing `-<8-hex>` suffix is stripped when present.
150pub fn display_handle_from_did(did: &str) -> &str {
151    let stripped = did.strip_prefix("did:wire:").unwrap_or(did);
152    // v0.5.7+ form: `<handle>-<8-hex>`. Detect by trailing exactly 8 hex
153    // chars after a final `-`. Anything else passes through unchanged.
154    if let Some(idx) = stripped.rfind('-') {
155        let suffix = &stripped[idx + 1..];
156        if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
157            return &stripped[..idx];
158        }
159    }
160    stripped
161}
162
163/// Convenience type — at this stage we use serde_json::Value so the wire
164/// shape stays explicit. A typed struct can come in v0.2+.
165pub type AgentCard = Value;
166
167#[derive(Debug, Error)]
168pub enum CardError {
169    #[error("missing field: {0}")]
170    MissingField(&'static str),
171    #[error("verify_keys is empty or malformed")]
172    NoVerifyKeys,
173    #[error("signature decode failed")]
174    BadSignature,
175    #[error("signature did not verify")]
176    SignatureRejected,
177}
178
179/// Build an unsigned agent card for `handle` with one verify key.
180///
181/// Optional overrides:
182///   - `name`: human-friendly display name (defaults to capitalized handle)
183///   - `capabilities`: list of capability strings (defaults to `["wire/v3.2"]`)
184///   - `max_body_kb`: per-message body cap in KB (defaults to 64)
185///
186/// v0.1 deliberately does NOT include `registries`, `onboard_endpoint`,
187/// `wire_raw_url_template`, or `revoked_at`. Those land in v0.2+ along
188/// with the registry feature itself (see ANTI_FEATURES.md).
189pub fn build_agent_card(
190    handle: &str,
191    public_key: &[u8],
192    name: Option<&str>,
193    capabilities: Option<Vec<String>>,
194    max_body_kb: Option<u64>,
195) -> AgentCard {
196    let display_name = name
197        .map(str::to_string)
198        .unwrap_or_else(|| capitalize(handle));
199    let caps = capabilities.unwrap_or_else(|| vec!["wire/v3.2".to_string()]);
200    let body_kb = max_body_kb.unwrap_or(64);
201
202    let key_id = make_key_id(handle, public_key);
203    let key_id_full = format!("ed25519:{key_id}");
204
205    json!({
206        "schema_version": CARD_SCHEMA_VERSION,
207        "did": did_for_with_key(handle, public_key),
208        "handle": handle,
209        "name": display_name,
210        "capabilities": caps,
211        "verify_keys": {
212            key_id_full: {
213                "key": b64encode(public_key),
214                "alg": "ed25519",
215                "active": true,
216            }
217        },
218        "policies": {
219            "max_message_body_kb": body_kb,
220        }
221    })
222}
223
224/// Capitalize the first character of an ASCII handle (`paul` → `Paul`).
225fn capitalize(s: &str) -> String {
226    let mut chars = s.chars();
227    match chars.next() {
228        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
229        None => String::new(),
230    }
231}
232
233// ─── RFC-001 §1: identity claims (operator / organization / project) ───────
234//
235// Optional, orthogonal claims layered onto the agent card. Cards without
236// any of these verify and route exactly as before — the additions are
237// strictly additive. v3.1 cards remain readable; v3.2 cards may carry
238// any subset of these fields.
239
240/// One entry in `org_memberships[]` (RFC-001 §1). `member_cert` is the
241/// org's signature over the operator's `op_did` UTF-8 bytes. A peer
242/// verifies the cert by looking up the org's pubkey (from a roster
243/// pull or a previously-pinned org) and calling
244/// `identity::verify_member_cert`.
245#[derive(Debug, Clone)]
246pub struct OrgMembership {
247    pub org_did: String,
248    /// Base64 Ed25519 public key of the org, carried inline so a receiver
249    /// verifies the vouch fully offline — `org_did` commits to this key
250    /// (`did:wire:org:<h>-<32hex sha256(org_pubkey)>`) and `member_cert` is
251    /// checked against it (RFC-001 Phase 1, `org_membership::evaluate_card_membership`).
252    pub org_pubkey: String,
253    /// Base64 Ed25519 signature by the org's key over `op_did` UTF-8 bytes.
254    pub member_cert: String,
255}
256
257/// Identity claims that may be layered onto an agent card. Each field
258/// is independently optional — a card may declare an operator anchor
259/// without an org membership, or an org membership without a project
260/// tag. The fields are orthogonal axes per RFC-001.
261#[derive(Debug, Clone, Default)]
262pub struct IdentityClaims {
263    /// Operator DID — `did:wire:op:<handle>-<32hex>`. Must satisfy
264    /// `is_op_did(...)`. The operator's root key separately signs
265    /// `op_cert` over the *session* DID this card belongs to, anchoring
266    /// the session under the operator.
267    pub op_did: Option<String>,
268    /// Base64 Ed25519 signature by the operator's key over this card's
269    /// session DID (UTF-8 bytes). Verifiable with `identity::verify_op_cert`.
270    /// Meaningful only when `op_did` is set.
271    pub op_cert: Option<String>,
272    /// Base64 Ed25519 operator root public key, carried inline so the operator
273    /// binding verifies offline — `op_did` commits to this key and `op_cert` is
274    /// checked against it. Set whenever `op_did` is set; without it the operator
275    /// claim is unverifiable and a receiver fails it closed (RFC-001 Phase 1).
276    pub op_pubkey: Option<String>,
277    /// Zero or more org membership entries. An operator may sit in
278    /// multiple orgs simultaneously; each entry stands on its own.
279    pub org_memberships: Vec<OrgMembership>,
280    /// Opaque routing tag — NEVER trust-bearing. RFC-001 §6.
281    pub project: Option<String>,
282}
283
284/// Layer identity claims onto an existing (unsigned) card. The returned
285/// card is unsigned; the caller signs it with `sign_agent_card` after
286/// all claims are attached. Fields with `None`/empty values are not
287/// added to the JSON, keeping the canonical bytes minimal for v3.1-only
288/// peers and making round-trip semantics deterministic.
289///
290/// Returns `Err(ClaimError::InvalidOpDid)` if `op_did` is set but does
291/// not parse as `did:wire:op:<handle>-<32hex>`; same shape for
292/// `InvalidOrgDid`. The check is structural — cryptographic verification
293/// of `op_cert` / `member_cert` happens in `identity::verify_*`, which
294/// needs the pubkeys those certs are signed by.
295pub fn with_identity_claims(
296    card: &AgentCard,
297    claims: &IdentityClaims,
298) -> Result<AgentCard, ClaimError> {
299    if let Some(op_did) = &claims.op_did
300        && !is_op_did(op_did)
301    {
302        return Err(ClaimError::InvalidOpDid(op_did.clone()));
303    }
304    for m in &claims.org_memberships {
305        if !is_org_did(&m.org_did) {
306            return Err(ClaimError::InvalidOrgDid(m.org_did.clone()));
307        }
308    }
309
310    let mut out = card.as_object().cloned().unwrap_or_default();
311
312    if let Some(op_did) = &claims.op_did {
313        out.insert("op_did".into(), Value::String(op_did.clone()));
314    }
315    if let Some(op_cert) = &claims.op_cert {
316        out.insert("op_cert".into(), Value::String(op_cert.clone()));
317    }
318    if let Some(op_pubkey) = &claims.op_pubkey {
319        out.insert("op_pubkey".into(), Value::String(op_pubkey.clone()));
320    }
321    if !claims.org_memberships.is_empty() {
322        let arr: Vec<Value> = claims
323            .org_memberships
324            .iter()
325            .map(|m| {
326                json!({
327                    "org_did": m.org_did,
328                    "org_pubkey": m.org_pubkey,
329                    "member_cert": m.member_cert,
330                })
331            })
332            .collect();
333        out.insert("org_memberships".into(), Value::Array(arr));
334    }
335    if let Some(project) = &claims.project {
336        out.insert("project".into(), Value::String(project.clone()));
337    }
338
339    Ok(Value::Object(out))
340}
341
342#[derive(Debug, Error)]
343pub enum ClaimError {
344    #[error("op_did is not a well-formed did:wire:op:<handle>-<32hex>: {0}")]
345    InvalidOpDid(String),
346    #[error("org_did is not a well-formed did:wire:org:<handle>-<32hex>: {0}")]
347    InvalidOrgDid(String),
348}
349
350/// Read `op_did` from a card. Returns `None` if absent or malformed.
351pub fn card_op_did(card: &AgentCard) -> Option<&str> {
352    card.get("op_did").and_then(Value::as_str)
353}
354
355/// Read `op_cert` from a card. Returns `None` if absent.
356pub fn card_op_cert(card: &AgentCard) -> Option<&str> {
357    card.get("op_cert").and_then(Value::as_str)
358}
359
360/// Read `project` routing tag from a card.
361pub fn card_project(card: &AgentCard) -> Option<&str> {
362    card.get("project").and_then(Value::as_str)
363}
364
365/// Read `org_memberships[]` from a card as a list of `(org_did,
366/// member_cert)` borrowed pairs. Returns empty if absent or malformed.
367pub fn card_org_memberships(card: &AgentCard) -> Vec<(&str, &str)> {
368    card.get("org_memberships")
369        .and_then(Value::as_array)
370        .map(|arr| {
371            arr.iter()
372                .filter_map(|entry| {
373                    let org = entry.get("org_did").and_then(Value::as_str)?;
374                    let cert = entry.get("member_cert").and_then(Value::as_str)?;
375                    Some((org, cert))
376                })
377                .collect()
378        })
379        .unwrap_or_default()
380}
381
382/// Canonical bytes of an agent card — strips `signature` before serialization.
383pub fn card_canonical(card: &AgentCard) -> Vec<u8> {
384    canonical(card, false)
385}
386
387/// Sign an agent card with `private_key`. Returns the card with `signature`
388/// field appended (base64 of Ed25519 signature over `card_canonical(card)`).
389pub fn sign_agent_card(card: &AgentCard, private_key: &[u8]) -> AgentCard {
390    let mut sk_bytes = [0u8; 32];
391    sk_bytes.copy_from_slice(&private_key[..32]);
392    let sk = SigningKey::from_bytes(&sk_bytes);
393    let sig = sk.sign(&card_canonical(card));
394    let mut out = card.as_object().cloned().unwrap_or_default();
395    out.insert(
396        "signature".into(),
397        Value::String(b64encode(&sig.to_bytes())),
398    );
399    Value::Object(out)
400}
401
402/// Verify a signed card. Picks the first verify_key, validates the
403/// signature over `card_canonical(card)` (stripped of `signature`).
404pub fn verify_agent_card(card: &AgentCard) -> Result<(), CardError> {
405    let signature_b64 = card
406        .get("signature")
407        .and_then(Value::as_str)
408        .ok_or(CardError::MissingField("signature"))?;
409
410    let verify_keys = card
411        .get("verify_keys")
412        .and_then(Value::as_object)
413        .ok_or(CardError::MissingField("verify_keys"))?;
414
415    let (_kid, key_record) = verify_keys.iter().next().ok_or(CardError::NoVerifyKeys)?;
416    let pk_b64 = key_record
417        .get("key")
418        .and_then(Value::as_str)
419        .ok_or(CardError::MissingField("verify_keys[*].key"))?;
420    let pk_bytes = b64decode(pk_b64).map_err(|_| CardError::BadSignature)?;
421    if pk_bytes.len() != 32 {
422        return Err(CardError::BadSignature);
423    }
424    let mut pk_arr = [0u8; 32];
425    pk_arr.copy_from_slice(&pk_bytes);
426    let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| CardError::BadSignature)?;
427
428    let sig_bytes = b64decode(signature_b64).map_err(|_| CardError::BadSignature)?;
429    if sig_bytes.len() != 64 {
430        return Err(CardError::BadSignature);
431    }
432    let mut sig_arr = [0u8; 64];
433    sig_arr.copy_from_slice(&sig_bytes);
434    let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
435
436    vk.verify(&card_canonical(card), &sig)
437        .map_err(|_| CardError::SignatureRejected)
438}
439
440/// 6-digit bilateral SAS over two raw 32-byte public keys.
441///
442/// `sha256(min(a, b) || max(a, b))` then take the last 6 decimal digits.
443/// Symmetric in `(a, b)` so either operator computes the same digits from
444/// independent knowledge of both keys.
445pub fn compute_sas(public_key_a: &[u8], public_key_b: &[u8]) -> String {
446    let (lo, hi) = if public_key_a <= public_key_b {
447        (public_key_a, public_key_b)
448    } else {
449        (public_key_b, public_key_a)
450    };
451    let mut h = Sha256::new();
452    h.update(lo);
453    h.update(hi);
454    let digest = h.finalize();
455    // Take low 4 bytes -> u32, mod 1_000_000 for 6 digits.
456    let n = u32::from_be_bytes([digest[28], digest[29], digest[30], digest[31]]);
457    format!("{:06}", n % 1_000_000)
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::signing::generate_keypair;
464
465    #[test]
466    fn did_for_handle() {
467        assert_eq!(did_for("paul"), "did:wire:paul");
468    }
469
470    #[test]
471    fn did_for_already_did_passthrough() {
472        assert_eq!(did_for("did:wire:paul"), "did:wire:paul");
473        assert_eq!(did_for("did:key:abc"), "did:key:abc");
474    }
475
476    #[test]
477    fn did_method_constant() {
478        assert_eq!(DID_METHOD, "did:wire");
479    }
480
481    #[test]
482    fn build_minimal_card() {
483        let (_, pk) = generate_keypair();
484        let card = build_agent_card("paul", &pk, None, None, None);
485        assert_eq!(card["schema_version"], CARD_SCHEMA_VERSION);
486        // v0.5.7+: DID is pubkey-suffixed for cross-operator uniqueness.
487        let did = card["did"].as_str().unwrap();
488        assert!(did.starts_with("did:wire:paul-"), "got: {did}");
489        assert_eq!(did.len(), "did:wire:paul-".len() + 8);
490        assert_eq!(card["handle"], "paul");
491        assert_eq!(card["name"], "Paul");
492        let vks = card["verify_keys"].as_object().unwrap();
493        assert_eq!(vks.len(), 1);
494        assert_eq!(card["policies"]["max_message_body_kb"], 64);
495    }
496
497    #[test]
498    fn build_card_with_overrides() {
499        let (_, pk) = generate_keypair();
500        let card = build_agent_card(
501            "carol",
502            &pk,
503            Some("Carol's Agent"),
504            Some(vec!["custom-cap".to_string()]),
505            Some(128),
506        );
507        assert_eq!(card["name"], "Carol's Agent");
508        assert_eq!(card["capabilities"], json!(["custom-cap"]));
509        assert_eq!(card["policies"]["max_message_body_kb"], 128);
510    }
511
512    #[test]
513    fn build_card_does_not_carry_v02_fields() {
514        let (_, pk) = generate_keypair();
515        let card = build_agent_card("paul", &pk, None, None, None);
516        let obj = card.as_object().unwrap();
517        for v02 in [
518            "registries",
519            "onboard_endpoint",
520            "wire_raw_url_template",
521            "revoked_at",
522        ] {
523            assert!(
524                !obj.contains_key(v02),
525                "v0.2+ field {v02} leaked into v0.1 card"
526            );
527        }
528    }
529
530    #[test]
531    fn card_canonical_excludes_signature() {
532        let v = json!({"schema_version": "v3.1", "did": "did:wire:paul", "signature": "sig"});
533        let bytes = card_canonical(&v);
534        assert!(!String::from_utf8_lossy(&bytes).contains("signature"));
535    }
536
537    #[test]
538    fn card_canonical_sort_keys_stable() {
539        let a = json!({"b": 1, "a": 2, "did": "did:wire:paul"});
540        let b = json!({"did": "did:wire:paul", "a": 2, "b": 1});
541        assert_eq!(card_canonical(&a), card_canonical(&b));
542    }
543
544    #[test]
545    fn sign_verify_roundtrip() {
546        let (sk, pk) = generate_keypair();
547        let card = build_agent_card("paul", &pk, None, None, None);
548        let signed = sign_agent_card(&card, &sk);
549        assert!(signed.get("signature").is_some());
550        verify_agent_card(&signed).unwrap();
551    }
552
553    #[test]
554    fn verify_rejects_unsigned_card() {
555        let (_, pk) = generate_keypair();
556        let card = build_agent_card("paul", &pk, None, None, None);
557        let err = verify_agent_card(&card).unwrap_err();
558        assert!(matches!(err, CardError::MissingField("signature")));
559    }
560
561    #[test]
562    fn verify_rejects_tampered_card() {
563        let (sk, pk) = generate_keypair();
564        let mut signed = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
565        signed["name"] = json!("TamperedName");
566        let err = verify_agent_card(&signed).unwrap_err();
567        assert!(matches!(err, CardError::SignatureRejected));
568    }
569
570    #[test]
571    fn verify_rejects_card_with_no_verify_keys() {
572        let (sk, _) = generate_keypair();
573        let card = json!({"schema_version": "v3.1", "did": "did:wire:paul", "verify_keys": {}});
574        let signed = sign_agent_card(&card, &sk);
575        let err = verify_agent_card(&signed).unwrap_err();
576        assert!(matches!(err, CardError::NoVerifyKeys));
577    }
578
579    #[test]
580    fn compute_sas_is_6_digits() {
581        let (_, a) = generate_keypair();
582        let (_, b) = generate_keypair();
583        let sas = compute_sas(&a, &b);
584        assert_eq!(sas.len(), 6);
585        assert!(sas.chars().all(|c| c.is_ascii_digit()));
586    }
587
588    #[test]
589    fn compute_sas_bilateral_symmetric() {
590        let (_, a) = generate_keypair();
591        let (_, b) = generate_keypair();
592        assert_eq!(compute_sas(&a, &b), compute_sas(&b, &a));
593    }
594
595    #[test]
596    fn compute_sas_changes_with_inputs() {
597        let (_, a) = generate_keypair();
598        let (_, b) = generate_keypair();
599        let (_, c) = generate_keypair();
600        assert_ne!(compute_sas(&a, &b), compute_sas(&a, &c));
601    }
602
603    // ─── RFC-001 §1: identity claims ───────────────────────────────────────
604
605    fn op_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
606        let (sk, pk) = generate_keypair();
607        (did_for_op(handle, &pk), sk.to_vec(), pk.to_vec())
608    }
609
610    fn org_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
611        let (sk, pk) = generate_keypair();
612        (did_for_org(handle, &pk), sk.to_vec(), pk.to_vec())
613    }
614
615    #[test]
616    fn schema_version_is_v3_2() {
617        assert_eq!(CARD_SCHEMA_VERSION, "v3.2");
618    }
619
620    #[test]
621    fn op_did_has_long_hex_suffix_and_method_prefix() {
622        let (did, _, _) = op_did_for_test("darby");
623        assert!(did.starts_with("did:wire:op:darby-"), "got: {did}");
624        let tail = did.rsplit('-').next().unwrap();
625        assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
626        assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
627    }
628
629    #[test]
630    fn org_did_has_long_hex_suffix_and_method_prefix() {
631        let (did, _, _) = org_did_for_test("slanchaai");
632        assert!(did.starts_with("did:wire:org:slanchaai-"), "got: {did}");
633        let tail = did.rsplit('-').next().unwrap();
634        assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
635        assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
636    }
637
638    #[test]
639    fn op_did_passthrough_when_already_op_did() {
640        // Passing a fully-formed op_did back through `did_for_op` is a no-op;
641        // protects callers that mix raw handles + already-built DIDs.
642        let (_, pk) = generate_keypair();
643        let did = did_for_op("darby", &pk);
644        let again = did_for_op(&did, &pk);
645        assert_eq!(did, again);
646    }
647
648    #[test]
649    fn is_op_did_rejects_session_did() {
650        // The classification check exists precisely to refuse this confusion.
651        let (_, pk) = generate_keypair();
652        let session_did = did_for_with_key("darby", &pk);
653        assert!(!is_op_did(&session_did));
654        assert!(!is_org_did(&session_did));
655    }
656
657    #[test]
658    fn is_op_did_rejects_org_did_and_vice_versa() {
659        // Disjoint namespaces — an org_did is not an op_did even though both
660        // share the long-hex suffix shape.
661        let (op, _, _) = op_did_for_test("darby");
662        let (org, _, _) = org_did_for_test("slanchaai");
663        assert!(is_op_did(&op) && !is_org_did(&op));
664        assert!(is_org_did(&org) && !is_op_did(&org));
665    }
666
667    #[test]
668    fn is_op_did_rejects_short_hex_suffix() {
669        // An 8-hex tail (session-DID shape) under the op prefix would be a
670        // namespace squat. Refuse on syntax alone.
671        assert!(!is_op_did("did:wire:op:darby-deadbeef"));
672        assert!(!is_org_did("did:wire:org:slanchaai-deadbeef"));
673    }
674
675    #[test]
676    fn is_op_did_rejects_non_hex_suffix() {
677        let bad = format!("did:wire:op:darby-{}", "z".repeat(LONG_FINGERPRINT_HEX_LEN));
678        assert!(!is_op_did(&bad));
679    }
680
681    #[test]
682    fn with_identity_claims_attaches_all_fields() {
683        let (sk, pk) = generate_keypair();
684        let card = build_agent_card("vesper-valley", &pk, None, None, None);
685        let (op_did, _, op_pk) = op_did_for_test("darby");
686        let (org_did, _, org_pk) = org_did_for_test("slanchaai");
687        let op_pubkey = crate::signing::b64encode(&op_pk);
688        let org_pubkey = crate::signing::b64encode(&org_pk);
689        let claims = IdentityClaims {
690            op_did: Some(op_did.clone()),
691            op_cert: Some("AAAA".into()),
692            op_pubkey: Some(op_pubkey.clone()),
693            org_memberships: vec![OrgMembership {
694                org_did: org_did.clone(),
695                org_pubkey: org_pubkey.clone(),
696                member_cert: "BBBB".into(),
697            }],
698            project: Some("wire-codex-integration".into()),
699        };
700        let with = with_identity_claims(&card, &claims).unwrap();
701        assert_eq!(card_op_did(&with), Some(op_did.as_str()));
702        assert_eq!(card_op_cert(&with), Some("AAAA"));
703        assert_eq!(
704            with.get("op_pubkey").and_then(|v| v.as_str()),
705            Some(op_pubkey.as_str())
706        );
707        assert_eq!(card_project(&with), Some("wire-codex-integration"));
708        let orgs = card_org_memberships(&with);
709        assert_eq!(orgs.len(), 1);
710        assert_eq!(orgs[0], (org_did.as_str(), "BBBB"));
711        assert_eq!(
712            with.get("org_memberships").unwrap()[0]
713                .get("org_pubkey")
714                .and_then(|v| v.as_str()),
715            Some(org_pubkey.as_str())
716        );
717        // Card still signs + verifies after identity claims are layered.
718        let signed = sign_agent_card(&with, &sk);
719        verify_agent_card(&signed).unwrap();
720    }
721
722    #[test]
723    fn with_identity_claims_skips_absent_fields() {
724        // A card with no claims must not gain empty `op_did`/`project`/etc.
725        // entries — keeps canonical bytes minimal and v3.1-peer-friendly.
726        let (_, pk) = generate_keypair();
727        let card = build_agent_card("vesper-valley", &pk, None, None, None);
728        let with = with_identity_claims(&card, &IdentityClaims::default()).unwrap();
729        let obj = with.as_object().unwrap();
730        for field in ["op_did", "op_cert", "org_memberships", "project"] {
731            assert!(
732                !obj.contains_key(field),
733                "{field} leaked into claim-less card"
734            );
735        }
736    }
737
738    #[test]
739    fn with_identity_claims_rejects_malformed_op_did() {
740        let (_, pk) = generate_keypair();
741        let card = build_agent_card("vesper-valley", &pk, None, None, None);
742        let claims = IdentityClaims {
743            // Session-DID shape under op prefix → namespace confusion.
744            op_did: Some("did:wire:op:darby-deadbeef".into()),
745            ..Default::default()
746        };
747        let err = with_identity_claims(&card, &claims).unwrap_err();
748        assert!(matches!(err, ClaimError::InvalidOpDid(_)));
749    }
750
751    #[test]
752    fn with_identity_claims_rejects_malformed_org_did() {
753        let (_, pk) = generate_keypair();
754        let card = build_agent_card("vesper-valley", &pk, None, None, None);
755        let claims = IdentityClaims {
756            org_memberships: vec![OrgMembership {
757                org_did: "did:wire:slanchaai".into(),
758                org_pubkey: "AAAA".into(),
759                member_cert: "BBBB".into(),
760            }],
761            ..Default::default()
762        };
763        let err = with_identity_claims(&card, &claims).unwrap_err();
764        assert!(matches!(err, ClaimError::InvalidOrgDid(_)));
765    }
766
767    #[test]
768    fn v3_1_card_remains_verifiable_under_v3_2_code() {
769        // Backward-compat: a v3.1-shaped card (no identity claims, schema
770        // string literally "v3.1") still round-trips signing and verify.
771        // This is the wire-compat invariant — peers on the network mid-
772        // upgrade keep talking.
773        let (sk, pk) = generate_keypair();
774        let mut card = build_agent_card("paul", &pk, None, None, None);
775        card["schema_version"] = json!("v3.1");
776        let signed = sign_agent_card(&card, &sk);
777        verify_agent_card(&signed).unwrap();
778    }
779
780    #[test]
781    fn build_agent_card_default_capability_advertises_v3_2() {
782        let (_, pk) = generate_keypair();
783        let card = build_agent_card("paul", &pk, None, None, None);
784        let caps = card["capabilities"].as_array().unwrap();
785        let has_v32 = caps.iter().any(|v| v.as_str() == Some("wire/v3.2"));
786        assert!(has_v32, "default caps should advertise wire/v3.2: {caps:?}");
787    }
788}