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 Ok(Value::Object(out))
340}
341
342#[derive(Debug, Error)]
343pub enum ClaimError {
344 #[error("op_did is not a well-formed did:wire:op:<handle>-<32hex>: {0}")]
345 InvalidOpDid(String),
346 #[error("org_did is not a well-formed did:wire:org:<handle>-<32hex>: {0}")]
347 InvalidOrgDid(String),
348}
349
350pub fn card_op_did(card: &AgentCard) -> Option<&str> {
352 card.get("op_did").and_then(Value::as_str)
353}
354
355pub fn card_op_cert(card: &AgentCard) -> Option<&str> {
357 card.get("op_cert").and_then(Value::as_str)
358}
359
360pub fn card_project(card: &AgentCard) -> Option<&str> {
362 card.get("project").and_then(Value::as_str)
363}
364
365pub fn card_org_memberships(card: &AgentCard) -> Vec<(&str, &str)> {
368 card.get("org_memberships")
369 .and_then(Value::as_array)
370 .map(|arr| {
371 arr.iter()
372 .filter_map(|entry| {
373 let org = entry.get("org_did").and_then(Value::as_str)?;
374 let cert = entry.get("member_cert").and_then(Value::as_str)?;
375 Some((org, cert))
376 })
377 .collect()
378 })
379 .unwrap_or_default()
380}
381
382pub fn card_canonical(card: &AgentCard) -> Vec<u8> {
384 canonical(card, false)
385}
386
387pub fn sign_agent_card(card: &AgentCard, private_key: &[u8]) -> AgentCard {
390 let mut sk_bytes = [0u8; 32];
391 sk_bytes.copy_from_slice(&private_key[..32]);
392 let sk = SigningKey::from_bytes(&sk_bytes);
393 let sig = sk.sign(&card_canonical(card));
394 let mut out = card.as_object().cloned().unwrap_or_default();
395 out.insert(
396 "signature".into(),
397 Value::String(b64encode(&sig.to_bytes())),
398 );
399 Value::Object(out)
400}
401
402pub fn verify_agent_card(card: &AgentCard) -> Result<(), CardError> {
405 let signature_b64 = card
406 .get("signature")
407 .and_then(Value::as_str)
408 .ok_or(CardError::MissingField("signature"))?;
409
410 let verify_keys = card
411 .get("verify_keys")
412 .and_then(Value::as_object)
413 .ok_or(CardError::MissingField("verify_keys"))?;
414
415 let (_kid, key_record) = verify_keys.iter().next().ok_or(CardError::NoVerifyKeys)?;
416 let pk_b64 = key_record
417 .get("key")
418 .and_then(Value::as_str)
419 .ok_or(CardError::MissingField("verify_keys[*].key"))?;
420 let pk_bytes = b64decode(pk_b64).map_err(|_| CardError::BadSignature)?;
421 if pk_bytes.len() != 32 {
422 return Err(CardError::BadSignature);
423 }
424 let mut pk_arr = [0u8; 32];
425 pk_arr.copy_from_slice(&pk_bytes);
426 let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| CardError::BadSignature)?;
427
428 let sig_bytes = b64decode(signature_b64).map_err(|_| CardError::BadSignature)?;
429 if sig_bytes.len() != 64 {
430 return Err(CardError::BadSignature);
431 }
432 let mut sig_arr = [0u8; 64];
433 sig_arr.copy_from_slice(&sig_bytes);
434 let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
435
436 vk.verify(&card_canonical(card), &sig)
437 .map_err(|_| CardError::SignatureRejected)
438}
439
440pub fn compute_sas(public_key_a: &[u8], public_key_b: &[u8]) -> String {
446 let (lo, hi) = if public_key_a <= public_key_b {
447 (public_key_a, public_key_b)
448 } else {
449 (public_key_b, public_key_a)
450 };
451 let mut h = Sha256::new();
452 h.update(lo);
453 h.update(hi);
454 let digest = h.finalize();
455 let n = u32::from_be_bytes([digest[28], digest[29], digest[30], digest[31]]);
457 format!("{:06}", n % 1_000_000)
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use crate::signing::generate_keypair;
464
465 #[test]
466 fn did_for_handle() {
467 assert_eq!(did_for("paul"), "did:wire:paul");
468 }
469
470 #[test]
471 fn did_for_already_did_passthrough() {
472 assert_eq!(did_for("did:wire:paul"), "did:wire:paul");
473 assert_eq!(did_for("did:key:abc"), "did:key:abc");
474 }
475
476 #[test]
477 fn did_method_constant() {
478 assert_eq!(DID_METHOD, "did:wire");
479 }
480
481 #[test]
482 fn build_minimal_card() {
483 let (_, pk) = generate_keypair();
484 let card = build_agent_card("paul", &pk, None, None, None);
485 assert_eq!(card["schema_version"], CARD_SCHEMA_VERSION);
486 let did = card["did"].as_str().unwrap();
488 assert!(did.starts_with("did:wire:paul-"), "got: {did}");
489 assert_eq!(did.len(), "did:wire:paul-".len() + 8);
490 assert_eq!(card["handle"], "paul");
491 assert_eq!(card["name"], "Paul");
492 let vks = card["verify_keys"].as_object().unwrap();
493 assert_eq!(vks.len(), 1);
494 assert_eq!(card["policies"]["max_message_body_kb"], 64);
495 }
496
497 #[test]
498 fn build_card_with_overrides() {
499 let (_, pk) = generate_keypair();
500 let card = build_agent_card(
501 "carol",
502 &pk,
503 Some("Carol's Agent"),
504 Some(vec!["custom-cap".to_string()]),
505 Some(128),
506 );
507 assert_eq!(card["name"], "Carol's Agent");
508 assert_eq!(card["capabilities"], json!(["custom-cap"]));
509 assert_eq!(card["policies"]["max_message_body_kb"], 128);
510 }
511
512 #[test]
513 fn build_card_does_not_carry_v02_fields() {
514 let (_, pk) = generate_keypair();
515 let card = build_agent_card("paul", &pk, None, None, None);
516 let obj = card.as_object().unwrap();
517 for v02 in [
518 "registries",
519 "onboard_endpoint",
520 "wire_raw_url_template",
521 "revoked_at",
522 ] {
523 assert!(
524 !obj.contains_key(v02),
525 "v0.2+ field {v02} leaked into v0.1 card"
526 );
527 }
528 }
529
530 #[test]
531 fn card_canonical_excludes_signature() {
532 let v = json!({"schema_version": "v3.1", "did": "did:wire:paul", "signature": "sig"});
533 let bytes = card_canonical(&v);
534 assert!(!String::from_utf8_lossy(&bytes).contains("signature"));
535 }
536
537 #[test]
538 fn card_canonical_sort_keys_stable() {
539 let a = json!({"b": 1, "a": 2, "did": "did:wire:paul"});
540 let b = json!({"did": "did:wire:paul", "a": 2, "b": 1});
541 assert_eq!(card_canonical(&a), card_canonical(&b));
542 }
543
544 #[test]
545 fn sign_verify_roundtrip() {
546 let (sk, pk) = generate_keypair();
547 let card = build_agent_card("paul", &pk, None, None, None);
548 let signed = sign_agent_card(&card, &sk);
549 assert!(signed.get("signature").is_some());
550 verify_agent_card(&signed).unwrap();
551 }
552
553 #[test]
554 fn verify_rejects_unsigned_card() {
555 let (_, pk) = generate_keypair();
556 let card = build_agent_card("paul", &pk, None, None, None);
557 let err = verify_agent_card(&card).unwrap_err();
558 assert!(matches!(err, CardError::MissingField("signature")));
559 }
560
561 #[test]
562 fn verify_rejects_tampered_card() {
563 let (sk, pk) = generate_keypair();
564 let mut signed = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
565 signed["name"] = json!("TamperedName");
566 let err = verify_agent_card(&signed).unwrap_err();
567 assert!(matches!(err, CardError::SignatureRejected));
568 }
569
570 #[test]
571 fn verify_rejects_card_with_no_verify_keys() {
572 let (sk, _) = generate_keypair();
573 let card = json!({"schema_version": "v3.1", "did": "did:wire:paul", "verify_keys": {}});
574 let signed = sign_agent_card(&card, &sk);
575 let err = verify_agent_card(&signed).unwrap_err();
576 assert!(matches!(err, CardError::NoVerifyKeys));
577 }
578
579 #[test]
580 fn compute_sas_is_6_digits() {
581 let (_, a) = generate_keypair();
582 let (_, b) = generate_keypair();
583 let sas = compute_sas(&a, &b);
584 assert_eq!(sas.len(), 6);
585 assert!(sas.chars().all(|c| c.is_ascii_digit()));
586 }
587
588 #[test]
589 fn compute_sas_bilateral_symmetric() {
590 let (_, a) = generate_keypair();
591 let (_, b) = generate_keypair();
592 assert_eq!(compute_sas(&a, &b), compute_sas(&b, &a));
593 }
594
595 #[test]
596 fn compute_sas_changes_with_inputs() {
597 let (_, a) = generate_keypair();
598 let (_, b) = generate_keypair();
599 let (_, c) = generate_keypair();
600 assert_ne!(compute_sas(&a, &b), compute_sas(&a, &c));
601 }
602
603 fn op_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
606 let (sk, pk) = generate_keypair();
607 (did_for_op(handle, &pk), sk.to_vec(), pk.to_vec())
608 }
609
610 fn org_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
611 let (sk, pk) = generate_keypair();
612 (did_for_org(handle, &pk), sk.to_vec(), pk.to_vec())
613 }
614
615 #[test]
616 fn schema_version_is_v3_2() {
617 assert_eq!(CARD_SCHEMA_VERSION, "v3.2");
618 }
619
620 #[test]
621 fn op_did_has_long_hex_suffix_and_method_prefix() {
622 let (did, _, _) = op_did_for_test("darby");
623 assert!(did.starts_with("did:wire:op:darby-"), "got: {did}");
624 let tail = did.rsplit('-').next().unwrap();
625 assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
626 assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
627 }
628
629 #[test]
630 fn org_did_has_long_hex_suffix_and_method_prefix() {
631 let (did, _, _) = org_did_for_test("slanchaai");
632 assert!(did.starts_with("did:wire:org:slanchaai-"), "got: {did}");
633 let tail = did.rsplit('-').next().unwrap();
634 assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
635 assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
636 }
637
638 #[test]
639 fn op_did_passthrough_when_already_op_did() {
640 let (_, pk) = generate_keypair();
643 let did = did_for_op("darby", &pk);
644 let again = did_for_op(&did, &pk);
645 assert_eq!(did, again);
646 }
647
648 #[test]
649 fn is_op_did_rejects_session_did() {
650 let (_, pk) = generate_keypair();
652 let session_did = did_for_with_key("darby", &pk);
653 assert!(!is_op_did(&session_did));
654 assert!(!is_org_did(&session_did));
655 }
656
657 #[test]
658 fn is_op_did_rejects_org_did_and_vice_versa() {
659 let (op, _, _) = op_did_for_test("darby");
662 let (org, _, _) = org_did_for_test("slanchaai");
663 assert!(is_op_did(&op) && !is_org_did(&op));
664 assert!(is_org_did(&org) && !is_op_did(&org));
665 }
666
667 #[test]
668 fn is_op_did_rejects_short_hex_suffix() {
669 assert!(!is_op_did("did:wire:op:darby-deadbeef"));
672 assert!(!is_org_did("did:wire:org:slanchaai-deadbeef"));
673 }
674
675 #[test]
676 fn is_op_did_rejects_non_hex_suffix() {
677 let bad = format!("did:wire:op:darby-{}", "z".repeat(LONG_FINGERPRINT_HEX_LEN));
678 assert!(!is_op_did(&bad));
679 }
680
681 #[test]
682 fn with_identity_claims_attaches_all_fields() {
683 let (sk, pk) = generate_keypair();
684 let card = build_agent_card("vesper-valley", &pk, None, None, None);
685 let (op_did, _, op_pk) = op_did_for_test("darby");
686 let (org_did, _, org_pk) = org_did_for_test("slanchaai");
687 let op_pubkey = crate::signing::b64encode(&op_pk);
688 let org_pubkey = crate::signing::b64encode(&org_pk);
689 let claims = IdentityClaims {
690 op_did: Some(op_did.clone()),
691 op_cert: Some("AAAA".into()),
692 op_pubkey: Some(op_pubkey.clone()),
693 org_memberships: vec![OrgMembership {
694 org_did: org_did.clone(),
695 org_pubkey: org_pubkey.clone(),
696 member_cert: "BBBB".into(),
697 }],
698 project: Some("wire-codex-integration".into()),
699 };
700 let with = with_identity_claims(&card, &claims).unwrap();
701 assert_eq!(card_op_did(&with), Some(op_did.as_str()));
702 assert_eq!(card_op_cert(&with), Some("AAAA"));
703 assert_eq!(
704 with.get("op_pubkey").and_then(|v| v.as_str()),
705 Some(op_pubkey.as_str())
706 );
707 assert_eq!(card_project(&with), Some("wire-codex-integration"));
708 let orgs = card_org_memberships(&with);
709 assert_eq!(orgs.len(), 1);
710 assert_eq!(orgs[0], (org_did.as_str(), "BBBB"));
711 assert_eq!(
712 with.get("org_memberships").unwrap()[0]
713 .get("org_pubkey")
714 .and_then(|v| v.as_str()),
715 Some(org_pubkey.as_str())
716 );
717 let signed = sign_agent_card(&with, &sk);
719 verify_agent_card(&signed).unwrap();
720 }
721
722 #[test]
723 fn with_identity_claims_skips_absent_fields() {
724 let (_, pk) = generate_keypair();
727 let card = build_agent_card("vesper-valley", &pk, None, None, None);
728 let with = with_identity_claims(&card, &IdentityClaims::default()).unwrap();
729 let obj = with.as_object().unwrap();
730 for field in ["op_did", "op_cert", "org_memberships", "project"] {
731 assert!(
732 !obj.contains_key(field),
733 "{field} leaked into claim-less card"
734 );
735 }
736 }
737
738 #[test]
739 fn with_identity_claims_rejects_malformed_op_did() {
740 let (_, pk) = generate_keypair();
741 let card = build_agent_card("vesper-valley", &pk, None, None, None);
742 let claims = IdentityClaims {
743 op_did: Some("did:wire:op:darby-deadbeef".into()),
745 ..Default::default()
746 };
747 let err = with_identity_claims(&card, &claims).unwrap_err();
748 assert!(matches!(err, ClaimError::InvalidOpDid(_)));
749 }
750
751 #[test]
752 fn with_identity_claims_rejects_malformed_org_did() {
753 let (_, pk) = generate_keypair();
754 let card = build_agent_card("vesper-valley", &pk, None, None, None);
755 let claims = IdentityClaims {
756 org_memberships: vec![OrgMembership {
757 org_did: "did:wire:slanchaai".into(),
758 org_pubkey: "AAAA".into(),
759 member_cert: "BBBB".into(),
760 }],
761 ..Default::default()
762 };
763 let err = with_identity_claims(&card, &claims).unwrap_err();
764 assert!(matches!(err, ClaimError::InvalidOrgDid(_)));
765 }
766
767 #[test]
768 fn v3_1_card_remains_verifiable_under_v3_2_code() {
769 let (sk, pk) = generate_keypair();
774 let mut card = build_agent_card("paul", &pk, None, None, None);
775 card["schema_version"] = json!("v3.1");
776 let signed = sign_agent_card(&card, &sk);
777 verify_agent_card(&signed).unwrap();
778 }
779
780 #[test]
781 fn build_agent_card_default_capability_advertises_v3_2() {
782 let (_, pk) = generate_keypair();
783 let card = build_agent_card("paul", &pk, None, None, None);
784 let caps = card["capabilities"].as_array().unwrap();
785 let has_v32 = caps.iter().any(|v| v.as_str() == Some("wire/v3.2"));
786 assert!(has_v32, "default caps should advertise wire/v3.2: {caps:?}");
787 }
788}