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 {
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
61pub 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
75pub 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
86pub fn long_fingerprint(public_key: &[u8]) -> String {
90 let digest = Sha256::digest(public_key);
91 hex::encode(&digest[..16])
92}
93
94pub 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
104pub 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
120pub 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
132pub fn bare_handle(handle: &str) -> &str {
144 handle.split_once('@').map(|(n, _)| n).unwrap_or(handle)
145}
146
147pub fn display_handle_from_did(did: &str) -> &str {
151 let stripped = did.strip_prefix("did:wire:").unwrap_or(did);
152 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
163pub 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
179pub 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
224fn 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#[derive(Debug, Clone)]
246pub struct OrgMembership {
247 pub org_did: String,
248 pub org_pubkey: String,
253 pub member_cert: String,
255}
256
257#[derive(Debug, Clone, Default)]
262pub struct IdentityClaims {
263 pub op_did: Option<String>,
268 pub op_cert: Option<String>,
272 pub op_pubkey: Option<String>,
277 pub org_memberships: Vec<OrgMembership>,
280 pub project: Option<String>,
282}
283
284pub 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 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
366fn 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 (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
399pub fn card_op_did(card: &AgentCard) -> Option<&str> {
401 card.get("op_did").and_then(Value::as_str)
402}
403
404pub fn card_op_cert(card: &AgentCard) -> Option<&str> {
406 card.get("op_cert").and_then(Value::as_str)
407}
408
409pub fn card_project(card: &AgentCard) -> Option<&str> {
411 card.get("project").and_then(Value::as_str)
412}
413
414pub 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
431pub fn card_canonical(card: &AgentCard) -> Vec<u8> {
433 canonical(card, false)
434}
435
436pub 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
451pub 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
489pub 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 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 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 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 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 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 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 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 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 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 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 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 #[test]
845 fn with_identity_claims_bumps_schema_version_when_op_did_attached() {
846 let (_, pk) = generate_keypair();
850 let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
851 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 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 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 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 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}