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/// Strip the federation suffix (`@relay.example`) from a handle, returning
115/// the bare local-part. This is the canonical on-disk form: outbox/inbox
116/// files are keyed by bare handle (`paul-mac.jsonl`), and the pinned-peers
117/// map in `relay_state.json` is keyed by bare handle.
118///
119/// Why this exists (v0.5.13): `wire send paul-mac@wireup.net "..."` used
120/// to write the outbox to `paul-mac@wireup.net.jsonl`, but `wire push`
121/// only enumerated bare-handle filenames. Events stuck silently for 25
122/// minutes (issue #2). Normalizing here makes the on-disk contract the
123/// single source of truth — accepts both `paul-mac` and `paul-mac@host`,
124/// always writes to `paul-mac.jsonl`.
125pub fn bare_handle(handle: &str) -> &str {
126    handle.split_once('@').map(|(n, _)| n).unwrap_or(handle)
127}
128
129/// Extract the display-friendly handle from a DID. Handles both legacy
130/// (`did:wire:paul`) and v0.5.7+ (`did:wire:paul-abc12345`) forms. The
131/// v0.5.7 trailing `-<8-hex>` suffix is stripped when present.
132pub fn display_handle_from_did(did: &str) -> &str {
133    let stripped = did.strip_prefix("did:wire:").unwrap_or(did);
134    // v0.5.7+ form: `<handle>-<8-hex>`. Detect by trailing exactly 8 hex
135    // chars after a final `-`. Anything else passes through unchanged.
136    if let Some(idx) = stripped.rfind('-') {
137        let suffix = &stripped[idx + 1..];
138        if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
139            return &stripped[..idx];
140        }
141    }
142    stripped
143}
144
145/// Convenience type — at this stage we use serde_json::Value so the wire
146/// shape stays explicit. A typed struct can come in v0.2+.
147pub type AgentCard = Value;
148
149#[derive(Debug, Error)]
150pub enum CardError {
151    #[error("missing field: {0}")]
152    MissingField(&'static str),
153    #[error("verify_keys is empty or malformed")]
154    NoVerifyKeys,
155    #[error("signature decode failed")]
156    BadSignature,
157    #[error("signature did not verify")]
158    SignatureRejected,
159}
160
161/// Build an unsigned agent card for `handle` with one verify key.
162///
163/// Optional overrides:
164///   - `name`: human-friendly display name (defaults to capitalized handle)
165///   - `capabilities`: list of capability strings (defaults to `["wire/v3.2"]`)
166///   - `max_body_kb`: per-message body cap in KB (defaults to 64)
167///
168/// v0.1 deliberately does NOT include `registries`, `onboard_endpoint`,
169/// `wire_raw_url_template`, or `revoked_at`. Those land in v0.2+ along
170/// with the registry feature itself (see ANTI_FEATURES.md).
171pub fn build_agent_card(
172    handle: &str,
173    public_key: &[u8],
174    name: Option<&str>,
175    capabilities: Option<Vec<String>>,
176    max_body_kb: Option<u64>,
177) -> AgentCard {
178    let display_name = name
179        .map(str::to_string)
180        .unwrap_or_else(|| capitalize(handle));
181    let caps = capabilities.unwrap_or_else(|| vec!["wire/v3.2".to_string()]);
182    let body_kb = max_body_kb.unwrap_or(64);
183
184    let key_id = make_key_id(handle, public_key);
185    let key_id_full = format!("ed25519:{key_id}");
186
187    json!({
188        "schema_version": CARD_SCHEMA_VERSION,
189        "did": did_for_with_key(handle, public_key),
190        "handle": handle,
191        "name": display_name,
192        "capabilities": caps,
193        "verify_keys": {
194            key_id_full: {
195                "key": b64encode(public_key),
196                "alg": "ed25519",
197                "active": true,
198            }
199        },
200        "policies": {
201            "max_message_body_kb": body_kb,
202        }
203    })
204}
205
206/// Capitalize the first character of an ASCII handle (`paul` → `Paul`).
207fn capitalize(s: &str) -> String {
208    let mut chars = s.chars();
209    match chars.next() {
210        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
211        None => String::new(),
212    }
213}
214
215// ─── RFC-001 §1: identity claims (operator / organization / project) ───────
216//
217// Optional, orthogonal claims layered onto the agent card. Cards without
218// any of these verify and route exactly as before — the additions are
219// strictly additive. v3.1 cards remain readable; v3.2 cards may carry
220// any subset of these fields.
221
222/// One entry in `org_memberships[]` (RFC-001 §1). `member_cert` is the
223/// org's signature over the operator's `op_did` UTF-8 bytes. A peer
224/// verifies the cert by looking up the org's pubkey (from a roster
225/// pull or a previously-pinned org) and calling
226/// `identity::verify_member_cert`.
227#[derive(Debug, Clone)]
228pub struct OrgMembership {
229    pub org_did: String,
230    /// Base64 Ed25519 public key of the org, carried inline so a receiver
231    /// verifies the vouch fully offline — `org_did` commits to this key
232    /// (`did:wire:org:<h>-<32hex sha256(org_pubkey)>`) and `member_cert` is
233    /// checked against it (RFC-001 Phase 1, `org_membership::evaluate_card_membership`).
234    pub org_pubkey: String,
235    /// Base64 Ed25519 signature by the org's key over `op_did` UTF-8 bytes.
236    pub member_cert: String,
237}
238
239/// Identity claims that may be layered onto an agent card. Each field
240/// is independently optional — a card may declare an operator anchor
241/// without an org membership, or an org membership without a project
242/// tag. The fields are orthogonal axes per RFC-001.
243#[derive(Debug, Clone, Default)]
244pub struct IdentityClaims {
245    /// Operator DID — `did:wire:op:<handle>-<32hex>`. Must satisfy
246    /// `is_op_did(...)`. The operator's root key separately signs
247    /// `op_cert` over the *session* DID this card belongs to, anchoring
248    /// the session under the operator.
249    pub op_did: Option<String>,
250    /// Base64 Ed25519 signature by the operator's key over this card's
251    /// session DID (UTF-8 bytes). Verifiable with `identity::verify_op_cert`.
252    /// Meaningful only when `op_did` is set.
253    pub op_cert: Option<String>,
254    /// Base64 Ed25519 operator root public key, carried inline so the operator
255    /// binding verifies offline — `op_did` commits to this key and `op_cert` is
256    /// checked against it. Set whenever `op_did` is set; without it the operator
257    /// claim is unverifiable and a receiver fails it closed (RFC-001 Phase 1).
258    pub op_pubkey: Option<String>,
259    /// Zero or more org membership entries. An operator may sit in
260    /// multiple orgs simultaneously; each entry stands on its own.
261    pub org_memberships: Vec<OrgMembership>,
262    /// Opaque routing tag — NEVER trust-bearing. RFC-001 §6.
263    pub project: Option<String>,
264}
265
266/// Layer identity claims onto an existing (unsigned) card. The returned
267/// card is unsigned; the caller signs it with `sign_agent_card` after
268/// all claims are attached. Fields with `None`/empty values are not
269/// added to the JSON, keeping the canonical bytes minimal for v3.1-only
270/// peers and making round-trip semantics deterministic.
271///
272/// Returns `Err(ClaimError::InvalidOpDid)` if `op_did` is set but does
273/// not parse as `did:wire:op:<handle>-<32hex>`; same shape for
274/// `InvalidOrgDid`. The check is structural — cryptographic verification
275/// of `op_cert` / `member_cert` happens in `identity::verify_*`, which
276/// needs the pubkeys those certs are signed by.
277pub fn with_identity_claims(
278    card: &AgentCard,
279    claims: &IdentityClaims,
280) -> Result<AgentCard, ClaimError> {
281    if let Some(op_did) = &claims.op_did
282        && !is_op_did(op_did)
283    {
284        return Err(ClaimError::InvalidOpDid(op_did.clone()));
285    }
286    for m in &claims.org_memberships {
287        if !is_org_did(&m.org_did) {
288            return Err(ClaimError::InvalidOrgDid(m.org_did.clone()));
289        }
290    }
291
292    let mut out = card.as_object().cloned().unwrap_or_default();
293
294    if let Some(op_did) = &claims.op_did {
295        out.insert("op_did".into(), Value::String(op_did.clone()));
296    }
297    if let Some(op_cert) = &claims.op_cert {
298        out.insert("op_cert".into(), Value::String(op_cert.clone()));
299    }
300    if let Some(op_pubkey) = &claims.op_pubkey {
301        out.insert("op_pubkey".into(), Value::String(op_pubkey.clone()));
302    }
303    if !claims.org_memberships.is_empty() {
304        let arr: Vec<Value> = claims
305            .org_memberships
306            .iter()
307            .map(|m| {
308                json!({
309                    "org_did": m.org_did,
310                    "org_pubkey": m.org_pubkey,
311                    "member_cert": m.member_cert,
312                })
313            })
314            .collect();
315        out.insert("org_memberships".into(), Value::Array(arr));
316    }
317    if let Some(project) = &claims.project {
318        out.insert("project".into(), Value::String(project.clone()));
319    }
320
321    // v0.14.x retro-fix: when ANY RFC-001 op claim lands on the card,
322    // bump `schema_version` to at least `CARD_SCHEMA_VERSION` (currently
323    // "v3.2"). Existing cards minted at v3.1 keep their version field
324    // until republish hits this path — at which point the version
325    // matches the inline-fields shape. Monotonic (never downgrades): a
326    // card already at >= v3.2 is unchanged. Readers that key off
327    // `schema_version >= "v3.2"` to discriminate "carries op claims"
328    // now have a truthful signal. (The bug it closes: v0.14 stored
329    // op_did but kept emitting `schema_version: "v3.1"` — readers
330    // couldn't tell from the version alone whether the card had
331    // op claims; they had to probe the inline fields directly.)
332    let has_any_op_claim = claims.op_did.is_some()
333        || claims.op_cert.is_some()
334        || claims.op_pubkey.is_some()
335        || !claims.org_memberships.is_empty();
336    if has_any_op_claim {
337        let current = out
338            .get("schema_version")
339            .and_then(Value::as_str)
340            .unwrap_or("v3.0");
341        let target = max_schema_version(current, CARD_SCHEMA_VERSION);
342        out.insert("schema_version".into(), Value::String(target.to_string()));
343    }
344
345    Ok(Value::Object(out))
346}
347
348/// Compare two `vX.Y` schema-version strings as `(major, minor)` integer
349/// tuples and return the higher. Defensive: unparseable inputs fall back
350/// to the OTHER argument (so a malformed stored card doesn't poison the
351/// republish). `v3.10` correctly compares as > `v3.2`.
352fn max_schema_version<'a>(a: &'a str, b: &'a str) -> &'a str {
353    fn parse(s: &str) -> Option<(u32, u32)> {
354        let rest = s.strip_prefix('v')?;
355        let (maj, min) = rest.split_once('.')?;
356        Some((maj.parse().ok()?, min.parse().ok()?))
357    }
358    match (parse(a), parse(b)) {
359        (Some(pa), Some(pb)) => {
360            if pa >= pb {
361                a
362            } else {
363                b
364            }
365        }
366        // Bias toward the parseable one; if neither parses, keep `a`.
367        (Some(_), None) => a,
368        (None, Some(_)) => b,
369        (None, None) => a,
370    }
371}
372
373#[derive(Debug, Error)]
374pub enum ClaimError {
375    #[error("op_did is not a well-formed did:wire:op:<handle>-<32hex>: {0}")]
376    InvalidOpDid(String),
377    #[error("org_did is not a well-formed did:wire:org:<handle>-<32hex>: {0}")]
378    InvalidOrgDid(String),
379}
380
381/// Read `op_did` from a card. Returns `None` if absent or malformed.
382pub fn card_op_did(card: &AgentCard) -> Option<&str> {
383    card.get("op_did").and_then(Value::as_str)
384}
385
386/// Read `op_cert` from a card. Returns `None` if absent.
387pub fn card_op_cert(card: &AgentCard) -> Option<&str> {
388    card.get("op_cert").and_then(Value::as_str)
389}
390
391/// Read `project` routing tag from a card.
392pub fn card_project(card: &AgentCard) -> Option<&str> {
393    card.get("project").and_then(Value::as_str)
394}
395
396/// Read `org_memberships[]` from a card as a list of `(org_did,
397/// member_cert)` borrowed pairs. Returns empty if absent or malformed.
398pub fn card_org_memberships(card: &AgentCard) -> Vec<(&str, &str)> {
399    card.get("org_memberships")
400        .and_then(Value::as_array)
401        .map(|arr| {
402            arr.iter()
403                .filter_map(|entry| {
404                    let org = entry.get("org_did").and_then(Value::as_str)?;
405                    let cert = entry.get("member_cert").and_then(Value::as_str)?;
406                    Some((org, cert))
407                })
408                .collect()
409        })
410        .unwrap_or_default()
411}
412
413/// Canonical bytes of an agent card — strips `signature` before serialization.
414pub fn card_canonical(card: &AgentCard) -> Vec<u8> {
415    canonical(card, false)
416}
417
418/// Sign an agent card with `private_key`. Returns the card with `signature`
419/// field appended (base64 of Ed25519 signature over `card_canonical(card)`).
420pub fn sign_agent_card(card: &AgentCard, private_key: &[u8]) -> AgentCard {
421    let mut sk_bytes = [0u8; 32];
422    sk_bytes.copy_from_slice(&private_key[..32]);
423    let sk = SigningKey::from_bytes(&sk_bytes);
424    let sig = sk.sign(&card_canonical(card));
425    let mut out = card.as_object().cloned().unwrap_or_default();
426    out.insert(
427        "signature".into(),
428        Value::String(b64encode(&sig.to_bytes())),
429    );
430    Value::Object(out)
431}
432
433/// Verify a signed card. Picks the first verify_key, validates the
434/// signature over `card_canonical(card)` (stripped of `signature`).
435pub fn verify_agent_card(card: &AgentCard) -> Result<(), CardError> {
436    let signature_b64 = card
437        .get("signature")
438        .and_then(Value::as_str)
439        .ok_or(CardError::MissingField("signature"))?;
440
441    let verify_keys = card
442        .get("verify_keys")
443        .and_then(Value::as_object)
444        .ok_or(CardError::MissingField("verify_keys"))?;
445
446    let (_kid, key_record) = verify_keys.iter().next().ok_or(CardError::NoVerifyKeys)?;
447    let pk_b64 = key_record
448        .get("key")
449        .and_then(Value::as_str)
450        .ok_or(CardError::MissingField("verify_keys[*].key"))?;
451    let pk_bytes = b64decode(pk_b64).map_err(|_| CardError::BadSignature)?;
452    if pk_bytes.len() != 32 {
453        return Err(CardError::BadSignature);
454    }
455    let mut pk_arr = [0u8; 32];
456    pk_arr.copy_from_slice(&pk_bytes);
457    let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| CardError::BadSignature)?;
458
459    let sig_bytes = b64decode(signature_b64).map_err(|_| CardError::BadSignature)?;
460    if sig_bytes.len() != 64 {
461        return Err(CardError::BadSignature);
462    }
463    let mut sig_arr = [0u8; 64];
464    sig_arr.copy_from_slice(&sig_bytes);
465    let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
466
467    vk.verify(&card_canonical(card), &sig)
468        .map_err(|_| CardError::SignatureRejected)
469}
470
471/// 6-digit bilateral SAS over two raw 32-byte public keys.
472///
473/// `sha256(min(a, b) || max(a, b))` then take the last 6 decimal digits.
474/// Symmetric in `(a, b)` so either operator computes the same digits from
475/// independent knowledge of both keys.
476pub fn compute_sas(public_key_a: &[u8], public_key_b: &[u8]) -> String {
477    let (lo, hi) = if public_key_a <= public_key_b {
478        (public_key_a, public_key_b)
479    } else {
480        (public_key_b, public_key_a)
481    };
482    let mut h = Sha256::new();
483    h.update(lo);
484    h.update(hi);
485    let digest = h.finalize();
486    // Take low 4 bytes -> u32, mod 1_000_000 for 6 digits.
487    let n = u32::from_be_bytes([digest[28], digest[29], digest[30], digest[31]]);
488    format!("{:06}", n % 1_000_000)
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::signing::generate_keypair;
495
496    #[test]
497    fn did_method_constant() {
498        assert_eq!(DID_METHOD, "did:wire");
499    }
500
501    #[test]
502    fn build_minimal_card() {
503        let (_, pk) = generate_keypair();
504        let card = build_agent_card("paul", &pk, None, None, None);
505        assert_eq!(card["schema_version"], CARD_SCHEMA_VERSION);
506        // v0.5.7+: DID is pubkey-suffixed for cross-operator uniqueness.
507        let did = card["did"].as_str().unwrap();
508        assert!(did.starts_with("did:wire:paul-"), "got: {did}");
509        assert_eq!(did.len(), "did:wire:paul-".len() + 8);
510        assert_eq!(card["handle"], "paul");
511        assert_eq!(card["name"], "Paul");
512        let vks = card["verify_keys"].as_object().unwrap();
513        assert_eq!(vks.len(), 1);
514        assert_eq!(card["policies"]["max_message_body_kb"], 64);
515    }
516
517    #[test]
518    fn build_card_with_overrides() {
519        let (_, pk) = generate_keypair();
520        let card = build_agent_card(
521            "carol",
522            &pk,
523            Some("Carol's Agent"),
524            Some(vec!["custom-cap".to_string()]),
525            Some(128),
526        );
527        assert_eq!(card["name"], "Carol's Agent");
528        assert_eq!(card["capabilities"], json!(["custom-cap"]));
529        assert_eq!(card["policies"]["max_message_body_kb"], 128);
530    }
531
532    #[test]
533    fn build_card_does_not_carry_v02_fields() {
534        let (_, pk) = generate_keypair();
535        let card = build_agent_card("paul", &pk, None, None, None);
536        let obj = card.as_object().unwrap();
537        for v02 in [
538            "registries",
539            "onboard_endpoint",
540            "wire_raw_url_template",
541            "revoked_at",
542        ] {
543            assert!(
544                !obj.contains_key(v02),
545                "v0.2+ field {v02} leaked into v0.1 card"
546            );
547        }
548    }
549
550    #[test]
551    fn card_canonical_excludes_signature() {
552        let v = json!({"schema_version": "v3.1", "did": "did:wire:paul", "signature": "sig"});
553        let bytes = card_canonical(&v);
554        assert!(!String::from_utf8_lossy(&bytes).contains("signature"));
555    }
556
557    #[test]
558    fn card_canonical_sort_keys_stable() {
559        let a = json!({"b": 1, "a": 2, "did": "did:wire:paul"});
560        let b = json!({"did": "did:wire:paul", "a": 2, "b": 1});
561        assert_eq!(card_canonical(&a), card_canonical(&b));
562    }
563
564    #[test]
565    fn sign_verify_roundtrip() {
566        let (sk, pk) = generate_keypair();
567        let card = build_agent_card("paul", &pk, None, None, None);
568        let signed = sign_agent_card(&card, &sk);
569        assert!(signed.get("signature").is_some());
570        verify_agent_card(&signed).unwrap();
571    }
572
573    #[test]
574    fn verify_rejects_unsigned_card() {
575        let (_, pk) = generate_keypair();
576        let card = build_agent_card("paul", &pk, None, None, None);
577        let err = verify_agent_card(&card).unwrap_err();
578        assert!(matches!(err, CardError::MissingField("signature")));
579    }
580
581    #[test]
582    fn verify_rejects_tampered_card() {
583        let (sk, pk) = generate_keypair();
584        let mut signed = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
585        signed["name"] = json!("TamperedName");
586        let err = verify_agent_card(&signed).unwrap_err();
587        assert!(matches!(err, CardError::SignatureRejected));
588    }
589
590    #[test]
591    fn verify_rejects_card_with_no_verify_keys() {
592        let (sk, _) = generate_keypair();
593        let card = json!({"schema_version": "v3.1", "did": "did:wire:paul", "verify_keys": {}});
594        let signed = sign_agent_card(&card, &sk);
595        let err = verify_agent_card(&signed).unwrap_err();
596        assert!(matches!(err, CardError::NoVerifyKeys));
597    }
598
599    #[test]
600    fn compute_sas_is_6_digits() {
601        let (_, a) = generate_keypair();
602        let (_, b) = generate_keypair();
603        let sas = compute_sas(&a, &b);
604        assert_eq!(sas.len(), 6);
605        assert!(sas.chars().all(|c| c.is_ascii_digit()));
606    }
607
608    #[test]
609    fn compute_sas_bilateral_symmetric() {
610        let (_, a) = generate_keypair();
611        let (_, b) = generate_keypair();
612        assert_eq!(compute_sas(&a, &b), compute_sas(&b, &a));
613    }
614
615    #[test]
616    fn compute_sas_changes_with_inputs() {
617        let (_, a) = generate_keypair();
618        let (_, b) = generate_keypair();
619        let (_, c) = generate_keypair();
620        assert_ne!(compute_sas(&a, &b), compute_sas(&a, &c));
621    }
622
623    // ─── RFC-001 §1: identity claims ───────────────────────────────────────
624
625    fn op_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
626        let (sk, pk) = generate_keypair();
627        (did_for_op(handle, &pk), sk.to_vec(), pk.to_vec())
628    }
629
630    fn org_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
631        let (sk, pk) = generate_keypair();
632        (did_for_org(handle, &pk), sk.to_vec(), pk.to_vec())
633    }
634
635    #[test]
636    fn schema_version_is_v3_2() {
637        assert_eq!(CARD_SCHEMA_VERSION, "v3.2");
638    }
639
640    #[test]
641    fn op_did_has_long_hex_suffix_and_method_prefix() {
642        let (did, _, _) = op_did_for_test("darby");
643        assert!(did.starts_with("did:wire:op:darby-"), "got: {did}");
644        let tail = did.rsplit('-').next().unwrap();
645        assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
646        assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
647    }
648
649    #[test]
650    fn org_did_has_long_hex_suffix_and_method_prefix() {
651        let (did, _, _) = org_did_for_test("slanchaai");
652        assert!(did.starts_with("did:wire:org:slanchaai-"), "got: {did}");
653        let tail = did.rsplit('-').next().unwrap();
654        assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
655        assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
656    }
657
658    #[test]
659    fn op_did_passthrough_when_already_op_did() {
660        // Passing a fully-formed op_did back through `did_for_op` is a no-op;
661        // protects callers that mix raw handles + already-built DIDs.
662        let (_, pk) = generate_keypair();
663        let did = did_for_op("darby", &pk);
664        let again = did_for_op(&did, &pk);
665        assert_eq!(did, again);
666    }
667
668    #[test]
669    fn is_op_did_rejects_session_did() {
670        // The classification check exists precisely to refuse this confusion.
671        let (_, pk) = generate_keypair();
672        let session_did = did_for_with_key("darby", &pk);
673        assert!(!is_op_did(&session_did));
674        assert!(!is_org_did(&session_did));
675    }
676
677    #[test]
678    fn is_op_did_rejects_org_did_and_vice_versa() {
679        // Disjoint namespaces — an org_did is not an op_did even though both
680        // share the long-hex suffix shape.
681        let (op, _, _) = op_did_for_test("darby");
682        let (org, _, _) = org_did_for_test("slanchaai");
683        assert!(is_op_did(&op) && !is_org_did(&op));
684        assert!(is_org_did(&org) && !is_op_did(&org));
685    }
686
687    #[test]
688    fn is_op_did_rejects_short_hex_suffix() {
689        // An 8-hex tail (session-DID shape) under the op prefix would be a
690        // namespace squat. Refuse on syntax alone.
691        assert!(!is_op_did("did:wire:op:darby-deadbeef"));
692        assert!(!is_org_did("did:wire:org:slanchaai-deadbeef"));
693    }
694
695    #[test]
696    fn is_op_did_rejects_non_hex_suffix() {
697        let bad = format!("did:wire:op:darby-{}", "z".repeat(LONG_FINGERPRINT_HEX_LEN));
698        assert!(!is_op_did(&bad));
699    }
700
701    #[test]
702    fn with_identity_claims_attaches_all_fields() {
703        let (sk, pk) = generate_keypair();
704        let card = build_agent_card("vesper-valley", &pk, None, None, None);
705        let (op_did, _, op_pk) = op_did_for_test("darby");
706        let (org_did, _, org_pk) = org_did_for_test("slanchaai");
707        let op_pubkey = crate::signing::b64encode(&op_pk);
708        let org_pubkey = crate::signing::b64encode(&org_pk);
709        let claims = IdentityClaims {
710            op_did: Some(op_did.clone()),
711            op_cert: Some("AAAA".into()),
712            op_pubkey: Some(op_pubkey.clone()),
713            org_memberships: vec![OrgMembership {
714                org_did: org_did.clone(),
715                org_pubkey: org_pubkey.clone(),
716                member_cert: "BBBB".into(),
717            }],
718            project: Some("wire-codex-integration".into()),
719        };
720        let with = with_identity_claims(&card, &claims).unwrap();
721        assert_eq!(card_op_did(&with), Some(op_did.as_str()));
722        assert_eq!(card_op_cert(&with), Some("AAAA"));
723        assert_eq!(
724            with.get("op_pubkey").and_then(|v| v.as_str()),
725            Some(op_pubkey.as_str())
726        );
727        assert_eq!(card_project(&with), Some("wire-codex-integration"));
728        let orgs = card_org_memberships(&with);
729        assert_eq!(orgs.len(), 1);
730        assert_eq!(orgs[0], (org_did.as_str(), "BBBB"));
731        assert_eq!(
732            with.get("org_memberships").unwrap()[0]
733                .get("org_pubkey")
734                .and_then(|v| v.as_str()),
735            Some(org_pubkey.as_str())
736        );
737        // Card still signs + verifies after identity claims are layered.
738        let signed = sign_agent_card(&with, &sk);
739        verify_agent_card(&signed).unwrap();
740    }
741
742    #[test]
743    fn with_identity_claims_skips_absent_fields() {
744        // A card with no claims must not gain empty `op_did`/`project`/etc.
745        // entries — keeps canonical bytes minimal and v3.1-peer-friendly.
746        let (_, pk) = generate_keypair();
747        let card = build_agent_card("vesper-valley", &pk, None, None, None);
748        let with = with_identity_claims(&card, &IdentityClaims::default()).unwrap();
749        let obj = with.as_object().unwrap();
750        for field in ["op_did", "op_cert", "org_memberships", "project"] {
751            assert!(
752                !obj.contains_key(field),
753                "{field} leaked into claim-less card"
754            );
755        }
756    }
757
758    #[test]
759    fn with_identity_claims_rejects_malformed_op_did() {
760        let (_, pk) = generate_keypair();
761        let card = build_agent_card("vesper-valley", &pk, None, None, None);
762        let claims = IdentityClaims {
763            // Session-DID shape under op prefix → namespace confusion.
764            op_did: Some("did:wire:op:darby-deadbeef".into()),
765            ..Default::default()
766        };
767        let err = with_identity_claims(&card, &claims).unwrap_err();
768        assert!(matches!(err, ClaimError::InvalidOpDid(_)));
769    }
770
771    #[test]
772    fn with_identity_claims_rejects_malformed_org_did() {
773        let (_, pk) = generate_keypair();
774        let card = build_agent_card("vesper-valley", &pk, None, None, None);
775        let claims = IdentityClaims {
776            org_memberships: vec![OrgMembership {
777                org_did: "did:wire:slanchaai".into(),
778                org_pubkey: "AAAA".into(),
779                member_cert: "BBBB".into(),
780            }],
781            ..Default::default()
782        };
783        let err = with_identity_claims(&card, &claims).unwrap_err();
784        assert!(matches!(err, ClaimError::InvalidOrgDid(_)));
785    }
786
787    #[test]
788    fn build_agent_card_default_capability_advertises_v3_2() {
789        let (_, pk) = generate_keypair();
790        let card = build_agent_card("paul", &pk, None, None, None);
791        let caps = card["capabilities"].as_array().unwrap();
792        let has_v32 = caps.iter().any(|v| v.as_str() == Some("wire/v3.2"));
793        assert!(has_v32, "default caps should advertise wire/v3.2: {caps:?}");
794    }
795
796    // v0.14.x retro-fix tests: when op claims are attached, the card's
797    // `schema_version` field bumps to at least `CARD_SCHEMA_VERSION`. The
798    // bump is monotonic (never downgrades), conditional (claim-less
799    // attach leaves the field alone), and version-numeric (v3.10 > v3.2,
800    // not lexicographic).
801
802    #[test]
803    fn with_identity_claims_bumps_schema_version_when_op_did_attached() {
804        // A card that was minted at v3.1 (the pre-v0.14 emit version)
805        // must surface as >= v3.2 once op claims are attached — readers
806        // discriminate "card carries op_*" off the version field.
807        let (_, pk) = generate_keypair();
808        let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
809        // Roll back to v3.1 to simulate a pre-v0.14 stored card.
810        card.as_object_mut()
811            .unwrap()
812            .insert("schema_version".into(), json!("v3.1"));
813        let (op_did, _, op_pk) = op_did_for_test("darby");
814        let claims = IdentityClaims {
815            op_did: Some(op_did),
816            op_pubkey: Some(crate::signing::b64encode(&op_pk)),
817            op_cert: Some("AAAA".into()),
818            ..Default::default()
819        };
820        let with = with_identity_claims(&card, &claims).unwrap();
821        assert_eq!(
822            with.get("schema_version").and_then(|v| v.as_str()),
823            Some(CARD_SCHEMA_VERSION),
824            "post-attach schema_version must bump to {CARD_SCHEMA_VERSION}",
825        );
826    }
827
828    #[test]
829    fn with_identity_claims_does_not_touch_schema_version_when_no_claims() {
830        // Claim-less attach (e.g. an unenrolled operator's republish)
831        // leaves the version field exactly as it was — no spurious bump
832        // for a v3.1 peer that has zero op_* fields to surface.
833        let (_, pk) = generate_keypair();
834        let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
835        card.as_object_mut()
836            .unwrap()
837            .insert("schema_version".into(), json!("v3.1"));
838        let with = with_identity_claims(&card, &IdentityClaims::default()).unwrap();
839        assert_eq!(
840            with.get("schema_version").and_then(|v| v.as_str()),
841            Some("v3.1"),
842            "claim-less attach must NOT bump",
843        );
844    }
845
846    #[test]
847    fn with_identity_claims_never_downgrades_schema_version() {
848        // A hypothetical v3.5 card (future extension peer) attaching op
849        // claims via an older `CARD_SCHEMA_VERSION` build must NOT lose
850        // its higher version. Monotonic invariant.
851        let (_, pk) = generate_keypair();
852        let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
853        card.as_object_mut()
854            .unwrap()
855            .insert("schema_version".into(), json!("v3.5"));
856        let (op_did, _, op_pk) = op_did_for_test("darby");
857        let claims = IdentityClaims {
858            op_did: Some(op_did),
859            op_pubkey: Some(crate::signing::b64encode(&op_pk)),
860            op_cert: Some("AAAA".into()),
861            ..Default::default()
862        };
863        let with = with_identity_claims(&card, &claims).unwrap();
864        assert_eq!(
865            with.get("schema_version").and_then(|v| v.as_str()),
866            Some("v3.5"),
867            "monotonic bump must not downgrade v3.5 to {CARD_SCHEMA_VERSION}",
868        );
869    }
870
871    #[test]
872    fn max_schema_version_compares_numerically_not_lexicographically() {
873        // Lexicographic compare would call "v3.10" < "v3.2" because '1' <
874        // '2'. The helper parses to (major, minor) ints so v3.10 > v3.2.
875        assert_eq!(max_schema_version("v3.10", "v3.2"), "v3.10");
876        assert_eq!(max_schema_version("v3.2", "v3.10"), "v3.10");
877        assert_eq!(max_schema_version("v3.2", "v3.2"), "v3.2");
878        assert_eq!(max_schema_version("v4.0", "v3.99"), "v4.0");
879    }
880
881    #[test]
882    fn max_schema_version_biases_to_parseable_on_malformed_input() {
883        // A malformed stored card must not poison the republish: parseable
884        // wins, both-malformed keeps `a` (deterministic, no panic).
885        assert_eq!(max_schema_version("garbage", "v3.2"), "v3.2");
886        assert_eq!(max_schema_version("v3.2", "garbage"), "v3.2");
887        assert_eq!(max_schema_version("garbage1", "garbage2"), "garbage1");
888    }
889}