dpop_verifier/
nonce.rs

1//! Stateless DPoP-Nonce issuance & verification using HMAC-SHA256.
2//!
3//! Nonce format (binary, then base64url(no-pad)):
4//!   version(1) || ts_be(8) || rand(16) || mac(16)
5//!
6//! mac = HMAC-SHA256(secret, version || ts || rand || ctx_bytes)[..16]
7//! ctx_bytes = concatenation of tagged optional fields:
8//!   b"HTU\0" + htu + b"\0"  (if provided)
9//!   b"HTM\0" + htm + b"\0"  (if provided)
10//!   b"JKT\0" + jkt + b"\0"  (if provided)
11
12use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
13use base64::Engine;
14use hmac::{Hmac, Mac};
15use rand_core::{OsRng, RngCore};
16use secrecy::{ExposeSecret, SecretBox};
17use sha2::Sha256;
18use subtle::ConstantTimeEq;
19
20use crate::DpopError;
21
22/// Helper trait to convert various secret types into `SecretBox<[u8]>`.
23/// This allows functions to accept both `SecretBox<[u8]>` and non-boxed types like `&[u8]` or `Vec<u8>`.
24pub trait IntoSecretBox {
25    fn into_secret_box(self) -> SecretBox<[u8]>;
26}
27
28impl IntoSecretBox for SecretBox<[u8]> {
29    fn into_secret_box(self) -> SecretBox<[u8]> {
30        self
31    }
32}
33
34impl IntoSecretBox for &SecretBox<[u8]> {
35    fn into_secret_box(self) -> SecretBox<[u8]> {
36        self.clone()
37    }
38}
39
40impl IntoSecretBox for &[u8] {
41    fn into_secret_box(self) -> SecretBox<[u8]> {
42        SecretBox::from(self.to_vec())
43    }
44}
45
46impl<const N: usize> IntoSecretBox for &[u8; N] {
47    fn into_secret_box(self) -> SecretBox<[u8]> {
48        SecretBox::from(self.to_vec())
49    }
50}
51
52impl IntoSecretBox for Vec<u8> {
53    fn into_secret_box(self) -> SecretBox<[u8]> {
54        SecretBox::from(self)
55    }
56}
57
58impl IntoSecretBox for &Vec<u8> {
59    fn into_secret_box(self) -> SecretBox<[u8]> {
60        SecretBox::from(self.clone())
61    }
62}
63
64type HmacSha256 = Hmac<Sha256>;
65
66const NONCE_VERSION: u8 = 1;
67const NONCE_RANDOM_LENGTH: usize = 16;
68const NONCE_MAC_LENGTH: usize = 16; // truncated
69const NONCE_TOTAL_LENGTH: usize = 1 + 8 + 16 + 16; // version + timestamp + random + mac
70const NONCE_FUTURE_SKEW_SECS: i64 = 5;
71
72/// Optional binding context (only fields you want to bind).
73pub struct NonceCtx<'a> {
74    pub htu: Option<&'a str>,
75    pub htm: Option<&'a str>,
76    pub jkt: Option<&'a str>,
77    pub client: Option<&'a str>,
78}
79
80fn ctx_bytes(ctx: &NonceCtx<'_>) -> Vec<u8> {
81    let mut context_bytes = Vec::new();
82    if let Some(htu) = ctx.htu {
83        context_bytes.extend_from_slice(b"HTU\0");
84        context_bytes.extend_from_slice(htu.as_bytes());
85        context_bytes.push(0);
86    }
87    if let Some(htm) = ctx.htm {
88        context_bytes.extend_from_slice(b"HTM\0");
89        context_bytes.extend_from_slice(htm.as_bytes());
90        context_bytes.push(0);
91    }
92    if let Some(jkt) = ctx.jkt {
93        context_bytes.extend_from_slice(b"JKT\0");
94        context_bytes.extend_from_slice(jkt.as_bytes());
95        context_bytes.push(0);
96    }
97    if let Some(client_id) = ctx.client {
98        context_bytes.extend_from_slice(b"CID\0");
99        context_bytes.extend_from_slice(client_id.as_bytes());
100        context_bytes.push(0);
101    }
102    context_bytes
103}
104
105/// Issue a fresh nonce bound to the given context.
106/// 
107/// Accepts either a `SecretBox<[u8]>` or any type that can be converted to bytes (e.g., `&[u8]`, `Vec<u8>`).
108/// Non-boxed types will be automatically converted to `SecretBox` internally.
109pub fn issue_nonce<S>(secret: S, now_unix: i64, ctx: &NonceCtx<'_>) -> Result<String, DpopError>
110where
111    S: IntoSecretBox,
112{
113    let secret_box = secret.into_secret_box();
114    let version_bytes = [NONCE_VERSION];
115    let timestamp_bytes = now_unix.to_be_bytes();
116
117    let mut random_bytes = [0u8; NONCE_RANDOM_LENGTH];
118    OsRng.fill_bytes(&mut random_bytes);
119
120    // HMAC-SHA256 accepts keys of any length; this should never fail
121    let mut hmac = HmacSha256::new_from_slice(secret_box.expose_secret()).map_err(|_| DpopError::InvalidHmacConfig)?;
122
123    hmac.update(&version_bytes);
124    hmac.update(&timestamp_bytes);
125    hmac.update(&random_bytes);
126    hmac.update(&ctx_bytes(ctx));
127    let mac_tag = hmac.finalize().into_bytes();
128
129    let mut nonce_bytes = Vec::with_capacity(NONCE_TOTAL_LENGTH);
130    nonce_bytes.extend_from_slice(&version_bytes);
131    nonce_bytes.extend_from_slice(&timestamp_bytes);
132    nonce_bytes.extend_from_slice(&random_bytes);
133    nonce_bytes.extend_from_slice(&mac_tag[..NONCE_MAC_LENGTH]);
134
135    Ok(B64.encode(nonce_bytes))
136}
137
138/// Verify a nonce with age & skew limits, re-binding to the given context.
139/// On success returns Ok(()); on failure returns a DpopError (NonceMismatch/NonceStale/FutureSkew).
140/// 
141/// Accepts either a `SecretBox<[u8]>` or any type that can be converted to bytes (e.g., `&[u8]`, `Vec<u8>`).
142/// Non-boxed types will be automatically converted to `SecretBox` internally.
143pub fn verify_nonce<S>(
144    secret: S,
145    nonce_b64: &str,
146    now_unix: i64,
147    max_age_secs: i64,
148    ctx: &NonceCtx<'_>,
149) -> Result<(), DpopError>
150where
151    S: IntoSecretBox,
152{
153    let secret_box = secret.into_secret_box();
154    let nonce_bytes = B64
155        .decode(nonce_b64.as_bytes())
156        .map_err(|_| DpopError::NonceMismatch)?;
157
158    if nonce_bytes.len() != NONCE_TOTAL_LENGTH {
159        return Err(DpopError::NonceMismatch);
160    }
161
162    let version = nonce_bytes[0];
163    if version != NONCE_VERSION {
164        return Err(DpopError::NonceMismatch);
165    }
166
167    // Safe extraction of timestamp bytes
168    let timestamp_bytes: [u8; 8] = nonce_bytes
169        .get(1..9)
170        .and_then(|slice| slice.try_into().ok())
171        .ok_or(DpopError::NonceMismatch)?;
172    let timestamp = i64::from_be_bytes(timestamp_bytes);
173
174    // Safe extraction of random and MAC bytes
175    let random_bytes = nonce_bytes
176        .get(9..9 + NONCE_RANDOM_LENGTH)
177        .ok_or(DpopError::NonceMismatch)?;
178    let mac_from_nonce = nonce_bytes
179        .get(9 + NONCE_RANDOM_LENGTH..)
180        .ok_or(DpopError::NonceMismatch)?;
181
182    // Age & future skew checks
183    if now_unix - timestamp > max_age_secs {
184        return Err(DpopError::NonceStale);
185    }
186    if timestamp - now_unix > NONCE_FUTURE_SKEW_SECS {
187        return Err(DpopError::FutureSkew);
188    }
189
190    // Recompute MAC
191    // HMAC-SHA256 accepts keys of any length; this should never fail
192    let mut hmac = HmacSha256::new_from_slice(secret_box.expose_secret()).map_err(|_| DpopError::InvalidHmacConfig)?;
193
194    hmac.update(&[version]);
195    hmac.update(&timestamp.to_be_bytes());
196    hmac.update(random_bytes);
197    hmac.update(&ctx_bytes(ctx));
198    let computed_mac = hmac.finalize().into_bytes();
199
200    if bool::from(mac_from_nonce.ct_eq(&computed_mac[..NONCE_MAC_LENGTH])) {
201        Ok(())
202    } else {
203        Err(DpopError::NonceMismatch)
204    }
205}
206
207/// Verify against multiple secrets (e.g., key rotation: current, previous).
208/// 
209/// Accepts a slice of secrets, where each secret can be either a `SecretBox<[u8]>` or any type
210/// that can be converted to bytes (e.g., `&[u8]`, `Vec<u8>`).
211pub fn verify_nonce_with_any<S>(
212    secrets: &[S],
213    nonce_b64: &str,
214    now_unix: i64,
215    max_age_secs: i64,
216    ctx: &NonceCtx<'_>,
217) -> Result<(), DpopError>
218where
219    S: IntoSecretBox + Clone,
220{
221    let mut last_error = DpopError::NonceMismatch;
222    for secret in secrets {
223        match verify_nonce(secret.clone(), nonce_b64, now_unix, max_age_secs, ctx) {
224            Ok(()) => return Ok(()),
225            Err(error @ DpopError::FutureSkew)
226            | Err(error @ DpopError::NonceStale)
227            | Err(error @ DpopError::NonceMismatch) => last_error = error,
228            Err(error) => return Err(error),
229        }
230    }
231    Err(last_error)
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use time::OffsetDateTime;
238
239    #[test]
240    fn roundtrip_ok_with_binding() {
241        let secret = SecretBox::from(b"supersecretkey".to_vec());
242        let current_time = OffsetDateTime::now_utc().unix_timestamp();
243        let context = NonceCtx {
244            htu: Some("https://ex.com/a"),
245            htm: Some("POST"),
246            jkt: Some("thumb"),
247            client: None,
248        };
249
250        let nonce = issue_nonce(&secret, current_time, &context).expect("issue_nonce");
251        assert!(verify_nonce(&secret, &nonce, current_time, 300, &context).is_ok());
252    }
253
254    #[test]
255    fn bad_ctx_fails() {
256        let secret = SecretBox::from(b"k".to_vec());
257        let current_time = OffsetDateTime::now_utc().unix_timestamp();
258        let original_context = NonceCtx {
259            htu: Some("https://ex.com/a"),
260            htm: Some("GET"),
261            jkt: Some("t"),
262            client: None,
263        };
264        let nonce = issue_nonce(&secret, current_time, &original_context).expect("issue_nonce");
265
266        // Change HTU → should fail
267        let different_context = NonceCtx {
268            htu: Some("https://ex.com/b"),
269            htm: Some("GET"),
270            jkt: Some("t"),
271            client: None,
272        };
273        assert!(matches!(
274            verify_nonce(&secret, &nonce, current_time, 300, &different_context),
275            Err(DpopError::NonceMismatch)
276        ));
277    }
278
279    #[test]
280    fn stale_and_future_skew() {
281        let secret = SecretBox::from(b"k2".to_vec());
282        let current_time = OffsetDateTime::now_utc().unix_timestamp();
283        let empty_context = NonceCtx {
284            htu: None,
285            htm: None,
286            jkt: None,
287            client: None,
288        };
289
290        let future_nonce =
291            issue_nonce(&secret, current_time + 10, &empty_context).expect("issue_nonce");
292        assert!(matches!(
293            verify_nonce(&secret, &future_nonce, current_time, 300, &empty_context),
294            Err(DpopError::FutureSkew)
295        ));
296
297        let stale_nonce =
298            issue_nonce(&secret, current_time - 301, &empty_context).expect("issue_nonce");
299        assert!(matches!(
300            verify_nonce(&secret, &stale_nonce, current_time, 300, &empty_context),
301            Err(DpopError::NonceStale)
302        ));
303    }
304
305    #[test]
306    fn rotation_any_secret() {
307        let current_secret = SecretBox::from(b"current".to_vec());
308        let previous_secret = SecretBox::from(b"previous".to_vec());
309        let current_time = OffsetDateTime::now_utc().unix_timestamp();
310        let context = NonceCtx {
311            htu: Some("u"),
312            htm: Some("M"),
313            jkt: None,
314            client: None,
315        };
316
317        let nonce_from_previous =
318            issue_nonce(&previous_secret, current_time, &context).expect("issue_nonce");
319        assert!(verify_nonce_with_any(
320            &[&current_secret, &previous_secret],
321            &nonce_from_previous,
322            current_time,
323            300,
324            &context
325        )
326        .is_ok());
327
328        let nonce_from_current =
329            issue_nonce(&current_secret, current_time, &context).expect("issue_nonce");
330        assert!(verify_nonce_with_any(
331            &[&current_secret, &previous_secret],
332            &nonce_from_current,
333            current_time,
334            300,
335            &context
336        )
337        .is_ok());
338    }
339
340    #[test]
341    fn accepts_non_boxed_types() {
342        // Test that functions accept &[u8] directly
343        let secret_bytes = b"test-secret";
344        let current_time = OffsetDateTime::now_utc().unix_timestamp();
345        let context = NonceCtx {
346            htu: Some("https://ex.com/a"),
347            htm: Some("GET"),
348            jkt: Some("thumb"),
349            client: None,
350        };
351
352        let nonce = issue_nonce(secret_bytes.as_slice(), current_time, &context).expect("issue_nonce");
353        assert!(verify_nonce(secret_bytes.as_slice(), &nonce, current_time, 300, &context).is_ok());
354
355        // Test with Vec<u8>
356        let secret_vec = b"test-secret-vec".to_vec();
357        let nonce2 = issue_nonce(&secret_vec, current_time, &context).expect("issue_nonce");
358        assert!(verify_nonce(&secret_vec, &nonce2, current_time, 300, &context).is_ok());
359    }
360}