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    ///
128    /// Built by mutating a `Default` rather than with a struct expression:
129    /// `UnpackMetadata` is `#[non_exhaustive]` as of affinidi-messaging-didcomm
130    /// 0.15.8, which bars the literal form from outside its own crate — `..`
131    /// does not exempt it. Field-at-a-time is also the shape that survives the
132    /// upstream adding another field, which is the point of the attribute.
133    fn meta(encrypted: bool, authenticated: bool, kid: Option<&str>) -> UnpackMetadata {
134        let mut meta = UnpackMetadata::default();
135        meta.encrypted = encrypted;
136        meta.authenticated = authenticated;
137        meta.encrypted_from_kid = kid.map(str::to_string);
138        meta
139    }
140
141    /// Happy path: authcrypt with a `from` matching the authenticated key's DID
142    /// returns the bound base DID (fragment stripped on both sides).
143    #[test]
144    fn binds_matching_sender() {
145        assert_eq!(
146            bind_authcrypt_sender(&msg(Some(DID)), &meta(true, true, Some(KID))),
147            Ok(DID.to_string()),
148        );
149        // `from` may itself carry a fragment; still binds to the base DID.
150        assert_eq!(
151            bind_authcrypt_sender(&msg(Some(KID)), &meta(true, true, Some(KID))),
152            Ok(DID.to_string()),
153        );
154    }
155
156    /// The core forged-sender case: authenticated (attacker's own key) but the
157    /// plaintext `from` claims a different DID → `Mismatch`, never bound to the
158    /// claimed DID.
159    #[test]
160    fn rejects_sender_mismatch() {
161        let err = bind_authcrypt_sender(
162            &msg(Some("did:key:z6MkAdminVictim")),
163            &meta(true, true, Some("did:key:z6MkAttacker#z6MkAttacker")),
164        )
165        .expect_err("forged from must be rejected");
166        assert_eq!(
167            err,
168            AuthcryptError::Mismatch {
169                claimed: "did:key:z6MkAdminVictim".to_string(),
170                authenticated: "did:key:z6MkAttacker".to_string(),
171            }
172        );
173        // And the rendered message names both DIDs.
174        let msg = err.message("authenticate message");
175        assert!(
176            msg.contains("z6MkAdminVictim") && msg.contains("z6MkAttacker"),
177            "got: {msg}"
178        );
179    }
180
181    /// A plaintext envelope (both flags false) is `NotAuthcrypt`, before the
182    /// sender is even considered.
183    #[test]
184    fn rejects_plaintext() {
185        assert_eq!(
186            bind_authcrypt_sender(&msg(Some(DID)), &meta(false, false, Some(KID))),
187            Err(AuthcryptError::NotAuthcrypt),
188        );
189    }
190
191    /// Anoncrypt (encrypted but not authenticated) is `NotAuthcrypt`: no proven
192    /// sender.
193    #[test]
194    fn rejects_anoncrypt() {
195        assert_eq!(
196            bind_authcrypt_sender(&msg(None), &meta(true, false, None)),
197            Err(AuthcryptError::NotAuthcrypt),
198        );
199    }
200
201    /// Authcrypt but the metadata carries no sender key id — `NoSenderKey`,
202    /// rather than falling back to trusting `from`.
203    #[test]
204    fn rejects_missing_sender_key() {
205        assert_eq!(
206            bind_authcrypt_sender(&msg(Some(DID)), &meta(true, true, None)),
207            Err(AuthcryptError::NoSenderKey),
208        );
209    }
210
211    /// Authcrypt with a proven key but no `from` header — `NoFrom`.
212    #[test]
213    fn rejects_missing_from() {
214        assert_eq!(
215            bind_authcrypt_sender(&msg(None), &meta(true, true, Some(KID))),
216            Err(AuthcryptError::NoFrom),
217        );
218    }
219
220    /// Each variant renders a distinct, subject-tagged message.
221    #[test]
222    fn messages_are_subject_tagged() {
223        assert!(
224            AuthcryptError::NotAuthcrypt
225                .message("sealed secret")
226                .starts_with("sealed secret must be an authenticated")
227        );
228        assert!(
229            AuthcryptError::NoFrom
230                .message("refresh message")
231                .contains("no sender")
232        );
233    }
234}