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}