pub fn build_message(msg_type: &str, body: serde_json::Value, from: &str, to: &str) -> String {
serde_json::json!({
"id": uuid::Uuid::new_v4().to_string(),
"typ": "application/didcomm-plain+json",
"type": msg_type,
"body": body,
"from": from,
"to": [to],
})
.to_string()
}
pub fn pack_anoncrypt(
plaintext: &[u8],
recipient_x25519_pub: &[u8; 32],
recipient_kid: &str,
) -> Result<String, Box<dyn std::error::Error>> {
use affinidi_crypto::jose::key_agreement::PublicKeyAgreement;
use affinidi_messaging_didcomm::jwe::encrypt::anoncrypt;
let recipient_pub = PublicKeyAgreement::X25519(*recipient_x25519_pub);
anoncrypt(plaintext, &[(recipient_kid, &recipient_pub)])
.map_err(|e| -> Box<dyn std::error::Error> { format!("anoncrypt: {e}").into() })
}
pub fn parse_did_key_ed25519(did: &str) -> Result<[u8; 32], Box<dyn std::error::Error>> {
let multibase_part = did.strip_prefix("did:key:").ok_or("not a did:key")?;
let (_, decoded) = multibase::decode(multibase_part)?;
if decoded.len() != 34 || decoded[0] != 0xed || decoded[1] != 0x01 {
return Err("invalid did:key: expected Ed25519 multicodec prefix 0xed01".into());
}
let mut key = [0u8; 32];
key.copy_from_slice(&decoded[2..]);
Ok(key)
}
pub fn ed25519_pub_to_x25519_pub(
ed_pub: &[u8; 32],
) -> Result<[u8; 32], Box<dyn std::error::Error>> {
let compressed = curve25519_dalek::edwards::CompressedEdwardsY(*ed_pub);
let edwards = compressed
.decompress()
.ok_or("invalid Ed25519 public key: decompression failed")?;
Ok(edwards.to_montgomery().to_bytes())
}
pub fn did_key_agreement_kid(did: &str) -> Result<String, Box<dyn std::error::Error>> {
let ed_pub = parse_did_key_ed25519(did)?;
let x_pub = ed25519_pub_to_x25519_pub(&ed_pub)?;
let mut buf = Vec::with_capacity(34);
buf.extend_from_slice(&[0xec, 0x01]); buf.extend_from_slice(&x_pub);
let x_multibase = multibase::encode(multibase::Base::Base58Btc, &buf);
Ok(format!("{did}#{x_multibase}"))
}
pub fn ed25519_seed_to_x25519_secret(seed: &[u8; 32]) -> [u8; 32] {
use sha2::Digest;
let hash = sha2::Sha512::digest(seed);
let mut x25519_bytes = [0u8; 32];
x25519_bytes.copy_from_slice(&hash[..32]);
x25519_bytes[0] &= 248;
x25519_bytes[31] &= 127;
x25519_bytes[31] |= 64;
x25519_bytes
}
pub async fn resolve_vta_keyagreement(
vta_did: &str,
) -> Result<(String, [u8; 32]), Box<dyn std::error::Error>> {
if vta_did.starts_with("did:key:") {
let ed_pub = parse_did_key_ed25519(vta_did)?;
let x_pub = ed25519_pub_to_x25519_pub(&ed_pub)?;
let kid = did_key_agreement_kid(vta_did)?;
Ok((kid, x_pub))
} else if vta_did.starts_with("did:webvh:") {
resolve_webvh_keyagreement(vta_did).await
} else {
Err(format!(
"unsupported VTA DID method (lightweight path supports did:key and did:webvh only): {vta_did}"
)
.into())
}
}
async fn resolve_webvh_keyagreement(
vta_did: &str,
) -> Result<(String, [u8; 32]), Box<dyn std::error::Error>> {
use didwebvh_rs::DIDWebVHState;
use didwebvh_rs::log_entry::LogEntryMethods;
use didwebvh_rs::resolve::ResolveOptions;
let mut state = DIDWebVHState::default();
let (log_entry, _meta) = state
.resolve(vta_did, ResolveOptions::default())
.await
.map_err(|e| format!("resolve did:webvh {vta_did}: {e}"))?;
let did_doc = log_entry
.get_did_document()
.map_err(|e| format!("render did:webvh document for {vta_did}: {e}"))?;
extract_x25519_keyagreement(&did_doc)
}
fn extract_x25519_keyagreement(
did_doc: &serde_json::Value,
) -> Result<(String, [u8; 32]), Box<dyn std::error::Error>> {
let did_id = did_doc
.get("id")
.and_then(|v| v.as_str())
.ok_or("DID document has no `id`")?;
let key_agreement = did_doc
.get("keyAgreement")
.and_then(|v| v.as_array())
.ok_or("DID document has no `keyAgreement` array")?;
for ka in key_agreement {
let vm = match ka {
serde_json::Value::String(reference) => {
resolve_vm_reference(did_doc, did_id, reference)?
}
serde_json::Value::Object(_) => ka.clone(),
_ => continue,
};
let kid = vm
.get("id")
.and_then(|v| v.as_str())
.ok_or("verificationMethod has no `id`")?
.to_string();
let Some(pk_mb) = vm.get("publicKeyMultibase").and_then(|v| v.as_str()) else {
continue;
};
let (_, bytes) =
multibase::decode(pk_mb).map_err(|e| format!("decode publicKeyMultibase: {e}"))?;
if bytes.len() == 34 && bytes[0] == 0xec && bytes[1] == 0x01 {
let mut x_pub = [0u8; 32];
x_pub.copy_from_slice(&bytes[2..]);
return Ok((kid, x_pub));
}
}
Err("no X25519 keyAgreement Multikey found on this DID".into())
}
fn resolve_vm_reference(
did_doc: &serde_json::Value,
did_id: &str,
reference: &str,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let target_id = if reference.starts_with('#') {
format!("{did_id}{reference}")
} else {
reference.to_string()
};
let vms = did_doc
.get("verificationMethod")
.and_then(|v| v.as_array())
.ok_or("DID document missing `verificationMethod` while resolving reference")?;
vms.iter()
.find(|vm| {
vm.get("id").and_then(|i| i.as_str()) == Some(target_id.as_str())
|| vm.get("id").and_then(|i| i.as_str()) == Some(reference)
})
.cloned()
.ok_or_else(|| {
format!("keyAgreement reference {reference} not in verificationMethod[]").into()
})
}
pub async fn pack_auth_message(
msg_type: &str,
body: serde_json::Value,
client_did: &str,
vta_did: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let plaintext = build_message(msg_type, body, client_did, vta_did);
let (kid, x_pub) = resolve_vta_keyagreement(vta_did).await?;
pack_anoncrypt(plaintext.as_bytes(), &x_pub, &kid)
}
#[cfg(test)]
mod tests {
use super::*;
fn x25519_multikey(bytes: &[u8; 32]) -> String {
let mut buf = Vec::with_capacity(34);
buf.extend_from_slice(&[0xec, 0x01]);
buf.extend_from_slice(bytes);
multibase::encode(multibase::Base::Base58Btc, &buf)
}
#[test]
fn extract_keyagreement_embedded_vm() {
let x = [7u8; 32];
let doc = serde_json::json!({
"id": "did:webvh:scid:example.com",
"keyAgreement": [{
"id": "did:webvh:scid:example.com#key-agreement-0",
"type": "Multikey",
"publicKeyMultibase": x25519_multikey(&x),
}],
});
let (kid, pub_bytes) = extract_x25519_keyagreement(&doc).unwrap();
assert_eq!(kid, "did:webvh:scid:example.com#key-agreement-0");
assert_eq!(pub_bytes, x);
}
#[test]
fn extract_keyagreement_by_reference() {
let x = [9u8; 32];
let doc = serde_json::json!({
"id": "did:webvh:scid:example.com",
"keyAgreement": ["#key-agreement-0"],
"verificationMethod": [{
"id": "did:webvh:scid:example.com#key-agreement-0",
"type": "Multikey",
"publicKeyMultibase": x25519_multikey(&x),
}],
});
let (kid, pub_bytes) = extract_x25519_keyagreement(&doc).unwrap();
assert_eq!(kid, "did:webvh:scid:example.com#key-agreement-0");
assert_eq!(pub_bytes, x);
}
#[test]
fn extract_keyagreement_skips_non_x25519_and_errors() {
let mut buf = vec![0xed, 0x01];
buf.extend_from_slice(&[1u8; 32]);
let doc = serde_json::json!({
"id": "did:webvh:scid:example.com",
"keyAgreement": [{
"id": "did:webvh:scid:example.com#wrong",
"type": "Multikey",
"publicKeyMultibase": multibase::encode(multibase::Base::Base58Btc, &buf),
}],
});
let err = extract_x25519_keyagreement(&doc).unwrap_err();
assert!(err.to_string().contains("no X25519 keyAgreement"), "{err}");
}
#[test]
fn extract_keyagreement_no_keyagreement_errors() {
let doc = serde_json::json!({ "id": "did:webvh:scid:example.com" });
let err = extract_x25519_keyagreement(&doc).unwrap_err();
assert!(err.to_string().contains("no `keyAgreement`"), "{err}");
}
#[tokio::test]
async fn resolve_keyagreement_did_key_no_io() {
let ed_pub = [42u8; 32];
let did = format!(
"did:key:{}",
crate::did_key::ed25519_multibase_pubkey(&ed_pub)
);
let (kid, x_pub) = resolve_vta_keyagreement(&did).await.unwrap();
assert!(kid.starts_with(&did));
assert_eq!(x_pub, ed25519_pub_to_x25519_pub(&ed_pub).unwrap());
}
#[tokio::test]
async fn resolve_keyagreement_rejects_unsupported_method() {
let err = resolve_vta_keyagreement("did:web:example.com")
.await
.unwrap_err();
assert!(
err.to_string().contains("unsupported VTA DID method"),
"{err}"
);
}
#[test]
fn test_parse_did_key_ed25519() {
let pub_bytes = [42u8; 32];
let did = format!(
"did:key:{}",
crate::did_key::ed25519_multibase_pubkey(&pub_bytes)
);
let parsed = parse_did_key_ed25519(&did).unwrap();
assert_eq!(parsed, pub_bytes);
}
#[test]
fn test_ed25519_to_x25519_conversion() {
let signing_key = ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]);
let verifying_key = signing_key.verifying_key();
let ed_pub = verifying_key.to_bytes();
let x_pub = ed25519_pub_to_x25519_pub(&ed_pub).unwrap();
assert_ne!(x_pub, [0u8; 32]); }
#[test]
fn test_did_key_agreement_kid() {
let pub_bytes = [42u8; 32];
let did = format!(
"did:key:{}",
crate::did_key::ed25519_multibase_pubkey(&pub_bytes)
);
let kid = did_key_agreement_kid(&did).unwrap();
assert!(kid.starts_with(&did));
assert!(kid.contains('#'));
let fragment = kid.split('#').nth(1).unwrap();
assert!(fragment.starts_with('z'));
}
#[test]
fn test_pack_anoncrypt_produces_valid_jwe() {
use affinidi_crypto::jose::key_agreement::{Curve, PrivateKeyAgreement};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B64};
let recipient_private = PrivateKeyAgreement::generate(Curve::X25519);
let recipient_pub_bytes = match recipient_private.public_key() {
affinidi_crypto::jose::key_agreement::PublicKeyAgreement::X25519(b) => b,
_ => unreachable!("we asked for X25519"),
};
let jwe_str =
pack_anoncrypt(b"hello world", &recipient_pub_bytes, "did:key:test#key-1").unwrap();
let jwe: serde_json::Value = serde_json::from_str(&jwe_str).unwrap();
assert!(jwe["protected"].is_string());
assert!(jwe["recipients"].is_array());
assert_eq!(jwe["recipients"].as_array().unwrap().len(), 1);
assert!(jwe["iv"].is_string());
assert!(jwe["ciphertext"].is_string());
assert!(jwe["tag"].is_string());
let protected_json: serde_json::Value =
serde_json::from_slice(&B64.decode(jwe["protected"].as_str().unwrap()).unwrap())
.unwrap();
assert_eq!(protected_json["alg"], "ECDH-ES+A256KW");
assert_eq!(protected_json["enc"], "A256CBC-HS512");
assert_eq!(protected_json["typ"], "application/didcomm-encrypted+json");
let decrypted = affinidi_messaging_didcomm::jwe::decrypt::decrypt(
&jwe_str,
"did:key:test#key-1",
&recipient_private,
None, )
.expect("decrypt round-trip");
assert_eq!(decrypted.plaintext, b"hello world");
assert!(!decrypted.authenticated, "anoncrypt is not authenticated");
}
#[test]
fn test_build_message_structure() {
let msg = build_message(
"https://example.com/test",
serde_json::json!({"foo": "bar"}),
"did:key:sender",
"did:key:recipient",
);
let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
assert_eq!(parsed["type"], "https://example.com/test");
assert_eq!(parsed["from"], "did:key:sender");
assert_eq!(parsed["to"][0], "did:key:recipient");
assert_eq!(parsed["body"]["foo"], "bar");
assert!(parsed["id"].is_string());
}
}