use affinidi_tdk::didcomm::Message;
use affinidi_tdk::messaging::messages::compat::UnpackMetadata;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthcryptError {
NotAuthcrypt,
NoSenderKey,
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::NoSenderKey => {
format!("{subject} is authcrypt but carries no authenticated sender key")
}
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 bind_authcrypt_sender(
message: &Message,
metadata: &UnpackMetadata,
) -> Result<String, AuthcryptError> {
if !(metadata.encrypted && metadata.authenticated) {
return Err(AuthcryptError::NotAuthcrypt);
}
let kid = metadata
.encrypted_from_kid
.as_deref()
.ok_or(AuthcryptError::NoSenderKey)?;
let key_did = base_did(kid);
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#z6MkSender";
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 {
UnpackMetadata {
encrypted,
authenticated,
encrypted_from_kid: kid.map(str::to_string),
..Default::default()
}
}
#[test]
fn binds_matching_sender() {
assert_eq!(
bind_authcrypt_sender(&msg(Some(DID)), &meta(true, true, Some(KID))),
Ok(DID.to_string()),
);
assert_eq!(
bind_authcrypt_sender(&msg(Some(KID)), &meta(true, true, Some(KID))),
Ok(DID.to_string()),
);
}
#[test]
fn rejects_sender_mismatch() {
let err = bind_authcrypt_sender(
&msg(Some("did:key:z6MkAdminVictim")),
&meta(true, true, Some("did:key:z6MkAttacker#z6MkAttacker")),
)
.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_plaintext() {
assert_eq!(
bind_authcrypt_sender(&msg(Some(DID)), &meta(false, false, Some(KID))),
Err(AuthcryptError::NotAuthcrypt),
);
}
#[test]
fn rejects_anoncrypt() {
assert_eq!(
bind_authcrypt_sender(&msg(None), &meta(true, false, None)),
Err(AuthcryptError::NotAuthcrypt),
);
}
#[test]
fn rejects_missing_sender_key() {
assert_eq!(
bind_authcrypt_sender(&msg(Some(DID)), &meta(true, true, None)),
Err(AuthcryptError::NoSenderKey),
);
}
#[test]
fn rejects_missing_from() {
assert_eq!(
bind_authcrypt_sender(&msg(None), &meta(true, true, Some(KID))),
Err(AuthcryptError::NoFrom),
);
}
#[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")
);
}
}