1use 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
25pub const DID_METHOD_OP: &str = "did:wire:op";
29
30pub const DID_METHOD_ORG: &str = "did:wire:org";
32
33pub const LONG_FINGERPRINT_HEX_LEN: usize = 32;
38
39pub 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
55pub 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
69pub 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
80pub fn long_fingerprint(public_key: &[u8]) -> String {
84 let digest = Sha256::digest(public_key);
85 hex::encode(&digest[..16])
86}
87
88pub 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
98pub 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
114pub fn did_commits_to_key(did: &str, public_key: &[u8]) -> bool {
127 let Some(rest) = did.strip_prefix(&format!("{DID_METHOD}:")) else {
130 return false;
131 };
132 if rest.starts_with("op:") || rest.starts_with("org:") {
133 return false;
134 }
135 let Some(idx) = rest.rfind('-') else {
136 return false;
137 };
138 let suffix = &rest[idx + 1..];
139 suffix == crate::signing::fingerprint(public_key)
140}
141
142pub fn bare_handle(handle: &str) -> &str {
154 handle.split_once('@').map(|(n, _)| n).unwrap_or(handle)
155}
156
157pub fn display_handle_from_did(did: &str) -> &str {
161 let stripped = did.strip_prefix("did:wire:").unwrap_or(did);
162 if let Some(idx) = stripped.rfind('-') {
165 let suffix = &stripped[idx + 1..];
166 if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
167 return &stripped[..idx];
168 }
169 }
170 stripped
171}
172
173pub type AgentCard = Value;
176
177#[derive(Debug, Error)]
178pub enum CardError {
179 #[error("missing field: {0}")]
180 MissingField(&'static str),
181 #[error("verify_keys is empty or malformed")]
182 NoVerifyKeys,
183 #[error("signature decode failed")]
184 BadSignature,
185 #[error("signature did not verify")]
186 SignatureRejected,
187 #[error("card DID does not commit to its verify key (suffix mismatch)")]
188 DidKeyMismatch,
189}
190
191pub fn build_agent_card(
202 handle: &str,
203 public_key: &[u8],
204 name: Option<&str>,
205 capabilities: Option<Vec<String>>,
206 max_body_kb: Option<u64>,
207) -> AgentCard {
208 let display_name = name
209 .map(str::to_string)
210 .unwrap_or_else(|| capitalize(handle));
211 let caps = capabilities.unwrap_or_else(|| vec!["wire/v3.2".to_string()]);
212 let body_kb = max_body_kb.unwrap_or(64);
213
214 let key_id = make_key_id(handle, public_key);
215 let key_id_full = format!("ed25519:{key_id}");
216
217 json!({
218 "schema_version": CARD_SCHEMA_VERSION,
219 "did": did_for_with_key(handle, public_key),
220 "handle": handle,
221 "name": display_name,
222 "capabilities": caps,
223 "verify_keys": {
224 key_id_full: {
225 "key": b64encode(public_key),
226 "alg": "ed25519",
227 "active": true,
228 }
229 },
230 "policies": {
231 "max_message_body_kb": body_kb,
232 }
233 })
234}
235
236fn capitalize(s: &str) -> String {
238 let mut chars = s.chars();
239 match chars.next() {
240 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
241 None => String::new(),
242 }
243}
244
245#[derive(Debug, Clone)]
258pub struct OrgMembership {
259 pub org_did: String,
260 pub org_pubkey: String,
265 pub member_cert: String,
267}
268
269#[derive(Debug, Clone, Default)]
274pub struct IdentityClaims {
275 pub op_did: Option<String>,
280 pub op_cert: Option<String>,
284 pub op_pubkey: Option<String>,
289 pub org_memberships: Vec<OrgMembership>,
292 pub project: Option<String>,
294 pub same_machine_attestation: Option<(String, String)>,
300}
301
302pub fn with_identity_claims(
314 card: &AgentCard,
315 claims: &IdentityClaims,
316) -> Result<AgentCard, ClaimError> {
317 if let Some(op_did) = &claims.op_did
318 && !is_op_did(op_did)
319 {
320 return Err(ClaimError::InvalidOpDid(op_did.clone()));
321 }
322 for m in &claims.org_memberships {
323 if !is_org_did(&m.org_did) {
324 return Err(ClaimError::InvalidOrgDid(m.org_did.clone()));
325 }
326 }
327
328 let mut out = card.as_object().cloned().unwrap_or_default();
329
330 if let Some(op_did) = &claims.op_did {
331 out.insert("op_did".into(), Value::String(op_did.clone()));
332 }
333 if let Some(op_cert) = &claims.op_cert {
334 out.insert("op_cert".into(), Value::String(op_cert.clone()));
335 }
336 if let Some(op_pubkey) = &claims.op_pubkey {
337 out.insert("op_pubkey".into(), Value::String(op_pubkey.clone()));
338 }
339 if !claims.org_memberships.is_empty() {
340 let arr: Vec<Value> = claims
341 .org_memberships
342 .iter()
343 .map(|m| {
344 json!({
345 "org_did": m.org_did,
346 "org_pubkey": m.org_pubkey,
347 "member_cert": m.member_cert,
348 })
349 })
350 .collect();
351 out.insert("org_memberships".into(), Value::Array(arr));
352 }
353 if let Some(project) = &claims.project {
354 out.insert("project".into(), Value::String(project.clone()));
355 }
356 if let Some((fingerprint, signature)) = &claims.same_machine_attestation {
357 out.insert(
358 "same_machine_attestation".into(),
359 json!({
360 "machine_fingerprint": fingerprint,
361 "signature": signature,
362 }),
363 );
364 }
365
366 let has_any_op_claim = claims.op_did.is_some()
378 || claims.op_cert.is_some()
379 || claims.op_pubkey.is_some()
380 || !claims.org_memberships.is_empty();
381 if has_any_op_claim {
382 let current = out
383 .get("schema_version")
384 .and_then(Value::as_str)
385 .unwrap_or("v3.0");
386 let target = max_schema_version(current, CARD_SCHEMA_VERSION);
387 out.insert("schema_version".into(), Value::String(target.to_string()));
388 }
389
390 Ok(Value::Object(out))
391}
392
393fn max_schema_version<'a>(a: &'a str, b: &'a str) -> &'a str {
398 fn parse(s: &str) -> Option<(u32, u32)> {
399 let rest = s.strip_prefix('v')?;
400 let (maj, min) = rest.split_once('.')?;
401 Some((maj.parse().ok()?, min.parse().ok()?))
402 }
403 match (parse(a), parse(b)) {
404 (Some(pa), Some(pb)) => {
405 if pa >= pb {
406 a
407 } else {
408 b
409 }
410 }
411 (Some(_), None) => a,
413 (None, Some(_)) => b,
414 (None, None) => a,
415 }
416}
417
418#[derive(Debug, Error)]
419pub enum ClaimError {
420 #[error("op_did is not a well-formed did:wire:op:<handle>-<32hex>: {0}")]
421 InvalidOpDid(String),
422 #[error("org_did is not a well-formed did:wire:org:<handle>-<32hex>: {0}")]
423 InvalidOrgDid(String),
424}
425
426pub fn card_op_did(card: &AgentCard) -> Option<&str> {
428 card.get("op_did").and_then(Value::as_str)
429}
430
431pub fn card_op_cert(card: &AgentCard) -> Option<&str> {
433 card.get("op_cert").and_then(Value::as_str)
434}
435
436pub fn card_project(card: &AgentCard) -> Option<&str> {
438 card.get("project").and_then(Value::as_str)
439}
440
441pub fn card_org_memberships(card: &AgentCard) -> Vec<(&str, &str)> {
444 card.get("org_memberships")
445 .and_then(Value::as_array)
446 .map(|arr| {
447 arr.iter()
448 .filter_map(|entry| {
449 let org = entry.get("org_did").and_then(Value::as_str)?;
450 let cert = entry.get("member_cert").and_then(Value::as_str)?;
451 Some((org, cert))
452 })
453 .collect()
454 })
455 .unwrap_or_default()
456}
457
458pub fn card_canonical(card: &AgentCard) -> Vec<u8> {
460 canonical(card, false)
461}
462
463pub fn sign_agent_card(card: &AgentCard, private_key: &[u8]) -> AgentCard {
466 let mut sk_bytes = [0u8; 32];
467 sk_bytes.copy_from_slice(&private_key[..32]);
468 let sk = SigningKey::from_bytes(&sk_bytes);
469 let mut card_obj = card.as_object().cloned().unwrap_or_default();
474 card_obj.insert(
475 "dh_pubkey".into(),
476 Value::String(crate::enc::wire_x25519::self_dh_pubkey_b64(&sk_bytes)),
477 );
478 let card_with_dh = Value::Object(card_obj);
479 let sig = sk.sign(&card_canonical(&card_with_dh));
480 let mut out = card_with_dh.as_object().cloned().unwrap_or_default();
481 out.insert(
482 "signature".into(),
483 Value::String(b64encode(&sig.to_bytes())),
484 );
485 Value::Object(out)
486}
487
488pub fn card_dh_pubkey(card: &AgentCard) -> Option<&str> {
490 card.get("dh_pubkey").and_then(Value::as_str)
491}
492
493pub fn verify_agent_card(card: &AgentCard) -> Result<(), CardError> {
496 let signature_b64 = card
497 .get("signature")
498 .and_then(Value::as_str)
499 .ok_or(CardError::MissingField("signature"))?;
500
501 let verify_keys = card
502 .get("verify_keys")
503 .and_then(Value::as_object)
504 .ok_or(CardError::MissingField("verify_keys"))?;
505
506 let (_kid, key_record) = verify_keys.iter().next().ok_or(CardError::NoVerifyKeys)?;
507 let pk_b64 = key_record
508 .get("key")
509 .and_then(Value::as_str)
510 .ok_or(CardError::MissingField("verify_keys[*].key"))?;
511 let pk_bytes = b64decode(pk_b64).map_err(|_| CardError::BadSignature)?;
512 if pk_bytes.len() != 32 {
513 return Err(CardError::BadSignature);
514 }
515 let mut pk_arr = [0u8; 32];
516 pk_arr.copy_from_slice(&pk_bytes);
517 let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| CardError::BadSignature)?;
518
519 let sig_bytes = b64decode(signature_b64).map_err(|_| CardError::BadSignature)?;
520 if sig_bytes.len() != 64 {
521 return Err(CardError::BadSignature);
522 }
523 let mut sig_arr = [0u8; 64];
524 sig_arr.copy_from_slice(&sig_bytes);
525 let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
526
527 vk.verify(&card_canonical(card), &sig)
528 .map_err(|_| CardError::SignatureRejected)?;
529
530 let did = card
538 .get("did")
539 .and_then(Value::as_str)
540 .ok_or(CardError::MissingField("did"))?;
541 if !did_commits_to_key(did, &pk_arr) {
542 return Err(CardError::DidKeyMismatch);
543 }
544 Ok(())
545}
546
547pub fn compute_sas(public_key_a: &[u8], public_key_b: &[u8]) -> String {
553 let (lo, hi) = if public_key_a <= public_key_b {
554 (public_key_a, public_key_b)
555 } else {
556 (public_key_b, public_key_a)
557 };
558 let mut h = Sha256::new();
559 h.update(lo);
560 h.update(hi);
561 let digest = h.finalize();
562 let n = u32::from_be_bytes([digest[28], digest[29], digest[30], digest[31]]);
564 format!("{:06}", n % 1_000_000)
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570 use crate::signing::generate_keypair;
571
572 #[test]
573 fn did_method_constant() {
574 assert_eq!(DID_METHOD, "did:wire");
575 }
576
577 #[test]
578 fn build_minimal_card() {
579 let (_, pk) = generate_keypair();
580 let card = build_agent_card("paul", &pk, None, None, None);
581 assert_eq!(card["schema_version"], CARD_SCHEMA_VERSION);
582 let did = card["did"].as_str().unwrap();
584 assert!(did.starts_with("did:wire:paul-"), "got: {did}");
585 assert_eq!(did.len(), "did:wire:paul-".len() + 8);
586 assert_eq!(card["handle"], "paul");
587 assert_eq!(card["name"], "Paul");
588 let vks = card["verify_keys"].as_object().unwrap();
589 assert_eq!(vks.len(), 1);
590 assert_eq!(card["policies"]["max_message_body_kb"], 64);
591 }
592
593 #[test]
594 fn build_card_with_overrides() {
595 let (_, pk) = generate_keypair();
596 let card = build_agent_card(
597 "carol",
598 &pk,
599 Some("Carol's Agent"),
600 Some(vec!["custom-cap".to_string()]),
601 Some(128),
602 );
603 assert_eq!(card["name"], "Carol's Agent");
604 assert_eq!(card["capabilities"], json!(["custom-cap"]));
605 assert_eq!(card["policies"]["max_message_body_kb"], 128);
606 }
607
608 #[test]
609 fn build_card_does_not_carry_v02_fields() {
610 let (_, pk) = generate_keypair();
611 let card = build_agent_card("paul", &pk, None, None, None);
612 let obj = card.as_object().unwrap();
613 for v02 in [
614 "registries",
615 "onboard_endpoint",
616 "wire_raw_url_template",
617 "revoked_at",
618 ] {
619 assert!(
620 !obj.contains_key(v02),
621 "v0.2+ field {v02} leaked into v0.1 card"
622 );
623 }
624 }
625
626 #[test]
627 fn card_canonical_excludes_signature() {
628 let v = json!({"schema_version": "v3.1", "did": "did:wire:paul", "signature": "sig"});
629 let bytes = card_canonical(&v);
630 assert!(!String::from_utf8_lossy(&bytes).contains("signature"));
631 }
632
633 #[test]
634 fn card_canonical_sort_keys_stable() {
635 let a = json!({"b": 1, "a": 2, "did": "did:wire:paul"});
636 let b = json!({"did": "did:wire:paul", "a": 2, "b": 1});
637 assert_eq!(card_canonical(&a), card_canonical(&b));
638 }
639
640 #[test]
641 fn sign_verify_roundtrip() {
642 let (sk, pk) = generate_keypair();
643 let card = build_agent_card("paul", &pk, None, None, None);
644 let signed = sign_agent_card(&card, &sk);
645 assert!(signed.get("signature").is_some());
646 verify_agent_card(&signed).unwrap();
647 }
648
649 #[test]
650 fn verify_rejects_unsigned_card() {
651 let (_, pk) = generate_keypair();
652 let card = build_agent_card("paul", &pk, None, None, None);
653 let err = verify_agent_card(&card).unwrap_err();
654 assert!(matches!(err, CardError::MissingField("signature")));
655 }
656
657 #[test]
658 fn verify_rejects_tampered_card() {
659 let (sk, pk) = generate_keypair();
660 let mut signed = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
661 signed["name"] = json!("TamperedName");
662 let err = verify_agent_card(&signed).unwrap_err();
663 assert!(matches!(err, CardError::SignatureRejected));
664 }
665
666 #[test]
667 fn verify_rejects_card_with_no_verify_keys() {
668 let (sk, _) = generate_keypair();
669 let card = json!({"schema_version": "v3.1", "did": "did:wire:paul", "verify_keys": {}});
670 let signed = sign_agent_card(&card, &sk);
671 let err = verify_agent_card(&signed).unwrap_err();
672 assert!(matches!(err, CardError::NoVerifyKeys));
673 }
674
675 #[test]
676 fn verify_rejects_did_claiming_foreign_key() {
677 let (victim_sk, victim_pk) = generate_keypair();
682 let (attacker_sk, attacker_pk) = generate_keypair();
683 assert_ne!(victim_pk, attacker_pk);
684 let victim_did = did_for_with_key("paul", &victim_pk);
685 let mut card = build_agent_card("paul", &attacker_pk, None, None, None);
689 card["did"] = json!(victim_did);
690 let signed = sign_agent_card(&card, &attacker_sk);
691 let err = verify_agent_card(&signed).unwrap_err();
693 assert!(
694 matches!(err, CardError::DidKeyMismatch),
695 "expected DidKeyMismatch, got {err:?}"
696 );
697 let real = sign_agent_card(
699 &build_agent_card("paul", &victim_pk, None, None, None),
700 &victim_sk,
701 );
702 verify_agent_card(&real).unwrap();
703 }
704
705 #[test]
706 fn did_commits_to_key_basic() {
707 let (_, pk) = generate_keypair();
708 let (_, other) = generate_keypair();
709 let did = did_for_with_key("alice", &pk);
710 assert!(did_commits_to_key(&did, &pk));
711 assert!(!did_commits_to_key(&did, &other));
712 let hdid = did_for_with_key("alice-bob", &pk);
714 assert!(did_commits_to_key(&hdid, &pk));
715 assert!(!did_commits_to_key(&did_for_op("acme", &pk), &pk));
717 assert!(!did_commits_to_key(&did_for_org("acme", &pk), &pk));
718 assert!(!did_commits_to_key("did:wire:alice", &pk));
720 }
721
722 #[test]
723 fn compute_sas_is_6_digits() {
724 let (_, a) = generate_keypair();
725 let (_, b) = generate_keypair();
726 let sas = compute_sas(&a, &b);
727 assert_eq!(sas.len(), 6);
728 assert!(sas.chars().all(|c| c.is_ascii_digit()));
729 }
730
731 #[test]
732 fn compute_sas_bilateral_symmetric() {
733 let (_, a) = generate_keypair();
734 let (_, b) = generate_keypair();
735 assert_eq!(compute_sas(&a, &b), compute_sas(&b, &a));
736 }
737
738 #[test]
739 fn compute_sas_changes_with_inputs() {
740 let (_, a) = generate_keypair();
741 let (_, b) = generate_keypair();
742 let (_, c) = generate_keypair();
743 assert_ne!(compute_sas(&a, &b), compute_sas(&a, &c));
744 }
745
746 fn op_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
749 let (sk, pk) = generate_keypair();
750 (did_for_op(handle, &pk), sk.to_vec(), pk.to_vec())
751 }
752
753 fn org_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
754 let (sk, pk) = generate_keypair();
755 (did_for_org(handle, &pk), sk.to_vec(), pk.to_vec())
756 }
757
758 #[test]
759 fn schema_version_is_v3_2() {
760 assert_eq!(CARD_SCHEMA_VERSION, "v3.2");
761 }
762
763 #[test]
764 fn op_did_has_long_hex_suffix_and_method_prefix() {
765 let (did, _, _) = op_did_for_test("darby");
766 assert!(did.starts_with("did:wire:op:darby-"), "got: {did}");
767 let tail = did.rsplit('-').next().unwrap();
768 assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
769 assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
770 }
771
772 #[test]
773 fn org_did_has_long_hex_suffix_and_method_prefix() {
774 let (did, _, _) = org_did_for_test("slanchaai");
775 assert!(did.starts_with("did:wire:org:slanchaai-"), "got: {did}");
776 let tail = did.rsplit('-').next().unwrap();
777 assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
778 assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
779 }
780
781 #[test]
782 fn op_did_passthrough_when_already_op_did() {
783 let (_, pk) = generate_keypair();
786 let did = did_for_op("darby", &pk);
787 let again = did_for_op(&did, &pk);
788 assert_eq!(did, again);
789 }
790
791 #[test]
792 fn is_op_did_rejects_session_did() {
793 let (_, pk) = generate_keypair();
795 let session_did = did_for_with_key("darby", &pk);
796 assert!(!is_op_did(&session_did));
797 assert!(!is_org_did(&session_did));
798 }
799
800 #[test]
801 fn is_op_did_rejects_org_did_and_vice_versa() {
802 let (op, _, _) = op_did_for_test("darby");
805 let (org, _, _) = org_did_for_test("slanchaai");
806 assert!(is_op_did(&op) && !is_org_did(&op));
807 assert!(is_org_did(&org) && !is_op_did(&org));
808 }
809
810 #[test]
811 fn is_op_did_rejects_short_hex_suffix() {
812 assert!(!is_op_did("did:wire:op:darby-deadbeef"));
815 assert!(!is_org_did("did:wire:org:slanchaai-deadbeef"));
816 }
817
818 #[test]
819 fn is_op_did_rejects_non_hex_suffix() {
820 let bad = format!("did:wire:op:darby-{}", "z".repeat(LONG_FINGERPRINT_HEX_LEN));
821 assert!(!is_op_did(&bad));
822 }
823
824 #[test]
825 fn with_identity_claims_attaches_all_fields() {
826 let (sk, pk) = generate_keypair();
827 let card = build_agent_card("vesper-valley", &pk, None, None, None);
828 let (op_did, _, op_pk) = op_did_for_test("darby");
829 let (org_did, _, org_pk) = org_did_for_test("slanchaai");
830 let op_pubkey = crate::signing::b64encode(&op_pk);
831 let org_pubkey = crate::signing::b64encode(&org_pk);
832 let claims = IdentityClaims {
833 op_did: Some(op_did.clone()),
834 op_cert: Some("AAAA".into()),
835 op_pubkey: Some(op_pubkey.clone()),
836 org_memberships: vec![OrgMembership {
837 org_did: org_did.clone(),
838 org_pubkey: org_pubkey.clone(),
839 member_cert: "BBBB".into(),
840 }],
841 project: Some("wire-codex-integration".into()),
842 same_machine_attestation: Some(("ZmluZ2VycHJpbnQ=".into(), "c2ln".into())),
843 };
844 let with = with_identity_claims(&card, &claims).unwrap();
845 assert_eq!(card_op_did(&with), Some(op_did.as_str()));
846 assert_eq!(card_op_cert(&with), Some("AAAA"));
847 assert_eq!(
849 with.pointer("/same_machine_attestation/machine_fingerprint")
850 .and_then(|v| v.as_str()),
851 Some("ZmluZ2VycHJpbnQ=")
852 );
853 assert_eq!(
854 with.pointer("/same_machine_attestation/signature")
855 .and_then(|v| v.as_str()),
856 Some("c2ln")
857 );
858 assert_eq!(
859 with.get("op_pubkey").and_then(|v| v.as_str()),
860 Some(op_pubkey.as_str())
861 );
862 assert_eq!(card_project(&with), Some("wire-codex-integration"));
863 let orgs = card_org_memberships(&with);
864 assert_eq!(orgs.len(), 1);
865 assert_eq!(orgs[0], (org_did.as_str(), "BBBB"));
866 assert_eq!(
867 with.get("org_memberships").unwrap()[0]
868 .get("org_pubkey")
869 .and_then(|v| v.as_str()),
870 Some(org_pubkey.as_str())
871 );
872 let signed = sign_agent_card(&with, &sk);
874 verify_agent_card(&signed).unwrap();
875 }
876
877 #[test]
878 fn with_identity_claims_skips_absent_fields() {
879 let (_, pk) = generate_keypair();
882 let card = build_agent_card("vesper-valley", &pk, None, None, None);
883 let with = with_identity_claims(&card, &IdentityClaims::default()).unwrap();
884 let obj = with.as_object().unwrap();
885 for field in ["op_did", "op_cert", "org_memberships", "project"] {
886 assert!(
887 !obj.contains_key(field),
888 "{field} leaked into claim-less card"
889 );
890 }
891 }
892
893 #[test]
894 fn with_identity_claims_rejects_malformed_op_did() {
895 let (_, pk) = generate_keypair();
896 let card = build_agent_card("vesper-valley", &pk, None, None, None);
897 let claims = IdentityClaims {
898 op_did: Some("did:wire:op:darby-deadbeef".into()),
900 ..Default::default()
901 };
902 let err = with_identity_claims(&card, &claims).unwrap_err();
903 assert!(matches!(err, ClaimError::InvalidOpDid(_)));
904 }
905
906 #[test]
907 fn with_identity_claims_rejects_malformed_org_did() {
908 let (_, pk) = generate_keypair();
909 let card = build_agent_card("vesper-valley", &pk, None, None, None);
910 let claims = IdentityClaims {
911 org_memberships: vec![OrgMembership {
912 org_did: "did:wire:slanchaai".into(),
913 org_pubkey: "AAAA".into(),
914 member_cert: "BBBB".into(),
915 }],
916 ..Default::default()
917 };
918 let err = with_identity_claims(&card, &claims).unwrap_err();
919 assert!(matches!(err, ClaimError::InvalidOrgDid(_)));
920 }
921
922 #[test]
923 fn build_agent_card_default_capability_advertises_v3_2() {
924 let (_, pk) = generate_keypair();
925 let card = build_agent_card("paul", &pk, None, None, None);
926 let caps = card["capabilities"].as_array().unwrap();
927 let has_v32 = caps.iter().any(|v| v.as_str() == Some("wire/v3.2"));
928 assert!(has_v32, "default caps should advertise wire/v3.2: {caps:?}");
929 }
930
931 #[test]
938 fn with_identity_claims_bumps_schema_version_when_op_did_attached() {
939 let (_, pk) = generate_keypair();
943 let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
944 card.as_object_mut()
946 .unwrap()
947 .insert("schema_version".into(), json!("v3.1"));
948 let (op_did, _, op_pk) = op_did_for_test("darby");
949 let claims = IdentityClaims {
950 op_did: Some(op_did),
951 op_pubkey: Some(crate::signing::b64encode(&op_pk)),
952 op_cert: Some("AAAA".into()),
953 ..Default::default()
954 };
955 let with = with_identity_claims(&card, &claims).unwrap();
956 assert_eq!(
957 with.get("schema_version").and_then(|v| v.as_str()),
958 Some(CARD_SCHEMA_VERSION),
959 "post-attach schema_version must bump to {CARD_SCHEMA_VERSION}",
960 );
961 }
962
963 #[test]
964 fn with_identity_claims_does_not_touch_schema_version_when_no_claims() {
965 let (_, pk) = generate_keypair();
969 let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
970 card.as_object_mut()
971 .unwrap()
972 .insert("schema_version".into(), json!("v3.1"));
973 let with = with_identity_claims(&card, &IdentityClaims::default()).unwrap();
974 assert_eq!(
975 with.get("schema_version").and_then(|v| v.as_str()),
976 Some("v3.1"),
977 "claim-less attach must NOT bump",
978 );
979 }
980
981 #[test]
982 fn with_identity_claims_never_downgrades_schema_version() {
983 let (_, pk) = generate_keypair();
987 let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
988 card.as_object_mut()
989 .unwrap()
990 .insert("schema_version".into(), json!("v3.5"));
991 let (op_did, _, op_pk) = op_did_for_test("darby");
992 let claims = IdentityClaims {
993 op_did: Some(op_did),
994 op_pubkey: Some(crate::signing::b64encode(&op_pk)),
995 op_cert: Some("AAAA".into()),
996 ..Default::default()
997 };
998 let with = with_identity_claims(&card, &claims).unwrap();
999 assert_eq!(
1000 with.get("schema_version").and_then(|v| v.as_str()),
1001 Some("v3.5"),
1002 "monotonic bump must not downgrade v3.5 to {CARD_SCHEMA_VERSION}",
1003 );
1004 }
1005
1006 #[test]
1007 fn max_schema_version_compares_numerically_not_lexicographically() {
1008 assert_eq!(max_schema_version("v3.10", "v3.2"), "v3.10");
1011 assert_eq!(max_schema_version("v3.2", "v3.10"), "v3.10");
1012 assert_eq!(max_schema_version("v3.2", "v3.2"), "v3.2");
1013 assert_eq!(max_schema_version("v4.0", "v3.99"), "v4.0");
1014 }
1015
1016 #[test]
1017 fn max_schema_version_biases_to_parseable_on_malformed_input() {
1018 assert_eq!(max_schema_version("garbage", "v3.2"), "v3.2");
1021 assert_eq!(max_schema_version("v3.2", "garbage"), "v3.2");
1022 assert_eq!(max_schema_version("garbage1", "garbage2"), "garbage1");
1023 }
1024}