Skip to main content

vti_common/auth/
didcomm.rs

1//! Shared authentication guard for just-unpacked DIDComm envelopes.
2//!
3//! `ATM::unpack` (affinidi-messaging-sdk) authenticates the JWE `skid` sender
4//! key and surfaces it as `encrypted_from_kid`, but it does **not** compare that
5//! key's DID to the inner plaintext `from` header — nor does it reject
6//! plaintext / anoncrypt envelopes. Any handler that consumes `atm.unpack`
7//! directly and then trusts `msg.from` as a proven signer is therefore open to
8//! the authentication-bypass class: an attacker authcrypts with their
9//! *own* key (so `encrypted` and `authenticated` are both true) while claiming a
10//! victim's `from`, and the handler mistakes them for the victim.
11//!
12//! The DIDComm *transport* path already binds the two — its `to_inbound` only
13//! surfaces a sender when `from == encrypted_from_kid`'s DID. [`bind_authcrypt_sender`]
14//! gives the direct-`unpack` callers (the REST `/auth/*` handlers, vault unseal)
15//! the same guarantee in one call: it does the authcrypt gate **and** the
16//! `from == skid` binding, returning the proven sender or a typed
17//! [`AuthcryptError`]. It takes the just-unpacked `(message, metadata)` pair
18//! directly, so a caller never re-derives `from` or the flags by hand.
19
20use affinidi_tdk::didcomm::Message;
21use affinidi_tdk::messaging::messages::compat::UnpackMetadata;
22
23/// Why binding an authcrypt sender failed. Auth handlers render it with
24/// [`AuthcryptError::message`]; the vault path matches specific variants to map
25/// onto its own error type.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum AuthcryptError {
28    /// Not a sender-authenticated encrypted (authcrypt) envelope — the message
29    /// was plaintext or anoncrypt, so its `from` is unauthenticated.
30    NotAuthcrypt,
31    /// Authcrypt, but the unpack metadata carried no authenticated sender key.
32    NoSenderKey,
33    /// The inner message carried no `from` header.
34    NoFrom,
35    /// The inner `from` did not match the DID of the authenticated sender key —
36    /// the core forged-sender case (attacker authcrypts with their own key while
37    /// claiming a victim's `from`).
38    Mismatch {
39        claimed: String,
40        authenticated: String,
41    },
42}
43
44impl AuthcryptError {
45    /// A one-line human message for `subject` (e.g. `"authenticate message"`,
46    /// `"sealed secret"`).
47    pub fn message(&self, subject: &str) -> String {
48        match self {
49            AuthcryptError::NotAuthcrypt => {
50                format!("{subject} must be an authenticated (authcrypt) DIDComm envelope")
51            }
52            AuthcryptError::NoSenderKey => {
53                format!("{subject} is authcrypt but carries no authenticated sender key")
54            }
55            AuthcryptError::NoFrom => format!("{subject} has no sender (from)"),
56            AuthcryptError::Mismatch {
57                claimed,
58                authenticated,
59            } => format!(
60                "{subject} sender mismatch: plaintext from `{claimed}` does not match the authenticated sender `{authenticated}`"
61            ),
62        }
63    }
64}
65
66/// Gate an unpacked envelope as authcrypt **and** verify its plaintext `from`
67/// matches the DID of the key that actually authenticated it, returning that
68/// cryptographically-bound sender DID (with any `#fragment` stripped).
69///
70/// This is the single entry point for direct-`unpack` callers: pass the
71/// `(message, metadata)` pair straight from `atm.unpack` and it folds the
72/// authcrypt requirement and the addressing-consistency check into one call.
73/// The encrypted/authenticated flags and the authenticated sender key id come
74/// from `metadata`; the claimed `from` comes from the decrypted `message`.
75/// Callers pass the returned (proven) sender on to authorization; they must
76/// **not** trust `message.from` on their own. See the module docs.
77pub fn bind_authcrypt_sender(
78    message: &Message,
79    metadata: &UnpackMetadata,
80) -> Result<String, AuthcryptError> {
81    if !(metadata.encrypted && metadata.authenticated) {
82        return Err(AuthcryptError::NotAuthcrypt);
83    }
84    let kid = metadata
85        .encrypted_from_kid
86        .as_deref()
87        .ok_or(AuthcryptError::NoSenderKey)?;
88    let key_did = base_did(kid);
89
90    match message.from.as_deref().map(base_did) {
91        Some(from_did) if from_did == key_did => Ok(key_did.to_string()),
92        Some(from_did) => Err(AuthcryptError::Mismatch {
93            claimed: from_did.to_string(),
94            authenticated: key_did.to_string(),
95        }),
96        None => Err(AuthcryptError::NoFrom),
97    }
98}
99
100/// Strip a `#fragment` from a DID / kid, returning the base DID.
101fn base_did(did: &str) -> &str {
102    did.split_once('#').map(|(base, _)| base).unwrap_or(did)
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use serde_json::json;
109
110    const DID: &str = "did:key:z6MkSender";
111    const KID: &str = "did:key:z6MkSender#z6MkSender";
112
113    /// A minimal unpacked `Message` carrying (or omitting) a `from` header.
114    fn msg(from: Option<&str>) -> Message {
115        let builder = Message::build(
116            "urn:uuid:test".to_string(),
117            "https://example.org/test/1.0".to_string(),
118            json!({}),
119        );
120        match from {
121            Some(f) => builder.from(f.to_string()).finalize(),
122            None => builder.finalize(),
123        }
124    }
125
126    /// Unpack metadata with the given authcrypt flags + sender key id.
127    fn meta(encrypted: bool, authenticated: bool, kid: Option<&str>) -> UnpackMetadata {
128        UnpackMetadata {
129            encrypted,
130            authenticated,
131            encrypted_from_kid: kid.map(str::to_string),
132            ..Default::default()
133        }
134    }
135
136    /// Happy path: authcrypt with a `from` matching the authenticated key's DID
137    /// returns the bound base DID (fragment stripped on both sides).
138    #[test]
139    fn binds_matching_sender() {
140        assert_eq!(
141            bind_authcrypt_sender(&msg(Some(DID)), &meta(true, true, Some(KID))),
142            Ok(DID.to_string()),
143        );
144        // `from` may itself carry a fragment; still binds to the base DID.
145        assert_eq!(
146            bind_authcrypt_sender(&msg(Some(KID)), &meta(true, true, Some(KID))),
147            Ok(DID.to_string()),
148        );
149    }
150
151    /// The core forged-sender case: authenticated (attacker's own key) but the
152    /// plaintext `from` claims a different DID → `Mismatch`, never bound to the
153    /// claimed DID.
154    #[test]
155    fn rejects_sender_mismatch() {
156        let err = bind_authcrypt_sender(
157            &msg(Some("did:key:z6MkAdminVictim")),
158            &meta(true, true, Some("did:key:z6MkAttacker#z6MkAttacker")),
159        )
160        .expect_err("forged from must be rejected");
161        assert_eq!(
162            err,
163            AuthcryptError::Mismatch {
164                claimed: "did:key:z6MkAdminVictim".to_string(),
165                authenticated: "did:key:z6MkAttacker".to_string(),
166            }
167        );
168        // And the rendered message names both DIDs.
169        let msg = err.message("authenticate message");
170        assert!(
171            msg.contains("z6MkAdminVictim") && msg.contains("z6MkAttacker"),
172            "got: {msg}"
173        );
174    }
175
176    /// A plaintext envelope (both flags false) is `NotAuthcrypt`, before the
177    /// sender is even considered.
178    #[test]
179    fn rejects_plaintext() {
180        assert_eq!(
181            bind_authcrypt_sender(&msg(Some(DID)), &meta(false, false, Some(KID))),
182            Err(AuthcryptError::NotAuthcrypt),
183        );
184    }
185
186    /// Anoncrypt (encrypted but not authenticated) is `NotAuthcrypt`: no proven
187    /// sender.
188    #[test]
189    fn rejects_anoncrypt() {
190        assert_eq!(
191            bind_authcrypt_sender(&msg(None), &meta(true, false, None)),
192            Err(AuthcryptError::NotAuthcrypt),
193        );
194    }
195
196    /// Authcrypt but the metadata carries no sender key id — `NoSenderKey`,
197    /// rather than falling back to trusting `from`.
198    #[test]
199    fn rejects_missing_sender_key() {
200        assert_eq!(
201            bind_authcrypt_sender(&msg(Some(DID)), &meta(true, true, None)),
202            Err(AuthcryptError::NoSenderKey),
203        );
204    }
205
206    /// Authcrypt with a proven key but no `from` header — `NoFrom`.
207    #[test]
208    fn rejects_missing_from() {
209        assert_eq!(
210            bind_authcrypt_sender(&msg(None), &meta(true, true, Some(KID))),
211            Err(AuthcryptError::NoFrom),
212        );
213    }
214
215    /// Each variant renders a distinct, subject-tagged message.
216    #[test]
217    fn messages_are_subject_tagged() {
218        assert!(
219            AuthcryptError::NotAuthcrypt
220                .message("sealed secret")
221                .starts_with("sealed secret must be an authenticated")
222        );
223        assert!(
224            AuthcryptError::NoFrom
225                .message("refresh message")
226                .contains("no sender")
227        );
228    }
229}