use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::canonical::canonical;
use crate::signing::{b64decode, b64encode, make_key_id};
pub const CARD_SCHEMA_VERSION: &str = "v3.2";
pub const DID_METHOD: &str = "did:wire";
pub const DID_METHOD_OP: &str = "did:wire:op";
pub const DID_METHOD_ORG: &str = "did:wire:org";
pub const LONG_FINGERPRINT_HEX_LEN: usize = 32;
pub fn did_for_with_key(handle: &str, public_key: &[u8]) -> String {
if handle.starts_with("did:") {
return handle.to_string();
}
let suffix = crate::signing::fingerprint(public_key);
format!("{DID_METHOD}:{handle}-{suffix}")
}
pub fn did_for_op(handle: &str, public_key: &[u8]) -> String {
if handle.starts_with("did:wire:op:") {
return handle.to_string();
}
let suffix = long_fingerprint(public_key);
format!("{DID_METHOD_OP}:{handle}-{suffix}")
}
pub fn did_for_org(handle: &str, public_key: &[u8]) -> String {
if handle.starts_with("did:wire:org:") {
return handle.to_string();
}
let suffix = long_fingerprint(public_key);
format!("{DID_METHOD_ORG}:{handle}-{suffix}")
}
pub fn long_fingerprint(public_key: &[u8]) -> String {
let digest = Sha256::digest(public_key);
hex::encode(&digest[..16])
}
pub fn is_op_did(did: &str) -> bool {
let Some(rest) = did.strip_prefix("did:wire:op:") else {
return false;
};
has_long_hex_suffix(rest)
}
pub fn is_org_did(did: &str) -> bool {
let Some(rest) = did.strip_prefix("did:wire:org:") else {
return false;
};
has_long_hex_suffix(rest)
}
fn has_long_hex_suffix(s: &str) -> bool {
let Some(idx) = s.rfind('-') else {
return false;
};
let suffix = &s[idx + 1..];
suffix.len() == LONG_FINGERPRINT_HEX_LEN && suffix.chars().all(|c| c.is_ascii_hexdigit())
}
pub fn did_for(handle: &str) -> String {
if handle.starts_with("did:") {
handle.to_string()
} else {
format!("{DID_METHOD}:{handle}")
}
}
pub fn bare_handle(handle: &str) -> &str {
handle.split_once('@').map(|(n, _)| n).unwrap_or(handle)
}
pub fn display_handle_from_did(did: &str) -> &str {
let stripped = did.strip_prefix("did:wire:").unwrap_or(did);
if let Some(idx) = stripped.rfind('-') {
let suffix = &stripped[idx + 1..];
if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
return &stripped[..idx];
}
}
stripped
}
pub type AgentCard = Value;
#[derive(Debug, Error)]
pub enum CardError {
#[error("missing field: {0}")]
MissingField(&'static str),
#[error("verify_keys is empty or malformed")]
NoVerifyKeys,
#[error("signature decode failed")]
BadSignature,
#[error("signature did not verify")]
SignatureRejected,
}
pub fn build_agent_card(
handle: &str,
public_key: &[u8],
name: Option<&str>,
capabilities: Option<Vec<String>>,
max_body_kb: Option<u64>,
) -> AgentCard {
let display_name = name
.map(str::to_string)
.unwrap_or_else(|| capitalize(handle));
let caps = capabilities.unwrap_or_else(|| vec!["wire/v3.2".to_string()]);
let body_kb = max_body_kb.unwrap_or(64);
let key_id = make_key_id(handle, public_key);
let key_id_full = format!("ed25519:{key_id}");
json!({
"schema_version": CARD_SCHEMA_VERSION,
"did": did_for_with_key(handle, public_key),
"handle": handle,
"name": display_name,
"capabilities": caps,
"verify_keys": {
key_id_full: {
"key": b64encode(public_key),
"alg": "ed25519",
"active": true,
}
},
"policies": {
"max_message_body_kb": body_kb,
}
})
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
#[derive(Debug, Clone)]
pub struct OrgMembership {
pub org_did: String,
pub org_pubkey: String,
pub member_cert: String,
}
#[derive(Debug, Clone, Default)]
pub struct IdentityClaims {
pub op_did: Option<String>,
pub op_cert: Option<String>,
pub op_pubkey: Option<String>,
pub org_memberships: Vec<OrgMembership>,
pub project: Option<String>,
}
pub fn with_identity_claims(
card: &AgentCard,
claims: &IdentityClaims,
) -> Result<AgentCard, ClaimError> {
if let Some(op_did) = &claims.op_did
&& !is_op_did(op_did)
{
return Err(ClaimError::InvalidOpDid(op_did.clone()));
}
for m in &claims.org_memberships {
if !is_org_did(&m.org_did) {
return Err(ClaimError::InvalidOrgDid(m.org_did.clone()));
}
}
let mut out = card.as_object().cloned().unwrap_or_default();
if let Some(op_did) = &claims.op_did {
out.insert("op_did".into(), Value::String(op_did.clone()));
}
if let Some(op_cert) = &claims.op_cert {
out.insert("op_cert".into(), Value::String(op_cert.clone()));
}
if let Some(op_pubkey) = &claims.op_pubkey {
out.insert("op_pubkey".into(), Value::String(op_pubkey.clone()));
}
if !claims.org_memberships.is_empty() {
let arr: Vec<Value> = claims
.org_memberships
.iter()
.map(|m| {
json!({
"org_did": m.org_did,
"org_pubkey": m.org_pubkey,
"member_cert": m.member_cert,
})
})
.collect();
out.insert("org_memberships".into(), Value::Array(arr));
}
if let Some(project) = &claims.project {
out.insert("project".into(), Value::String(project.clone()));
}
let has_any_op_claim = claims.op_did.is_some()
|| claims.op_cert.is_some()
|| claims.op_pubkey.is_some()
|| !claims.org_memberships.is_empty();
if has_any_op_claim {
let current = out
.get("schema_version")
.and_then(Value::as_str)
.unwrap_or("v3.0");
let target = max_schema_version(current, CARD_SCHEMA_VERSION);
out.insert("schema_version".into(), Value::String(target.to_string()));
}
Ok(Value::Object(out))
}
fn max_schema_version<'a>(a: &'a str, b: &'a str) -> &'a str {
fn parse(s: &str) -> Option<(u32, u32)> {
let rest = s.strip_prefix('v')?;
let (maj, min) = rest.split_once('.')?;
Some((maj.parse().ok()?, min.parse().ok()?))
}
match (parse(a), parse(b)) {
(Some(pa), Some(pb)) => {
if pa >= pb {
a
} else {
b
}
}
(Some(_), None) => a,
(None, Some(_)) => b,
(None, None) => a,
}
}
#[derive(Debug, Error)]
pub enum ClaimError {
#[error("op_did is not a well-formed did:wire:op:<handle>-<32hex>: {0}")]
InvalidOpDid(String),
#[error("org_did is not a well-formed did:wire:org:<handle>-<32hex>: {0}")]
InvalidOrgDid(String),
}
pub fn card_op_did(card: &AgentCard) -> Option<&str> {
card.get("op_did").and_then(Value::as_str)
}
pub fn card_op_cert(card: &AgentCard) -> Option<&str> {
card.get("op_cert").and_then(Value::as_str)
}
pub fn card_project(card: &AgentCard) -> Option<&str> {
card.get("project").and_then(Value::as_str)
}
pub fn card_org_memberships(card: &AgentCard) -> Vec<(&str, &str)> {
card.get("org_memberships")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|entry| {
let org = entry.get("org_did").and_then(Value::as_str)?;
let cert = entry.get("member_cert").and_then(Value::as_str)?;
Some((org, cert))
})
.collect()
})
.unwrap_or_default()
}
pub fn card_canonical(card: &AgentCard) -> Vec<u8> {
canonical(card, false)
}
pub fn sign_agent_card(card: &AgentCard, private_key: &[u8]) -> AgentCard {
let mut sk_bytes = [0u8; 32];
sk_bytes.copy_from_slice(&private_key[..32]);
let sk = SigningKey::from_bytes(&sk_bytes);
let sig = sk.sign(&card_canonical(card));
let mut out = card.as_object().cloned().unwrap_or_default();
out.insert(
"signature".into(),
Value::String(b64encode(&sig.to_bytes())),
);
Value::Object(out)
}
pub fn verify_agent_card(card: &AgentCard) -> Result<(), CardError> {
let signature_b64 = card
.get("signature")
.and_then(Value::as_str)
.ok_or(CardError::MissingField("signature"))?;
let verify_keys = card
.get("verify_keys")
.and_then(Value::as_object)
.ok_or(CardError::MissingField("verify_keys"))?;
let (_kid, key_record) = verify_keys.iter().next().ok_or(CardError::NoVerifyKeys)?;
let pk_b64 = key_record
.get("key")
.and_then(Value::as_str)
.ok_or(CardError::MissingField("verify_keys[*].key"))?;
let pk_bytes = b64decode(pk_b64).map_err(|_| CardError::BadSignature)?;
if pk_bytes.len() != 32 {
return Err(CardError::BadSignature);
}
let mut pk_arr = [0u8; 32];
pk_arr.copy_from_slice(&pk_bytes);
let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| CardError::BadSignature)?;
let sig_bytes = b64decode(signature_b64).map_err(|_| CardError::BadSignature)?;
if sig_bytes.len() != 64 {
return Err(CardError::BadSignature);
}
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
vk.verify(&card_canonical(card), &sig)
.map_err(|_| CardError::SignatureRejected)
}
pub fn compute_sas(public_key_a: &[u8], public_key_b: &[u8]) -> String {
let (lo, hi) = if public_key_a <= public_key_b {
(public_key_a, public_key_b)
} else {
(public_key_b, public_key_a)
};
let mut h = Sha256::new();
h.update(lo);
h.update(hi);
let digest = h.finalize();
let n = u32::from_be_bytes([digest[28], digest[29], digest[30], digest[31]]);
format!("{:06}", n % 1_000_000)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::signing::generate_keypair;
#[test]
fn did_for_handle() {
assert_eq!(did_for("paul"), "did:wire:paul");
}
#[test]
fn did_for_already_did_passthrough() {
assert_eq!(did_for("did:wire:paul"), "did:wire:paul");
assert_eq!(did_for("did:key:abc"), "did:key:abc");
}
#[test]
fn did_method_constant() {
assert_eq!(DID_METHOD, "did:wire");
}
#[test]
fn build_minimal_card() {
let (_, pk) = generate_keypair();
let card = build_agent_card("paul", &pk, None, None, None);
assert_eq!(card["schema_version"], CARD_SCHEMA_VERSION);
let did = card["did"].as_str().unwrap();
assert!(did.starts_with("did:wire:paul-"), "got: {did}");
assert_eq!(did.len(), "did:wire:paul-".len() + 8);
assert_eq!(card["handle"], "paul");
assert_eq!(card["name"], "Paul");
let vks = card["verify_keys"].as_object().unwrap();
assert_eq!(vks.len(), 1);
assert_eq!(card["policies"]["max_message_body_kb"], 64);
}
#[test]
fn build_card_with_overrides() {
let (_, pk) = generate_keypair();
let card = build_agent_card(
"carol",
&pk,
Some("Carol's Agent"),
Some(vec!["custom-cap".to_string()]),
Some(128),
);
assert_eq!(card["name"], "Carol's Agent");
assert_eq!(card["capabilities"], json!(["custom-cap"]));
assert_eq!(card["policies"]["max_message_body_kb"], 128);
}
#[test]
fn build_card_does_not_carry_v02_fields() {
let (_, pk) = generate_keypair();
let card = build_agent_card("paul", &pk, None, None, None);
let obj = card.as_object().unwrap();
for v02 in [
"registries",
"onboard_endpoint",
"wire_raw_url_template",
"revoked_at",
] {
assert!(
!obj.contains_key(v02),
"v0.2+ field {v02} leaked into v0.1 card"
);
}
}
#[test]
fn card_canonical_excludes_signature() {
let v = json!({"schema_version": "v3.1", "did": "did:wire:paul", "signature": "sig"});
let bytes = card_canonical(&v);
assert!(!String::from_utf8_lossy(&bytes).contains("signature"));
}
#[test]
fn card_canonical_sort_keys_stable() {
let a = json!({"b": 1, "a": 2, "did": "did:wire:paul"});
let b = json!({"did": "did:wire:paul", "a": 2, "b": 1});
assert_eq!(card_canonical(&a), card_canonical(&b));
}
#[test]
fn sign_verify_roundtrip() {
let (sk, pk) = generate_keypair();
let card = build_agent_card("paul", &pk, None, None, None);
let signed = sign_agent_card(&card, &sk);
assert!(signed.get("signature").is_some());
verify_agent_card(&signed).unwrap();
}
#[test]
fn verify_rejects_unsigned_card() {
let (_, pk) = generate_keypair();
let card = build_agent_card("paul", &pk, None, None, None);
let err = verify_agent_card(&card).unwrap_err();
assert!(matches!(err, CardError::MissingField("signature")));
}
#[test]
fn verify_rejects_tampered_card() {
let (sk, pk) = generate_keypair();
let mut signed = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
signed["name"] = json!("TamperedName");
let err = verify_agent_card(&signed).unwrap_err();
assert!(matches!(err, CardError::SignatureRejected));
}
#[test]
fn verify_rejects_card_with_no_verify_keys() {
let (sk, _) = generate_keypair();
let card = json!({"schema_version": "v3.1", "did": "did:wire:paul", "verify_keys": {}});
let signed = sign_agent_card(&card, &sk);
let err = verify_agent_card(&signed).unwrap_err();
assert!(matches!(err, CardError::NoVerifyKeys));
}
#[test]
fn compute_sas_is_6_digits() {
let (_, a) = generate_keypair();
let (_, b) = generate_keypair();
let sas = compute_sas(&a, &b);
assert_eq!(sas.len(), 6);
assert!(sas.chars().all(|c| c.is_ascii_digit()));
}
#[test]
fn compute_sas_bilateral_symmetric() {
let (_, a) = generate_keypair();
let (_, b) = generate_keypair();
assert_eq!(compute_sas(&a, &b), compute_sas(&b, &a));
}
#[test]
fn compute_sas_changes_with_inputs() {
let (_, a) = generate_keypair();
let (_, b) = generate_keypair();
let (_, c) = generate_keypair();
assert_ne!(compute_sas(&a, &b), compute_sas(&a, &c));
}
fn op_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
let (sk, pk) = generate_keypair();
(did_for_op(handle, &pk), sk.to_vec(), pk.to_vec())
}
fn org_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
let (sk, pk) = generate_keypair();
(did_for_org(handle, &pk), sk.to_vec(), pk.to_vec())
}
#[test]
fn schema_version_is_v3_2() {
assert_eq!(CARD_SCHEMA_VERSION, "v3.2");
}
#[test]
fn op_did_has_long_hex_suffix_and_method_prefix() {
let (did, _, _) = op_did_for_test("darby");
assert!(did.starts_with("did:wire:op:darby-"), "got: {did}");
let tail = did.rsplit('-').next().unwrap();
assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn org_did_has_long_hex_suffix_and_method_prefix() {
let (did, _, _) = org_did_for_test("slanchaai");
assert!(did.starts_with("did:wire:org:slanchaai-"), "got: {did}");
let tail = did.rsplit('-').next().unwrap();
assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn op_did_passthrough_when_already_op_did() {
let (_, pk) = generate_keypair();
let did = did_for_op("darby", &pk);
let again = did_for_op(&did, &pk);
assert_eq!(did, again);
}
#[test]
fn is_op_did_rejects_session_did() {
let (_, pk) = generate_keypair();
let session_did = did_for_with_key("darby", &pk);
assert!(!is_op_did(&session_did));
assert!(!is_org_did(&session_did));
}
#[test]
fn is_op_did_rejects_org_did_and_vice_versa() {
let (op, _, _) = op_did_for_test("darby");
let (org, _, _) = org_did_for_test("slanchaai");
assert!(is_op_did(&op) && !is_org_did(&op));
assert!(is_org_did(&org) && !is_op_did(&org));
}
#[test]
fn is_op_did_rejects_short_hex_suffix() {
assert!(!is_op_did("did:wire:op:darby-deadbeef"));
assert!(!is_org_did("did:wire:org:slanchaai-deadbeef"));
}
#[test]
fn is_op_did_rejects_non_hex_suffix() {
let bad = format!("did:wire:op:darby-{}", "z".repeat(LONG_FINGERPRINT_HEX_LEN));
assert!(!is_op_did(&bad));
}
#[test]
fn with_identity_claims_attaches_all_fields() {
let (sk, pk) = generate_keypair();
let card = build_agent_card("vesper-valley", &pk, None, None, None);
let (op_did, _, op_pk) = op_did_for_test("darby");
let (org_did, _, org_pk) = org_did_for_test("slanchaai");
let op_pubkey = crate::signing::b64encode(&op_pk);
let org_pubkey = crate::signing::b64encode(&org_pk);
let claims = IdentityClaims {
op_did: Some(op_did.clone()),
op_cert: Some("AAAA".into()),
op_pubkey: Some(op_pubkey.clone()),
org_memberships: vec![OrgMembership {
org_did: org_did.clone(),
org_pubkey: org_pubkey.clone(),
member_cert: "BBBB".into(),
}],
project: Some("wire-codex-integration".into()),
};
let with = with_identity_claims(&card, &claims).unwrap();
assert_eq!(card_op_did(&with), Some(op_did.as_str()));
assert_eq!(card_op_cert(&with), Some("AAAA"));
assert_eq!(
with.get("op_pubkey").and_then(|v| v.as_str()),
Some(op_pubkey.as_str())
);
assert_eq!(card_project(&with), Some("wire-codex-integration"));
let orgs = card_org_memberships(&with);
assert_eq!(orgs.len(), 1);
assert_eq!(orgs[0], (org_did.as_str(), "BBBB"));
assert_eq!(
with.get("org_memberships").unwrap()[0]
.get("org_pubkey")
.and_then(|v| v.as_str()),
Some(org_pubkey.as_str())
);
let signed = sign_agent_card(&with, &sk);
verify_agent_card(&signed).unwrap();
}
#[test]
fn with_identity_claims_skips_absent_fields() {
let (_, pk) = generate_keypair();
let card = build_agent_card("vesper-valley", &pk, None, None, None);
let with = with_identity_claims(&card, &IdentityClaims::default()).unwrap();
let obj = with.as_object().unwrap();
for field in ["op_did", "op_cert", "org_memberships", "project"] {
assert!(
!obj.contains_key(field),
"{field} leaked into claim-less card"
);
}
}
#[test]
fn with_identity_claims_rejects_malformed_op_did() {
let (_, pk) = generate_keypair();
let card = build_agent_card("vesper-valley", &pk, None, None, None);
let claims = IdentityClaims {
op_did: Some("did:wire:op:darby-deadbeef".into()),
..Default::default()
};
let err = with_identity_claims(&card, &claims).unwrap_err();
assert!(matches!(err, ClaimError::InvalidOpDid(_)));
}
#[test]
fn with_identity_claims_rejects_malformed_org_did() {
let (_, pk) = generate_keypair();
let card = build_agent_card("vesper-valley", &pk, None, None, None);
let claims = IdentityClaims {
org_memberships: vec![OrgMembership {
org_did: "did:wire:slanchaai".into(),
org_pubkey: "AAAA".into(),
member_cert: "BBBB".into(),
}],
..Default::default()
};
let err = with_identity_claims(&card, &claims).unwrap_err();
assert!(matches!(err, ClaimError::InvalidOrgDid(_)));
}
#[test]
fn v3_1_card_remains_verifiable_under_v3_2_code() {
let (sk, pk) = generate_keypair();
let mut card = build_agent_card("paul", &pk, None, None, None);
card["schema_version"] = json!("v3.1");
let signed = sign_agent_card(&card, &sk);
verify_agent_card(&signed).unwrap();
}
#[test]
fn build_agent_card_default_capability_advertises_v3_2() {
let (_, pk) = generate_keypair();
let card = build_agent_card("paul", &pk, None, None, None);
let caps = card["capabilities"].as_array().unwrap();
let has_v32 = caps.iter().any(|v| v.as_str() == Some("wire/v3.2"));
assert!(has_v32, "default caps should advertise wire/v3.2: {caps:?}");
}
#[test]
fn with_identity_claims_bumps_schema_version_when_op_did_attached() {
let (_, pk) = generate_keypair();
let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
card.as_object_mut()
.unwrap()
.insert("schema_version".into(), json!("v3.1"));
let (op_did, _, op_pk) = op_did_for_test("darby");
let claims = IdentityClaims {
op_did: Some(op_did),
op_pubkey: Some(crate::signing::b64encode(&op_pk)),
op_cert: Some("AAAA".into()),
..Default::default()
};
let with = with_identity_claims(&card, &claims).unwrap();
assert_eq!(
with.get("schema_version").and_then(|v| v.as_str()),
Some(CARD_SCHEMA_VERSION),
"post-attach schema_version must bump to {CARD_SCHEMA_VERSION}",
);
}
#[test]
fn with_identity_claims_does_not_touch_schema_version_when_no_claims() {
let (_, pk) = generate_keypair();
let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
card.as_object_mut()
.unwrap()
.insert("schema_version".into(), json!("v3.1"));
let with = with_identity_claims(&card, &IdentityClaims::default()).unwrap();
assert_eq!(
with.get("schema_version").and_then(|v| v.as_str()),
Some("v3.1"),
"claim-less attach must NOT bump",
);
}
#[test]
fn with_identity_claims_never_downgrades_schema_version() {
let (_, pk) = generate_keypair();
let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
card.as_object_mut()
.unwrap()
.insert("schema_version".into(), json!("v3.5"));
let (op_did, _, op_pk) = op_did_for_test("darby");
let claims = IdentityClaims {
op_did: Some(op_did),
op_pubkey: Some(crate::signing::b64encode(&op_pk)),
op_cert: Some("AAAA".into()),
..Default::default()
};
let with = with_identity_claims(&card, &claims).unwrap();
assert_eq!(
with.get("schema_version").and_then(|v| v.as_str()),
Some("v3.5"),
"monotonic bump must not downgrade v3.5 to {CARD_SCHEMA_VERSION}",
);
}
#[test]
fn max_schema_version_compares_numerically_not_lexicographically() {
assert_eq!(max_schema_version("v3.10", "v3.2"), "v3.10");
assert_eq!(max_schema_version("v3.2", "v3.10"), "v3.10");
assert_eq!(max_schema_version("v3.2", "v3.2"), "v3.2");
assert_eq!(max_schema_version("v4.0", "v3.99"), "v4.0");
}
#[test]
fn max_schema_version_biases_to_parseable_on_malformed_input() {
assert_eq!(max_schema_version("garbage", "v3.2"), "v3.2");
assert_eq!(max_schema_version("v3.2", "garbage"), "v3.2");
assert_eq!(max_schema_version("garbage1", "garbage2"), "garbage1");
}
}