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}