use affinidi_tdk::didcomm::Message;
use affinidi_tdk::messaging::config::MessageWrappingType;
use affinidi_tdk::messaging::messages::compat::UnpackMetadata;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AuthcryptError {
NotAuthcrypt,
MalformedEnvelope(String),
InvalidSenderKeyId(String),
MissingApu,
ApuMismatch { skid: String, apu: String },
UnsupportedWrapping(String),
NoSenderKey,
SenderKeyMismatch { header: String, metadata: String },
NoFrom,
Mismatch {
claimed: String,
authenticated: String,
},
}
impl AuthcryptError {
pub fn message(&self, subject: &str) -> String {
match self {
AuthcryptError::NotAuthcrypt => {
format!("{subject} must be an authenticated (authcrypt) DIDComm envelope")
}
AuthcryptError::MalformedEnvelope(why) => {
format!("{subject} is not a well-formed DIDComm encrypted envelope: {why}")
}
AuthcryptError::InvalidSenderKeyId(why) => {
format!("{subject} authcrypt header has no usable sender key id: {why}")
}
AuthcryptError::MissingApu => {
format!("{subject} authcrypt header has no apu")
}
AuthcryptError::ApuMismatch { skid, apu } => format!(
"{subject} authcrypt header is inconsistent: apu `{apu}` does not encode skid `{skid}`"
),
AuthcryptError::UnsupportedWrapping(wrapping) => format!(
"{subject} must be authcrypt(plaintext) or authcrypt(sign(plaintext)), got {wrapping}"
),
AuthcryptError::NoSenderKey => {
format!("{subject} is authcrypt but carries no authenticated sender key")
}
AuthcryptError::SenderKeyMismatch { header, metadata } => format!(
"{subject} sender key mismatch: header skid `{header}` is not the authenticated key `{metadata}`"
),
AuthcryptError::NoFrom => format!("{subject} has no sender (from)"),
AuthcryptError::Mismatch {
claimed,
authenticated,
} => format!(
"{subject} sender mismatch: plaintext from `{claimed}` does not match the authenticated sender `{authenticated}`"
),
}
}
}
pub fn verify_authcrypt_header(raw_jwe: &str) -> Result<String, AuthcryptError> {
let raw = raw_jwe.trim();
let protected_b64 = if raw.starts_with('{') {
let value: serde_json::Value = serde_json::from_str(raw)
.map_err(|e| AuthcryptError::MalformedEnvelope(format!("not JSON: {e}")))?;
if value.get("ciphertext").is_none() {
return Err(AuthcryptError::NotAuthcrypt);
}
value
.get("protected")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| AuthcryptError::MalformedEnvelope("JWE has no protected header".into()))?
.to_string()
} else {
let parts: Vec<&str> = raw.split('.').collect();
if parts.len() != 5 {
return Err(AuthcryptError::MalformedEnvelope(
"neither JWE JSON nor JWE compact serialization".into(),
));
}
parts[0].to_string()
};
let protected = URL_SAFE_NO_PAD
.decode(protected_b64.as_bytes())
.map_err(|e| AuthcryptError::MalformedEnvelope(format!("protected header: {e}")))?;
let header: serde_json::Value = serde_json::from_slice(&protected)
.map_err(|e| AuthcryptError::MalformedEnvelope(format!("protected header: {e}")))?;
let header = header.as_object().ok_or_else(|| {
AuthcryptError::MalformedEnvelope("protected header is not a JSON object".into())
})?;
let alg = header
.get("alg")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| AuthcryptError::MalformedEnvelope("protected header has no alg".into()))?;
if !alg.contains("1PU") {
return Err(AuthcryptError::NotAuthcrypt);
}
let skid = match header.get("skid") {
Some(serde_json::Value::String(s)) => s.clone(),
Some(_) => {
return Err(AuthcryptError::InvalidSenderKeyId(
"skid is not a string".into(),
));
}
None => return Err(AuthcryptError::InvalidSenderKeyId("skid is absent".into())),
};
match skid.split_once('#') {
Some((did, fragment)) if did.starts_with("did:") && !fragment.is_empty() => {}
_ => {
return Err(AuthcryptError::InvalidSenderKeyId(format!(
"skid `{skid}` is not a DID URL with a key fragment"
)));
}
}
let apu = match header.get("apu") {
Some(serde_json::Value::String(s)) => s.as_str(),
Some(_) => {
return Err(AuthcryptError::ApuMismatch {
skid,
apu: "<not a string>".into(),
});
}
None => return Err(AuthcryptError::MissingApu),
};
let decoded = URL_SAFE_NO_PAD.decode(apu.as_bytes()).ok();
if decoded.as_deref() != Some(skid.as_bytes()) || URL_SAFE_NO_PAD.encode(&skid) != apu {
let apu = decoded
.map(|b| String::from_utf8_lossy(&b).into_owned())
.unwrap_or_else(|| apu.to_string());
return Err(AuthcryptError::ApuMismatch { skid, apu });
}
Ok(skid)
}
pub fn bind_authcrypt_sender(
raw_jwe: &str,
message: &Message,
metadata: &UnpackMetadata,
) -> Result<String, AuthcryptError> {
if !(metadata.encrypted && metadata.authenticated) {
return Err(AuthcryptError::NotAuthcrypt);
}
let skid = verify_authcrypt_header(raw_jwe)?;
match metadata.wrapping {
MessageWrappingType::AuthcryptPlaintext | MessageWrappingType::AuthcryptSignPlaintext => {}
other => return Err(AuthcryptError::UnsupportedWrapping(format!("{other:?}"))),
}
let kid = metadata
.encrypted_from_kid
.as_deref()
.ok_or(AuthcryptError::NoSenderKey)?;
if kid != skid {
return Err(AuthcryptError::SenderKeyMismatch {
header: skid,
metadata: kid.to_string(),
});
}
let key_did = base_did(&skid);
match message.from.as_deref().map(base_did) {
Some(from_did) if from_did == key_did => Ok(key_did.to_string()),
Some(from_did) => Err(AuthcryptError::Mismatch {
claimed: from_did.to_string(),
authenticated: key_did.to_string(),
}),
None => Err(AuthcryptError::NoFrom),
}
}
fn base_did(did: &str) -> &str {
did.split_once('#').map(|(base, _)| base).unwrap_or(did)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const DID: &str = "did:key:z6MkSender";
const KID: &str = "did:key:z6MkSender#z6LSSender";
const VICTIM_KID: &str = "did:key:z6MkAdminVictim#z6LSAdminVictim";
fn msg(from: Option<&str>) -> Message {
let builder = Message::build(
"urn:uuid:test".to_string(),
"https://example.org/test/1.0".to_string(),
json!({}),
);
match from {
Some(f) => builder.from(f.to_string()).finalize(),
None => builder.finalize(),
}
}
fn meta(encrypted: bool, authenticated: bool, kid: Option<&str>) -> UnpackMetadata {
let mut meta = UnpackMetadata::default();
meta.encrypted = encrypted;
meta.authenticated = authenticated;
meta.encrypted_from_kid = kid.map(str::to_string);
meta.wrapping = match (encrypted, authenticated) {
(true, true) => MessageWrappingType::AuthcryptPlaintext,
(true, false) => MessageWrappingType::AnoncryptPlaintext,
_ => MessageWrappingType::Plaintext,
};
meta
}
fn jwe(header: serde_json::Value) -> String {
json!({
"protected": URL_SAFE_NO_PAD.encode(header.to_string()),
"recipients": [{ "header": { "kid": "did:key:z6MkVta#z6LSVta" }, "encrypted_key": "AA" }],
"iv": "AA",
"ciphertext": "AA",
"tag": "AA",
})
.to_string()
}
fn authcrypt_header(skid: Option<&str>, apu: Option<&str>) -> serde_json::Value {
let mut h =
json!({ "alg": "ECDH-1PU+A256KW", "enc": "A256CBC-HS512", "apv": "AA", "epk": {} });
if let Some(s) = skid {
h["skid"] = json!(s);
}
if let Some(a) = apu {
h["apu"] = json!(URL_SAFE_NO_PAD.encode(a));
}
h
}
fn good_jwe(kid: &str) -> String {
jwe(authcrypt_header(Some(kid), Some(kid)))
}
#[test]
fn binds_matching_sender() {
assert_eq!(
bind_authcrypt_sender(
&good_jwe(KID),
&msg(Some(DID)),
&meta(true, true, Some(KID))
),
Ok(DID.to_string()),
);
assert_eq!(
bind_authcrypt_sender(
&good_jwe(KID),
&msg(Some(KID)),
&meta(true, true, Some(KID))
),
Ok(DID.to_string()),
);
let mut m = meta(true, true, Some(KID));
m.wrapping = MessageWrappingType::AuthcryptSignPlaintext;
assert_eq!(
bind_authcrypt_sender(&good_jwe(KID), &msg(Some(DID)), &m),
Ok(DID.to_string()),
);
}
#[test]
fn rejects_skid_apu_split() {
const ATTACKER_KID: &str = "did:key:z6MkAttacker#z6LSAttacker";
let raw = jwe(authcrypt_header(Some(ATTACKER_KID), Some(VICTIM_KID)));
let err = bind_authcrypt_sender(
&raw,
&msg(Some("did:key:z6MkAdminVictim")),
&meta(true, true, Some(VICTIM_KID)),
)
.expect_err("skid/apu split must be refused");
assert_eq!(
err,
AuthcryptError::ApuMismatch {
skid: ATTACKER_KID.to_string(),
apu: VICTIM_KID.to_string(),
}
);
assert_eq!(verify_authcrypt_header(&raw), Err(err));
}
#[test]
fn rejects_metadata_key_other_than_header_skid() {
assert_eq!(
bind_authcrypt_sender(
&good_jwe(KID),
&msg(Some("did:key:z6MkAdminVictim")),
&meta(true, true, Some(VICTIM_KID)),
),
Err(AuthcryptError::SenderKeyMismatch {
header: KID.to_string(),
metadata: VICTIM_KID.to_string(),
}),
);
}
#[test]
fn rejects_sender_mismatch() {
const ATTACKER_KID: &str = "did:key:z6MkAttacker#z6MkAttacker";
let err = bind_authcrypt_sender(
&good_jwe(ATTACKER_KID),
&msg(Some("did:key:z6MkAdminVictim")),
&meta(true, true, Some(ATTACKER_KID)),
)
.expect_err("forged from must be rejected");
assert_eq!(
err,
AuthcryptError::Mismatch {
claimed: "did:key:z6MkAdminVictim".to_string(),
authenticated: "did:key:z6MkAttacker".to_string(),
}
);
let msg = err.message("authenticate message");
assert!(
msg.contains("z6MkAdminVictim") && msg.contains("z6MkAttacker"),
"got: {msg}"
);
}
#[test]
fn rejects_missing_skid() {
let raw = jwe(authcrypt_header(None, Some(KID)));
assert!(matches!(
verify_authcrypt_header(&raw),
Err(AuthcryptError::InvalidSenderKeyId(_))
));
}
#[test]
fn rejects_non_string_skid() {
let mut h = authcrypt_header(None, Some(KID));
h["skid"] = json!(42);
assert!(matches!(
verify_authcrypt_header(&jwe(h)),
Err(AuthcryptError::InvalidSenderKeyId(_))
));
}
#[test]
fn rejects_bare_did_skid() {
for bare in [DID, "did:key:z6MkSender#", "z6LSNotADid#frag"] {
let raw = jwe(authcrypt_header(Some(bare), Some(bare)));
assert!(
matches!(
verify_authcrypt_header(&raw),
Err(AuthcryptError::InvalidSenderKeyId(_))
),
"{bare} must be refused"
);
}
}
#[test]
fn rejects_missing_apu() {
let raw = jwe(authcrypt_header(Some(KID), None));
assert_eq!(
verify_authcrypt_header(&raw),
Err(AuthcryptError::MissingApu)
);
}
#[test]
fn rejects_near_miss_apu() {
for apu in [&KID[..KID.len() - 1], &format!("{KID}x"), DID] {
let raw = jwe(authcrypt_header(Some(KID), Some(apu)));
assert!(
matches!(
verify_authcrypt_header(&raw),
Err(AuthcryptError::ApuMismatch { .. })
),
"apu {apu} must be refused"
);
}
let mut h = authcrypt_header(Some(KID), None);
h["apu"] = json!(base64::engine::general_purpose::URL_SAFE.encode(KID));
if h["apu"].as_str().unwrap().ends_with('=') {
assert!(matches!(
verify_authcrypt_header(&jwe(h)),
Err(AuthcryptError::ApuMismatch { .. })
));
}
let mut h = authcrypt_header(Some(KID), None);
h["apu"] = json!(["x"]);
assert!(matches!(
verify_authcrypt_header(&jwe(h)),
Err(AuthcryptError::ApuMismatch { .. })
));
}
#[test]
fn reads_compact_serialization() {
let h = URL_SAFE_NO_PAD.encode(authcrypt_header(Some(KID), Some(KID)).to_string());
assert_eq!(
verify_authcrypt_header(&format!("{h}.AA.AA.AA.AA")),
Ok(KID.to_string())
);
let bad = URL_SAFE_NO_PAD.encode(authcrypt_header(Some(KID), Some(VICTIM_KID)).to_string());
assert!(verify_authcrypt_header(&format!("{bad}.AA.AA.AA.AA")).is_err());
}
#[test]
fn rejects_anoncrypt_outer_and_nested_authcrypt() {
let raw =
jwe(json!({ "alg": "ECDH-ES+A256KW", "enc": "A256CBC-HS512", "apv": "AA", "epk": {} }));
assert_eq!(
verify_authcrypt_header(&raw),
Err(AuthcryptError::NotAuthcrypt)
);
let mut m = meta(true, true, Some(KID));
m.wrapping = MessageWrappingType::AnoncryptAuthcryptPlaintext;
assert_eq!(
bind_authcrypt_sender(&raw, &msg(Some(DID)), &m),
Err(AuthcryptError::NotAuthcrypt),
);
assert!(matches!(
bind_authcrypt_sender(&good_jwe(KID), &msg(Some(DID)), &m),
Err(AuthcryptError::UnsupportedWrapping(_))
));
}
#[test]
fn rejects_malformed_envelopes() {
for raw in ["", "not a jwe", "{", "a.b.c", "!!!.AA.AA.AA.AA"] {
assert!(verify_authcrypt_header(raw).is_err(), "{raw:?}");
}
let no_protected = json!({ "recipients": [], "ciphertext": "AA" }).to_string();
assert!(matches!(
verify_authcrypt_header(&no_protected),
Err(AuthcryptError::MalformedEnvelope(_))
));
}
#[test]
fn rejects_plaintext() {
let plaintext = json!({ "type": "x", "from": DID, "body": {} }).to_string();
assert_eq!(
bind_authcrypt_sender(&plaintext, &msg(Some(DID)), &meta(false, false, Some(KID))),
Err(AuthcryptError::NotAuthcrypt),
);
assert_eq!(
verify_authcrypt_header(&plaintext),
Err(AuthcryptError::NotAuthcrypt)
);
}
#[test]
fn rejects_anoncrypt() {
assert_eq!(
bind_authcrypt_sender(&good_jwe(KID), &msg(None), &meta(true, false, None)),
Err(AuthcryptError::NotAuthcrypt),
);
}
#[test]
fn rejects_missing_sender_key() {
assert_eq!(
bind_authcrypt_sender(&good_jwe(KID), &msg(Some(DID)), &meta(true, true, None)),
Err(AuthcryptError::NoSenderKey),
);
}
#[test]
fn rejects_missing_from() {
assert_eq!(
bind_authcrypt_sender(&good_jwe(KID), &msg(None), &meta(true, true, Some(KID))),
Err(AuthcryptError::NoFrom),
);
}
#[test]
fn real_envelopes() {
use super::super::authcrypt_test_support::{
DidKeyParty, forge_authcrypt, genuine_authcrypt,
};
use affinidi_tdk::didcomm::jwe::decrypt::{SenderKey, decrypt_bound};
let attacker = DidKeyParty::from_seed([1; 32]);
let victim = DidKeyParty::from_seed([2; 32]);
let vta = DidKeyParty::from_seed([3; 32]);
let vta_pub = vta.public();
let forged = forge_authcrypt(b"{}", &attacker, &victim.kid, (&vta.kid, &vta_pub));
let attacker_pub = attacker.public();
assert!(
decrypt_bound(
&forged,
&vta.kid,
&vta.private(),
Some(SenderKey::new(&attacker.kid, &attacker_pub)),
)
.is_err(),
"the library refuses a JWE whose apu is not its skid"
);
assert!(matches!(
verify_authcrypt_header(&forged),
Err(AuthcryptError::ApuMismatch { .. })
));
let genuine = genuine_authcrypt(b"{}", &victim, (&vta.kid, &vta_pub));
let victim_pub = victim.public();
let decrypted = decrypt_bound(
&genuine,
&vta.kid,
&vta.private(),
Some(SenderKey::new(&victim.kid, &victim_pub)),
)
.expect("a genuine envelope decrypts");
assert_eq!(decrypted.sender_kid.as_deref(), Some(victim.kid.as_str()));
assert_eq!(verify_authcrypt_header(&genuine), Ok(victim.kid.clone()));
}
#[test]
fn messages_are_subject_tagged() {
assert!(
AuthcryptError::NotAuthcrypt
.message("sealed secret")
.starts_with("sealed secret must be an authenticated")
);
assert!(
AuthcryptError::NoFrom
.message("refresh message")
.contains("no sender")
);
}
}