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    // v0.14.x retro-fix: when ANY RFC-001 op claim lands on the card,
340    // bump `schema_version` to at least `CARD_SCHEMA_VERSION` (currently
341    // "v3.2"). Existing cards minted at v3.1 keep their version field
342    // until republish hits this path — at which point the version
343    // matches the inline-fields shape. Monotonic (never downgrades): a
344    // card already at >= v3.2 is unchanged. Readers that key off
345    // `schema_version >= "v3.2"` to discriminate "carries op claims"
346    // now have a truthful signal. (The bug it closes: v0.14 stored
347    // op_did but kept emitting `schema_version: "v3.1"` — readers
348    // couldn't tell from the version alone whether the card had
349    // op claims; they had to probe the inline fields directly.)
350    let has_any_op_claim = claims.op_did.is_some()
351        || claims.op_cert.is_some()
352        || claims.op_pubkey.is_some()
353        || !claims.org_memberships.is_empty();
354    if has_any_op_claim {
355        let current = out
356            .get("schema_version")
357            .and_then(Value::as_str)
358            .unwrap_or("v3.0");
359        let target = max_schema_version(current, CARD_SCHEMA_VERSION);
360        out.insert("schema_version".into(), Value::String(target.to_string()));
361    }
362
363    Ok(Value::Object(out))
364}
365
366/// Compare two `vX.Y` schema-version strings as `(major, minor)` integer
367/// tuples and return the higher. Defensive: unparseable inputs fall back
368/// to the OTHER argument (so a malformed stored card doesn't poison the
369/// republish). `v3.10` correctly compares as > `v3.2`.
370fn max_schema_version<'a>(a: &'a str, b: &'a str) -> &'a str {
371    fn parse(s: &str) -> Option<(u32, u32)> {
372        let rest = s.strip_prefix('v')?;
373        let (maj, min) = rest.split_once('.')?;
374        Some((maj.parse().ok()?, min.parse().ok()?))
375    }
376    match (parse(a), parse(b)) {
377        (Some(pa), Some(pb)) => {
378            if pa >= pb {
379                a
380            } else {
381                b
382            }
383        }
384        // Bias toward the parseable one; if neither parses, keep `a`.
385        (Some(_), None) => a,
386        (None, Some(_)) => b,
387        (None, None) => a,
388    }
389}
390
391#[derive(Debug, Error)]
392pub enum ClaimError {
393    #[error("op_did is not a well-formed did:wire:op:<handle>-<32hex>: {0}")]
394    InvalidOpDid(String),
395    #[error("org_did is not a well-formed did:wire:org:<handle>-<32hex>: {0}")]
396    InvalidOrgDid(String),
397}
398
399/// Read `op_did` from a card. Returns `None` if absent or malformed.
400pub fn card_op_did(card: &AgentCard) -> Option<&str> {
401    card.get("op_did").and_then(Value::as_str)
402}
403
404/// Read `op_cert` from a card. Returns `None` if absent.
405pub fn card_op_cert(card: &AgentCard) -> Option<&str> {
406    card.get("op_cert").and_then(Value::as_str)
407}
408
409/// Read `project` routing tag from a card.
410pub fn card_project(card: &AgentCard) -> Option<&str> {
411    card.get("project").and_then(Value::as_str)
412}
413
414/// Read `org_memberships[]` from a card as a list of `(org_did,
415/// member_cert)` borrowed pairs. Returns empty if absent or malformed.
416pub fn card_org_memberships(card: &AgentCard) -> Vec<(&str, &str)> {
417    card.get("org_memberships")
418        .and_then(Value::as_array)
419        .map(|arr| {
420            arr.iter()
421                .filter_map(|entry| {
422                    let org = entry.get("org_did").and_then(Value::as_str)?;
423                    let cert = entry.get("member_cert").and_then(Value::as_str)?;
424                    Some((org, cert))
425                })
426                .collect()
427        })
428        .unwrap_or_default()
429}
430
431/// Canonical bytes of an agent card — strips `signature` before serialization.
432pub fn card_canonical(card: &AgentCard) -> Vec<u8> {
433    canonical(card, false)
434}
435
436/// Sign an agent card with `private_key`. Returns the card with `signature`
437/// field appended (base64 of Ed25519 signature over `card_canonical(card)`).
438pub fn sign_agent_card(card: &AgentCard, private_key: &[u8]) -> AgentCard {
439    let mut sk_bytes = [0u8; 32];
440    sk_bytes.copy_from_slice(&private_key[..32]);
441    let sk = SigningKey::from_bytes(&sk_bytes);
442    let sig = sk.sign(&card_canonical(card));
443    let mut out = card.as_object().cloned().unwrap_or_default();
444    out.insert(
445        "signature".into(),
446        Value::String(b64encode(&sig.to_bytes())),
447    );
448    Value::Object(out)
449}
450
451/// Verify a signed card. Picks the first verify_key, validates the
452/// signature over `card_canonical(card)` (stripped of `signature`).
453pub fn verify_agent_card(card: &AgentCard) -> Result<(), CardError> {
454    let signature_b64 = card
455        .get("signature")
456        .and_then(Value::as_str)
457        .ok_or(CardError::MissingField("signature"))?;
458
459    let verify_keys = card
460        .get("verify_keys")
461        .and_then(Value::as_object)
462        .ok_or(CardError::MissingField("verify_keys"))?;
463
464    let (_kid, key_record) = verify_keys.iter().next().ok_or(CardError::NoVerifyKeys)?;
465    let pk_b64 = key_record
466        .get("key")
467        .and_then(Value::as_str)
468        .ok_or(CardError::MissingField("verify_keys[*].key"))?;
469    let pk_bytes = b64decode(pk_b64).map_err(|_| CardError::BadSignature)?;
470    if pk_bytes.len() != 32 {
471        return Err(CardError::BadSignature);
472    }
473    let mut pk_arr = [0u8; 32];
474    pk_arr.copy_from_slice(&pk_bytes);
475    let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| CardError::BadSignature)?;
476
477    let sig_bytes = b64decode(signature_b64).map_err(|_| CardError::BadSignature)?;
478    if sig_bytes.len() != 64 {
479        return Err(CardError::BadSignature);
480    }
481    let mut sig_arr = [0u8; 64];
482    sig_arr.copy_from_slice(&sig_bytes);
483    let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
484
485    vk.verify(&card_canonical(card), &sig)
486        .map_err(|_| CardError::SignatureRejected)
487}
488
489/// 6-digit bilateral SAS over two raw 32-byte public keys.
490///
491/// `sha256(min(a, b) || max(a, b))` then take the last 6 decimal digits.
492/// Symmetric in `(a, b)` so either operator computes the same digits from
493/// independent knowledge of both keys.
494pub fn compute_sas(public_key_a: &[u8], public_key_b: &[u8]) -> String {
495    let (lo, hi) = if public_key_a <= public_key_b {
496        (public_key_a, public_key_b)
497    } else {
498        (public_key_b, public_key_a)
499    };
500    let mut h = Sha256::new();
501    h.update(lo);
502    h.update(hi);
503    let digest = h.finalize();
504    // Take low 4 bytes -> u32, mod 1_000_000 for 6 digits.
505    let n = u32::from_be_bytes([digest[28], digest[29], digest[30], digest[31]]);
506    format!("{:06}", n % 1_000_000)
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::signing::generate_keypair;
513
514    #[test]
515    fn did_for_handle() {
516        assert_eq!(did_for("paul"), "did:wire:paul");
517    }
518
519    #[test]
520    fn did_for_already_did_passthrough() {
521        assert_eq!(did_for("did:wire:paul"), "did:wire:paul");
522        assert_eq!(did_for("did:key:abc"), "did:key:abc");
523    }
524
525    #[test]
526    fn did_method_constant() {
527        assert_eq!(DID_METHOD, "did:wire");
528    }
529
530    #[test]
531    fn build_minimal_card() {
532        let (_, pk) = generate_keypair();
533        let card = build_agent_card("paul", &pk, None, None, None);
534        assert_eq!(card["schema_version"], CARD_SCHEMA_VERSION);
535        // v0.5.7+: DID is pubkey-suffixed for cross-operator uniqueness.
536        let did = card["did"].as_str().unwrap();
537        assert!(did.starts_with("did:wire:paul-"), "got: {did}");
538        assert_eq!(did.len(), "did:wire:paul-".len() + 8);
539        assert_eq!(card["handle"], "paul");
540        assert_eq!(card["name"], "Paul");
541        let vks = card["verify_keys"].as_object().unwrap();
542        assert_eq!(vks.len(), 1);
543        assert_eq!(card["policies"]["max_message_body_kb"], 64);
544    }
545
546    #[test]
547    fn build_card_with_overrides() {
548        let (_, pk) = generate_keypair();
549        let card = build_agent_card(
550            "carol",
551            &pk,
552            Some("Carol's Agent"),
553            Some(vec!["custom-cap".to_string()]),
554            Some(128),
555        );
556        assert_eq!(card["name"], "Carol's Agent");
557        assert_eq!(card["capabilities"], json!(["custom-cap"]));
558        assert_eq!(card["policies"]["max_message_body_kb"], 128);
559    }
560
561    #[test]
562    fn build_card_does_not_carry_v02_fields() {
563        let (_, pk) = generate_keypair();
564        let card = build_agent_card("paul", &pk, None, None, None);
565        let obj = card.as_object().unwrap();
566        for v02 in [
567            "registries",
568            "onboard_endpoint",
569            "wire_raw_url_template",
570            "revoked_at",
571        ] {
572            assert!(
573                !obj.contains_key(v02),
574                "v0.2+ field {v02} leaked into v0.1 card"
575            );
576        }
577    }
578
579    #[test]
580    fn card_canonical_excludes_signature() {
581        let v = json!({"schema_version": "v3.1", "did": "did:wire:paul", "signature": "sig"});
582        let bytes = card_canonical(&v);
583        assert!(!String::from_utf8_lossy(&bytes).contains("signature"));
584    }
585
586    #[test]
587    fn card_canonical_sort_keys_stable() {
588        let a = json!({"b": 1, "a": 2, "did": "did:wire:paul"});
589        let b = json!({"did": "did:wire:paul", "a": 2, "b": 1});
590        assert_eq!(card_canonical(&a), card_canonical(&b));
591    }
592
593    #[test]
594    fn sign_verify_roundtrip() {
595        let (sk, pk) = generate_keypair();
596        let card = build_agent_card("paul", &pk, None, None, None);
597        let signed = sign_agent_card(&card, &sk);
598        assert!(signed.get("signature").is_some());
599        verify_agent_card(&signed).unwrap();
600    }
601
602    #[test]
603    fn verify_rejects_unsigned_card() {
604        let (_, pk) = generate_keypair();
605        let card = build_agent_card("paul", &pk, None, None, None);
606        let err = verify_agent_card(&card).unwrap_err();
607        assert!(matches!(err, CardError::MissingField("signature")));
608    }
609
610    #[test]
611    fn verify_rejects_tampered_card() {
612        let (sk, pk) = generate_keypair();
613        let mut signed = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
614        signed["name"] = json!("TamperedName");
615        let err = verify_agent_card(&signed).unwrap_err();
616        assert!(matches!(err, CardError::SignatureRejected));
617    }
618
619    #[test]
620    fn verify_rejects_card_with_no_verify_keys() {
621        let (sk, _) = generate_keypair();
622        let card = json!({"schema_version": "v3.1", "did": "did:wire:paul", "verify_keys": {}});
623        let signed = sign_agent_card(&card, &sk);
624        let err = verify_agent_card(&signed).unwrap_err();
625        assert!(matches!(err, CardError::NoVerifyKeys));
626    }
627
628    #[test]
629    fn compute_sas_is_6_digits() {
630        let (_, a) = generate_keypair();
631        let (_, b) = generate_keypair();
632        let sas = compute_sas(&a, &b);
633        assert_eq!(sas.len(), 6);
634        assert!(sas.chars().all(|c| c.is_ascii_digit()));
635    }
636
637    #[test]
638    fn compute_sas_bilateral_symmetric() {
639        let (_, a) = generate_keypair();
640        let (_, b) = generate_keypair();
641        assert_eq!(compute_sas(&a, &b), compute_sas(&b, &a));
642    }
643
644    #[test]
645    fn compute_sas_changes_with_inputs() {
646        let (_, a) = generate_keypair();
647        let (_, b) = generate_keypair();
648        let (_, c) = generate_keypair();
649        assert_ne!(compute_sas(&a, &b), compute_sas(&a, &c));
650    }
651
652    // ─── RFC-001 §1: identity claims ───────────────────────────────────────
653
654    fn op_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
655        let (sk, pk) = generate_keypair();
656        (did_for_op(handle, &pk), sk.to_vec(), pk.to_vec())
657    }
658
659    fn org_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
660        let (sk, pk) = generate_keypair();
661        (did_for_org(handle, &pk), sk.to_vec(), pk.to_vec())
662    }
663
664    #[test]
665    fn schema_version_is_v3_2() {
666        assert_eq!(CARD_SCHEMA_VERSION, "v3.2");
667    }
668
669    #[test]
670    fn op_did_has_long_hex_suffix_and_method_prefix() {
671        let (did, _, _) = op_did_for_test("darby");
672        assert!(did.starts_with("did:wire:op:darby-"), "got: {did}");
673        let tail = did.rsplit('-').next().unwrap();
674        assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
675        assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
676    }
677
678    #[test]
679    fn org_did_has_long_hex_suffix_and_method_prefix() {
680        let (did, _, _) = org_did_for_test("slanchaai");
681        assert!(did.starts_with("did:wire:org:slanchaai-"), "got: {did}");
682        let tail = did.rsplit('-').next().unwrap();
683        assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
684        assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
685    }
686
687    #[test]
688    fn op_did_passthrough_when_already_op_did() {
689        // Passing a fully-formed op_did back through `did_for_op` is a no-op;
690        // protects callers that mix raw handles + already-built DIDs.
691        let (_, pk) = generate_keypair();
692        let did = did_for_op("darby", &pk);
693        let again = did_for_op(&did, &pk);
694        assert_eq!(did, again);
695    }
696
697    #[test]
698    fn is_op_did_rejects_session_did() {
699        // The classification check exists precisely to refuse this confusion.
700        let (_, pk) = generate_keypair();
701        let session_did = did_for_with_key("darby", &pk);
702        assert!(!is_op_did(&session_did));
703        assert!(!is_org_did(&session_did));
704    }
705
706    #[test]
707    fn is_op_did_rejects_org_did_and_vice_versa() {
708        // Disjoint namespaces — an org_did is not an op_did even though both
709        // share the long-hex suffix shape.
710        let (op, _, _) = op_did_for_test("darby");
711        let (org, _, _) = org_did_for_test("slanchaai");
712        assert!(is_op_did(&op) && !is_org_did(&op));
713        assert!(is_org_did(&org) && !is_op_did(&org));
714    }
715
716    #[test]
717    fn is_op_did_rejects_short_hex_suffix() {
718        // An 8-hex tail (session-DID shape) under the op prefix would be a
719        // namespace squat. Refuse on syntax alone.
720        assert!(!is_op_did("did:wire:op:darby-deadbeef"));
721        assert!(!is_org_did("did:wire:org:slanchaai-deadbeef"));
722    }
723
724    #[test]
725    fn is_op_did_rejects_non_hex_suffix() {
726        let bad = format!("did:wire:op:darby-{}", "z".repeat(LONG_FINGERPRINT_HEX_LEN));
727        assert!(!is_op_did(&bad));
728    }
729
730    #[test]
731    fn with_identity_claims_attaches_all_fields() {
732        let (sk, pk) = generate_keypair();
733        let card = build_agent_card("vesper-valley", &pk, None, None, None);
734        let (op_did, _, op_pk) = op_did_for_test("darby");
735        let (org_did, _, org_pk) = org_did_for_test("slanchaai");
736        let op_pubkey = crate::signing::b64encode(&op_pk);
737        let org_pubkey = crate::signing::b64encode(&org_pk);
738        let claims = IdentityClaims {
739            op_did: Some(op_did.clone()),
740            op_cert: Some("AAAA".into()),
741            op_pubkey: Some(op_pubkey.clone()),
742            org_memberships: vec![OrgMembership {
743                org_did: org_did.clone(),
744                org_pubkey: org_pubkey.clone(),
745                member_cert: "BBBB".into(),
746            }],
747            project: Some("wire-codex-integration".into()),
748        };
749        let with = with_identity_claims(&card, &claims).unwrap();
750        assert_eq!(card_op_did(&with), Some(op_did.as_str()));
751        assert_eq!(card_op_cert(&with), Some("AAAA"));
752        assert_eq!(
753            with.get("op_pubkey").and_then(|v| v.as_str()),
754            Some(op_pubkey.as_str())
755        );
756        assert_eq!(card_project(&with), Some("wire-codex-integration"));
757        let orgs = card_org_memberships(&with);
758        assert_eq!(orgs.len(), 1);
759        assert_eq!(orgs[0], (org_did.as_str(), "BBBB"));
760        assert_eq!(
761            with.get("org_memberships").unwrap()[0]
762                .get("org_pubkey")
763                .and_then(|v| v.as_str()),
764            Some(org_pubkey.as_str())
765        );
766        // Card still signs + verifies after identity claims are layered.
767        let signed = sign_agent_card(&with, &sk);
768        verify_agent_card(&signed).unwrap();
769    }
770
771    #[test]
772    fn with_identity_claims_skips_absent_fields() {
773        // A card with no claims must not gain empty `op_did`/`project`/etc.
774        // entries — keeps canonical bytes minimal and v3.1-peer-friendly.
775        let (_, pk) = generate_keypair();
776        let card = build_agent_card("vesper-valley", &pk, None, None, None);
777        let with = with_identity_claims(&card, &IdentityClaims::default()).unwrap();
778        let obj = with.as_object().unwrap();
779        for field in ["op_did", "op_cert", "org_memberships", "project"] {
780            assert!(
781                !obj.contains_key(field),
782                "{field} leaked into claim-less card"
783            );
784        }
785    }
786
787    #[test]
788    fn with_identity_claims_rejects_malformed_op_did() {
789        let (_, pk) = generate_keypair();
790        let card = build_agent_card("vesper-valley", &pk, None, None, None);
791        let claims = IdentityClaims {
792            // Session-DID shape under op prefix → namespace confusion.
793            op_did: Some("did:wire:op:darby-deadbeef".into()),
794            ..Default::default()
795        };
796        let err = with_identity_claims(&card, &claims).unwrap_err();
797        assert!(matches!(err, ClaimError::InvalidOpDid(_)));
798    }
799
800    #[test]
801    fn with_identity_claims_rejects_malformed_org_did() {
802        let (_, pk) = generate_keypair();
803        let card = build_agent_card("vesper-valley", &pk, None, None, None);
804        let claims = IdentityClaims {
805            org_memberships: vec![OrgMembership {
806                org_did: "did:wire:slanchaai".into(),
807                org_pubkey: "AAAA".into(),
808                member_cert: "BBBB".into(),
809            }],
810            ..Default::default()
811        };
812        let err = with_identity_claims(&card, &claims).unwrap_err();
813        assert!(matches!(err, ClaimError::InvalidOrgDid(_)));
814    }
815
816    #[test]
817    fn v3_1_card_remains_verifiable_under_v3_2_code() {
818        // Backward-compat: a v3.1-shaped card (no identity claims, schema
819        // string literally "v3.1") still round-trips signing and verify.
820        // This is the wire-compat invariant — peers on the network mid-
821        // upgrade keep talking.
822        let (sk, pk) = generate_keypair();
823        let mut card = build_agent_card("paul", &pk, None, None, None);
824        card["schema_version"] = json!("v3.1");
825        let signed = sign_agent_card(&card, &sk);
826        verify_agent_card(&signed).unwrap();
827    }
828
829    #[test]
830    fn build_agent_card_default_capability_advertises_v3_2() {
831        let (_, pk) = generate_keypair();
832        let card = build_agent_card("paul", &pk, None, None, None);
833        let caps = card["capabilities"].as_array().unwrap();
834        let has_v32 = caps.iter().any(|v| v.as_str() == Some("wire/v3.2"));
835        assert!(has_v32, "default caps should advertise wire/v3.2: {caps:?}");
836    }
837
838    // v0.14.x retro-fix tests: when op claims are attached, the card's
839    // `schema_version` field bumps to at least `CARD_SCHEMA_VERSION`. The
840    // bump is monotonic (never downgrades), conditional (claim-less
841    // attach leaves the field alone), and version-numeric (v3.10 > v3.2,
842    // not lexicographic).
843
844    #[test]
845    fn with_identity_claims_bumps_schema_version_when_op_did_attached() {
846        // A card that was minted at v3.1 (the pre-v0.14 emit version)
847        // must surface as >= v3.2 once op claims are attached — readers
848        // discriminate "card carries op_*" off the version field.
849        let (_, pk) = generate_keypair();
850        let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
851        // Roll back to v3.1 to simulate a pre-v0.14 stored card.
852        card.as_object_mut()
853            .unwrap()
854            .insert("schema_version".into(), json!("v3.1"));
855        let (op_did, _, op_pk) = op_did_for_test("darby");
856        let claims = IdentityClaims {
857            op_did: Some(op_did),
858            op_pubkey: Some(crate::signing::b64encode(&op_pk)),
859            op_cert: Some("AAAA".into()),
860            ..Default::default()
861        };
862        let with = with_identity_claims(&card, &claims).unwrap();
863        assert_eq!(
864            with.get("schema_version").and_then(|v| v.as_str()),
865            Some(CARD_SCHEMA_VERSION),
866            "post-attach schema_version must bump to {CARD_SCHEMA_VERSION}",
867        );
868    }
869
870    #[test]
871    fn with_identity_claims_does_not_touch_schema_version_when_no_claims() {
872        // Claim-less attach (e.g. an unenrolled operator's republish)
873        // leaves the version field exactly as it was — no spurious bump
874        // for a v3.1 peer that has zero op_* fields to surface.
875        let (_, pk) = generate_keypair();
876        let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
877        card.as_object_mut()
878            .unwrap()
879            .insert("schema_version".into(), json!("v3.1"));
880        let with = with_identity_claims(&card, &IdentityClaims::default()).unwrap();
881        assert_eq!(
882            with.get("schema_version").and_then(|v| v.as_str()),
883            Some("v3.1"),
884            "claim-less attach must NOT bump",
885        );
886    }
887
888    #[test]
889    fn with_identity_claims_never_downgrades_schema_version() {
890        // A hypothetical v3.5 card (future extension peer) attaching op
891        // claims via an older `CARD_SCHEMA_VERSION` build must NOT lose
892        // its higher version. Monotonic invariant.
893        let (_, pk) = generate_keypair();
894        let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
895        card.as_object_mut()
896            .unwrap()
897            .insert("schema_version".into(), json!("v3.5"));
898        let (op_did, _, op_pk) = op_did_for_test("darby");
899        let claims = IdentityClaims {
900            op_did: Some(op_did),
901            op_pubkey: Some(crate::signing::b64encode(&op_pk)),
902            op_cert: Some("AAAA".into()),
903            ..Default::default()
904        };
905        let with = with_identity_claims(&card, &claims).unwrap();
906        assert_eq!(
907            with.get("schema_version").and_then(|v| v.as_str()),
908            Some("v3.5"),
909            "monotonic bump must not downgrade v3.5 to {CARD_SCHEMA_VERSION}",
910        );
911    }
912
913    #[test]
914    fn max_schema_version_compares_numerically_not_lexicographically() {
915        // Lexicographic compare would call "v3.10" < "v3.2" because '1' <
916        // '2'. The helper parses to (major, minor) ints so v3.10 > v3.2.
917        assert_eq!(max_schema_version("v3.10", "v3.2"), "v3.10");
918        assert_eq!(max_schema_version("v3.2", "v3.10"), "v3.10");
919        assert_eq!(max_schema_version("v3.2", "v3.2"), "v3.2");
920        assert_eq!(max_schema_version("v4.0", "v3.99"), "v4.0");
921    }
922
923    #[test]
924    fn max_schema_version_biases_to_parseable_on_malformed_input() {
925        // A malformed stored card must not poison the republish: parseable
926        // wins, both-malformed keeps `a` (deterministic, no panic).
927        assert_eq!(max_schema_version("garbage", "v3.2"), "v3.2");
928        assert_eq!(max_schema_version("v3.2", "garbage"), "v3.2");
929        assert_eq!(max_schema_version("garbage1", "garbage2"), "garbage1");
930    }
931}