dpop_verifier/
verify.rs

1use crate::uri::{normalize_htu, normalize_method};
2use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
3use base64::Engine;
4use serde::Deserialize;
5use sha2::{Digest, Sha256};
6use subtle::ConstantTimeEq;
7use time::OffsetDateTime;
8
9use crate::jwk::{thumbprint_ec_p256, verifying_key_from_p256_xy};
10use crate::replay::{ReplayContext, ReplayStore};
11use crate::DpopError;
12use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey};
13
14#[derive(Deserialize)]
15struct DpopHeader {
16    typ: String,
17    alg: String,
18    jwk: Jwk,
19}
20#[derive(Deserialize)]
21struct Jwk {
22    kty: String,
23    crv: String,
24    x: String,
25    y: String,
26}
27
28#[derive(Clone, Debug)]
29pub enum NonceMode {
30    Disabled,
31    /// Require exact equality against `expected_nonce`
32    RequireEqual {
33        expected_nonce: String, // the nonce you previously issued
34    },
35    /// Stateless HMAC-based nonces: encode ts+rand+ctx and MAC it
36    Hmac {
37        secret: std::sync::Arc<[u8]>, // server secret
38        max_age_secs: i64,            // window (e.g., 300)
39        bind_htu_htm: bool,
40        bind_jkt: bool,
41    },
42}
43
44#[derive(Debug, Clone)]
45pub struct VerifyOptions {
46    pub max_age_secs: i64,
47    pub future_skew_secs: i64,
48    pub nonce_mode: NonceMode,
49}
50impl Default for VerifyOptions {
51    fn default() -> Self {
52        Self {
53            max_age_secs: 300,
54            future_skew_secs: 5,
55            nonce_mode: NonceMode::Disabled,
56        }
57    }
58}
59
60#[derive(Debug)]
61pub struct VerifiedDpop {
62    pub jkt: String,
63    pub jti: String,
64    pub iat: i64,
65}
66
67/// Verify DPoP proof and record the jti to prevent replays.
68pub async fn verify_proof<S: ReplayStore + ?Sized>(
69    store: &mut S,
70    dpop_compact_jws: &str,
71    expected_htu: &str,
72    expected_htm: &str,
73    maybe_access_token: Option<&str>,
74    opts: VerifyOptions,
75) -> Result<VerifiedDpop, DpopError> {
76    let mut it = dpop_compact_jws.split('.');
77    let (h_b64, p_b64, s_b64) = match (it.next(), it.next(), it.next()) {
78        (Some(h), Some(p), Some(s)) if it.next().is_none() => (h, p, s),
79        _ => return Err(DpopError::MalformedJws),
80    };
81
82    // Decode JOSE header (as Value first, to reject private 'd')
83    let hdr: DpopHeader = {
84        let bytes = B64.decode(h_b64).map_err(|_| DpopError::MalformedJws)?;
85        let val: serde_json::Value =
86            serde_json::from_slice(&bytes).map_err(|_| DpopError::MalformedJws)?;
87        // MUST NOT include private JWK material
88        if val.get("jwk").and_then(|j| j.get("d")).is_some() {
89            return Err(DpopError::BadJwk("jwk must not include 'd'"));
90        }
91        serde_json::from_value(val).map_err(|_| DpopError::MalformedJws)?
92    };
93
94    if hdr.typ != "dpop+jwt" {
95        return Err(DpopError::MalformedJws);
96    }
97    // JOSE algorithm must be ES256 (P-256 + SHA-256)
98
99    match hdr.alg.as_str() {
100        "ES256" => { /* ok */ }
101        // "EdDSA" if cfg!(feature = "eddsa") => { /* ok */ } <-- Will maybe add later
102        "none" => return Err(DpopError::InvalidAlg("none".into())),
103        a if a.starts_with("HS") => return Err(DpopError::InvalidAlg(a.into())),
104        other => return Err(DpopError::UnsupportedAlg(other.into())),
105    }
106    if hdr.jwk.kty != "EC" || hdr.jwk.crv != "P-256" {
107        return Err(DpopError::BadJwk("expect EC P-256"));
108    }
109
110    let vk: VerifyingKey = verifying_key_from_p256_xy(&hdr.jwk.x, &hdr.jwk.y)?;
111
112    // Verify ECDSA signature over "<header>.<payload>"
113    let signing_input = {
114        let mut s = String::with_capacity(h_b64.len() + 1 + p_b64.len());
115        s.push_str(h_b64);
116        s.push('.');
117        s.push_str(p_b64);
118        s
119    };
120
121    let sig_bytes = B64.decode(s_b64).map_err(|_| DpopError::InvalidSignature)?;
122    // JOSE (JWS ES256) requires raw r||s (64 bytes). Do NOT accept DER.
123    if sig_bytes.len() != 64 {
124        return Err(DpopError::InvalidSignature);
125    }
126    let sig = Signature::from_slice(&sig_bytes).map_err(|_| DpopError::InvalidSignature)?;
127    vk.verify(signing_input.as_bytes(), &sig)
128        .map_err(|_| DpopError::InvalidSignature)?;
129
130    let claims: serde_json::Value = {
131        let bytes = B64.decode(p_b64).map_err(|_| DpopError::MalformedJws)?;
132        serde_json::from_slice(&bytes).map_err(|_| DpopError::MalformedJws)?
133    };
134
135    let jti = claims
136        .get("jti")
137        .and_then(|v| v.as_str())
138        .ok_or(DpopError::MissingClaim("jti"))?;
139    if jti.len() > 512 {
140        return Err(DpopError::JtiTooLong);
141    }
142    let iat = claims
143        .get("iat")
144        .and_then(|v| v.as_i64())
145        .ok_or(DpopError::MissingClaim("iat"))?;
146    let htm = claims
147        .get("htm")
148        .and_then(|v| v.as_str())
149        .ok_or(DpopError::MissingClaim("htm"))?;
150    let htu = claims
151        .get("htu")
152        .and_then(|v| v.as_str())
153        .ok_or(DpopError::MissingClaim("htu"))?;
154
155    // Strict method & URI checks (normalize both sides, then exact compare)
156    let want_htm = normalize_method(expected_htm)?; // e.g., "GET"
157    let got_htm = normalize_method(htm)?; // from claims
158    if got_htm != want_htm {
159        return Err(DpopError::HtmMismatch);
160    }
161
162    let want_htu = normalize_htu(expected_htu)?; // scheme://host[:port]/path (no q/frag)
163    let got_htu = normalize_htu(htu)?;
164    if got_htu != want_htu {
165        return Err(DpopError::HtuMismatch);
166    }
167
168    // Optional ath (only when an access token is being presented)
169    if let Some(at) = maybe_access_token {
170        // Compute expected SHA-256 bytes of the exact token octets:
171        let want = Sha256::digest(at.as_bytes());
172        // Decode provided ath (must be base64url no-pad):
173        let got_b64 = claims
174            .get("ath")
175            .and_then(|v| v.as_str())
176            .ok_or(DpopError::MissingAth)?;
177        let got = B64
178            .decode(got_b64.as_bytes())
179            .map_err(|_| DpopError::AthMalformed)?;
180        // Constant-time compare of raw digests:
181        if got.len() != want.len() || !bool::from(got.ct_eq(&want[..])) {
182            return Err(DpopError::AthMismatch);
183        }
184    }
185
186    // Freshness (iat)
187    let now = OffsetDateTime::now_utc().unix_timestamp();
188    if iat > now + opts.future_skew_secs {
189        return Err(DpopError::FutureSkew);
190    }
191    if now - iat > opts.max_age_secs {
192        return Err(DpopError::Stale);
193    }
194
195    // Replay prevention (store SHA-256(jti))
196    let mut hasher = Sha256::new();
197    hasher.update(jti.as_bytes());
198    let mut jti_hash = [0u8; 32];
199    jti_hash.copy_from_slice(&hasher.finalize());
200
201    let jkt = thumbprint_ec_p256(&hdr.jwk.x, &hdr.jwk.y)?;
202
203    let nonce_claim = claims.get("nonce").and_then(|v| v.as_str());
204
205    match &opts.nonce_mode {
206        NonceMode::Disabled => { /* do nothing */ }
207        NonceMode::RequireEqual { expected_nonce } => {
208            let n = nonce_claim.ok_or(DpopError::MissingNonce)?;
209            if n != expected_nonce {
210                let fresh = expected_nonce.to_string(); // or issue a new one if you prefer
211                return Err(DpopError::UseDpopNonce { nonce: fresh });
212            }
213        }
214        NonceMode::Hmac {
215            secret,
216            max_age_secs,
217            bind_htu_htm,
218            bind_jkt,
219        } => {
220            let n = match nonce_claim {
221                Some(s) => s,
222                None => {
223                    // Missing → ask client to retry with nonce
224                    let now = time::OffsetDateTime::now_utc().unix_timestamp();
225                    let ctx = crate::nonce::NonceCtx {
226                        htu: if *bind_htu_htm {
227                            Some(want_htu.as_str())
228                        } else {
229                            None
230                        },
231                        htm: if *bind_htu_htm {
232                            Some(want_htm.as_str())
233                        } else {
234                            None
235                        },
236                        jkt: if *bind_jkt { Some(jkt.as_str()) } else { None },
237                    };
238                    let fresh = crate::nonce::issue_nonce(secret, now, &ctx);
239                    return Err(DpopError::UseDpopNonce { nonce: fresh });
240                }
241            };
242            let now = time::OffsetDateTime::now_utc().unix_timestamp();
243            let ctx = crate::nonce::NonceCtx {
244                htu: if *bind_htu_htm {
245                    Some(want_htu.as_str())
246                } else {
247                    None
248                },
249                htm: if *bind_htu_htm {
250                    Some(want_htm.as_str())
251                } else {
252                    None
253                },
254                jkt: if *bind_jkt { Some(jkt.as_str()) } else { None },
255            };
256            if let Err(_) = crate::nonce::verify_nonce(secret, n, now, *max_age_secs, &ctx) {
257                // On invalid/stale → emit NEW nonce so client can retry immediately
258                let fresh = crate::nonce::issue_nonce(secret, now, &ctx);
259                return Err(DpopError::UseDpopNonce { nonce: fresh });
260            }
261        }
262    }
263
264    let ok = store
265        .insert_once(
266            jti_hash,
267            ReplayContext {
268                jkt: Some(&jkt),
269                htm: Some(htm),
270                htu: Some(htu),
271                iat,
272            },
273        )
274        .await?;
275    if !ok {
276        return Err(DpopError::Replay);
277    }
278
279    Ok(VerifiedDpop {
280        jkt,
281        jti: jti.to_string(),
282        iat,
283    })
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::jwk::thumbprint_ec_p256;
290    use crate::nonce::issue_nonce;
291    use p256::ecdsa::{signature::Signer, Signature, SigningKey};
292    use rand_core::OsRng;
293    use std::sync::Arc;
294
295    // ---- helpers ----------------------------------------------------------------
296
297    fn gen_es256_key() -> (SigningKey, String, String) {
298        let sk = SigningKey::random(&mut OsRng);
299        let vk = VerifyingKey::from(&sk);
300        let ep = vk.to_encoded_point(false);
301        let x = B64.encode(ep.x().unwrap());
302        let y = B64.encode(ep.y().unwrap());
303        (sk, x, y)
304    }
305
306    fn make_jws(
307        sk: &SigningKey,
308        header_val: serde_json::Value,
309        claims_val: serde_json::Value,
310    ) -> String {
311        let h = serde_json::to_vec(&header_val).unwrap();
312        let p = serde_json::to_vec(&claims_val).unwrap();
313        let h_b64 = B64.encode(h);
314        let p_b64 = B64.encode(p);
315        let signing_input = format!("{h_b64}.{p_b64}");
316        let sig: Signature = sk.sign(signing_input.as_bytes());
317        let s_b64 = B64.encode(sig.to_bytes());
318        format!("{h_b64}.{p_b64}.{s_b64}")
319    }
320
321    #[derive(Default)]
322    struct MemoryStore(std::collections::HashSet<[u8; 32]>);
323
324    #[async_trait::async_trait]
325    impl ReplayStore for MemoryStore {
326        async fn insert_once(
327            &mut self,
328            jti_hash: [u8; 32],
329            _ctx: ReplayContext<'_>,
330        ) -> Result<bool, DpopError> {
331            Ok(self.0.insert(jti_hash))
332        }
333    }
334    // ---- tests ------------------------------------------------------------------
335    #[test]
336    fn thumbprint_has_expected_length_and_no_padding() {
337        // 32 zero bytes -> base64url = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" (43 chars)
338        let x = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
339        let y = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
340        let t1 = thumbprint_ec_p256(x, y).expect("thumbprint");
341        let t2 = thumbprint_ec_p256(x, y).expect("thumbprint");
342        // deterministic and base64url w/out '=' padding; sha256 -> 43 chars
343        assert_eq!(t1, t2);
344        assert_eq!(t1.len(), 43);
345        assert!(!t1.contains('='));
346    }
347
348    #[test]
349    fn decoding_key_rejects_wrong_sizes() {
350        // 31-byte x (trimmed), 32-byte y
351        let bad_x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 31]);
352        let good_y = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 32]);
353        let res = crate::jwk::verifying_key_from_p256_xy(&bad_x, &good_y);
354        assert!(res.is_err(), "expected error for bad y");
355
356        // 32-byte x, 33-byte y
357        let good_x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 32]);
358        let bad_y = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 33]);
359        let res = crate::jwk::verifying_key_from_p256_xy(&good_x, &bad_y);
360        assert!(res.is_err(), "expected error for bad y");
361    }
362
363    #[tokio::test]
364    async fn replay_store_trait_basic() {
365        use async_trait::async_trait;
366        use std::collections::HashSet;
367
368        struct MemoryStore(HashSet<[u8; 32]>);
369
370        #[async_trait]
371        impl ReplayStore for MemoryStore {
372            async fn insert_once(
373                &mut self,
374                jti_hash: [u8; 32],
375                _ctx: ReplayContext<'_>,
376            ) -> Result<bool, DpopError> {
377                Ok(self.0.insert(jti_hash))
378            }
379        }
380
381        let mut s = MemoryStore(HashSet::new());
382        let first = s
383            .insert_once(
384                [42u8; 32],
385                ReplayContext {
386                    jkt: Some("j"),
387                    htm: Some("POST"),
388                    htu: Some("https://ex"),
389                    iat: 0,
390                },
391            )
392            .await
393            .unwrap();
394        let second = s
395            .insert_once(
396                [42u8; 32],
397                ReplayContext {
398                    jkt: Some("j"),
399                    htm: Some("POST"),
400                    htu: Some("https://ex"),
401                    iat: 0,
402                },
403            )
404            .await
405            .unwrap();
406        assert!(first);
407        assert!(!second); // replay detected
408    }
409    #[tokio::test]
410    async fn verify_valid_es256_proof() {
411        let (sk, x, y) = gen_es256_key();
412        let now = OffsetDateTime::now_utc().unix_timestamp();
413        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
414        let p = serde_json::json!({"jti":"j1","iat":now,"htm":"GET","htu":"https://api.example.com/resource"});
415        let jws = make_jws(&sk, h, p);
416
417        let mut store = MemoryStore::default();
418        let res = verify_proof(
419            &mut store,
420            &jws,
421            "https://api.example.com/resource",
422            "GET",
423            None,
424            VerifyOptions::default(),
425        )
426        .await;
427        assert!(res.is_ok(), "{res:?}");
428    }
429
430    #[tokio::test]
431    async fn method_normalization_allows_lowercase_claim() {
432        let (sk, x, y) = gen_es256_key();
433        let now = OffsetDateTime::now_utc().unix_timestamp();
434        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
435        let p = serde_json::json!({"jti":"j2","iat":now,"htm":"get","htu":"https://ex.com/a"});
436        let jws = make_jws(&sk, h, p);
437
438        let mut store = MemoryStore::default();
439        assert!(verify_proof(
440            &mut store,
441            &jws,
442            "https://ex.com/a",
443            "GET",
444            None,
445            VerifyOptions::default()
446        )
447        .await
448        .is_ok());
449    }
450
451    #[tokio::test]
452    async fn htu_normalizes_dot_segments_and_default_ports_and_strips_qf() {
453        let (sk, x, y) = gen_es256_key();
454        let now = OffsetDateTime::now_utc().unix_timestamp();
455        // claim has :443, dot-segment, query and fragment
456        let claim_htu = "https://EX.COM:443/a/../b?q=1#frag";
457        let expect_htu = "https://ex.com/b";
458        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
459        let p = serde_json::json!({"jti":"j3","iat":now,"htm":"GET","htu":claim_htu});
460        let jws = make_jws(&sk, h, p);
461
462        let mut store = MemoryStore::default();
463        assert!(verify_proof(
464            &mut store,
465            &jws,
466            expect_htu,
467            "GET",
468            None,
469            VerifyOptions::default()
470        )
471        .await
472        .is_ok());
473    }
474
475    #[tokio::test]
476    async fn htu_path_case_mismatch_fails() {
477        let (sk, x, y) = gen_es256_key();
478        let now = OffsetDateTime::now_utc().unix_timestamp();
479        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
480        let p = serde_json::json!({"jti":"j4","iat":now,"htm":"GET","htu":"https://ex.com/API"});
481        let jws = make_jws(&sk, h, p);
482
483        let mut store = MemoryStore::default();
484        let err = verify_proof(
485            &mut store,
486            &jws,
487            "https://ex.com/api",
488            "GET",
489            None,
490            VerifyOptions::default(),
491        )
492        .await
493        .unwrap_err();
494        matches!(err, DpopError::HtuMismatch);
495    }
496
497    #[tokio::test]
498    async fn alg_none_rejected() {
499        let (sk, x, y) = gen_es256_key();
500        let now = OffsetDateTime::now_utc().unix_timestamp();
501        // still sign, but "alg":"none" must be rejected before/independent of signature
502        let h = serde_json::json!({"typ":"dpop+jwt","alg":"none","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
503        let p = serde_json::json!({"jti":"j5","iat":now,"htm":"GET","htu":"https://ex.com/a"});
504        let jws = make_jws(&sk, h, p);
505
506        let mut store = MemoryStore::default();
507        let err = verify_proof(
508            &mut store,
509            &jws,
510            "https://ex.com/a",
511            "GET",
512            None,
513            VerifyOptions::default(),
514        )
515        .await
516        .unwrap_err();
517        matches!(err, DpopError::InvalidAlg(_));
518    }
519
520    #[tokio::test]
521    async fn alg_hs256_rejected() {
522        let (sk, x, y) = gen_es256_key();
523        let now = OffsetDateTime::now_utc().unix_timestamp();
524        let h = serde_json::json!({"typ":"dpop+jwt","alg":"HS256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
525        let p = serde_json::json!({"jti":"j6","iat":now,"htm":"GET","htu":"https://ex.com/a"});
526        let jws = make_jws(&sk, h, p);
527
528        let mut store = MemoryStore::default();
529        let err = verify_proof(
530            &mut store,
531            &jws,
532            "https://ex.com/a",
533            "GET",
534            None,
535            VerifyOptions::default(),
536        )
537        .await
538        .unwrap_err();
539        matches!(err, DpopError::InvalidAlg(_));
540    }
541
542    #[tokio::test]
543    async fn jwk_with_private_d_rejected() {
544        let (sk, x, y) = gen_es256_key();
545        let now = OffsetDateTime::now_utc().unix_timestamp();
546        // inject "d" (any string) -> must be rejected
547        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y,"d":"AAAA"}});
548        let p = serde_json::json!({"jti":"j7","iat":now,"htm":"GET","htu":"https://ex.com/a"});
549        let jws = make_jws(&sk, h, p);
550
551        let mut store = MemoryStore::default();
552        let err = verify_proof(
553            &mut store,
554            &jws,
555            "https://ex.com/a",
556            "GET",
557            None,
558            VerifyOptions::default(),
559        )
560        .await
561        .unwrap_err();
562        matches!(err, DpopError::BadJwk(_));
563    }
564
565    #[tokio::test]
566    async fn ath_binding_ok_and_mismatch_and_padded_rejected() {
567        let (sk, x, y) = gen_es256_key();
568        let now = OffsetDateTime::now_utc().unix_timestamp();
569        let at = "access.token.string";
570        let ath = B64.encode(Sha256::digest(at.as_bytes()));
571        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
572
573        // OK
574        let p_ok = serde_json::json!({"jti":"j8","iat":now,"htm":"GET","htu":"https://ex.com/a","ath":ath});
575        let jws_ok = make_jws(&sk, h.clone(), p_ok);
576        let mut store = MemoryStore::default();
577        assert!(verify_proof(
578            &mut store,
579            &jws_ok,
580            "https://ex.com/a",
581            "GET",
582            Some(at),
583            VerifyOptions::default()
584        )
585        .await
586        .is_ok());
587
588        // Mismatch
589        let p_bad = serde_json::json!({"jti":"j9","iat":now,"htm":"GET","htu":"https://ex.com/a","ath":ath});
590        let jws_bad = make_jws(&sk, h.clone(), p_bad);
591        let mut store2 = MemoryStore::default();
592        let err = verify_proof(
593            &mut store2,
594            &jws_bad,
595            "https://ex.com/a",
596            "GET",
597            Some("different.token"),
598            VerifyOptions::default(),
599        )
600        .await
601        .unwrap_err();
602        matches!(err, DpopError::AthMismatch);
603
604        // Padded ath should be rejected as malformed (engine is URL_SAFE_NO_PAD)
605        let ath_padded = format!("{ath}==");
606        let p_pad = serde_json::json!({"jti":"j10","iat":now,"htm":"GET","htu":"https://ex.com/a","ath":ath_padded});
607        let jws_pad = make_jws(&sk, h.clone(), p_pad);
608        let mut store3 = MemoryStore::default();
609        let err = verify_proof(
610            &mut store3,
611            &jws_pad,
612            "https://ex.com/a",
613            "GET",
614            Some(at),
615            VerifyOptions::default(),
616        )
617        .await
618        .unwrap_err();
619        matches!(err, DpopError::AthMalformed);
620    }
621
622    #[tokio::test]
623    async fn freshness_future_skew_and_stale() {
624        let (sk, x, y) = gen_es256_key();
625        let now = OffsetDateTime::now_utc().unix_timestamp();
626        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
627
628        // Future skew just over limit
629        let p_future =
630            serde_json::json!({"jti":"jf","iat":now + 6,"htm":"GET","htu":"https://ex.com/a"});
631        let jws_future = make_jws(&sk, h.clone(), p_future);
632        let mut store1 = MemoryStore::default();
633        let opts = VerifyOptions {
634            max_age_secs: 300,
635            future_skew_secs: 5,
636            nonce_mode: NonceMode::Disabled,
637        };
638        let err = verify_proof(
639            &mut store1,
640            &jws_future,
641            "https://ex.com/a",
642            "GET",
643            None,
644            opts,
645        )
646        .await
647        .unwrap_err();
648        matches!(err, DpopError::FutureSkew);
649
650        // Stale just over limit
651        let p_stale =
652            serde_json::json!({"jti":"js","iat":now - 301,"htm":"GET","htu":"https://ex.com/a"});
653        let jws_stale = make_jws(&sk, h.clone(), p_stale);
654        let mut store2 = MemoryStore::default();
655        let opts = VerifyOptions {
656            max_age_secs: 300,
657            future_skew_secs: 5,
658            nonce_mode: NonceMode::Disabled,
659        };
660        let err = verify_proof(
661            &mut store2,
662            &jws_stale,
663            "https://ex.com/a",
664            "GET",
665            None,
666            opts,
667        )
668        .await
669        .unwrap_err();
670        matches!(err, DpopError::Stale);
671    }
672
673    #[tokio::test]
674    async fn replay_same_jti_is_rejected() {
675        let (sk, x, y) = gen_es256_key();
676        let now = OffsetDateTime::now_utc().unix_timestamp();
677        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
678        let p = serde_json::json!({"jti":"jr","iat":now,"htm":"GET","htu":"https://ex.com/a"});
679        let jws = make_jws(&sk, h, p);
680
681        let mut store = MemoryStore::default();
682        let ok1 = verify_proof(
683            &mut store,
684            &jws,
685            "https://ex.com/a",
686            "GET",
687            None,
688            VerifyOptions::default(),
689        )
690        .await;
691        assert!(ok1.is_ok());
692        let err = verify_proof(
693            &mut store,
694            &jws,
695            "https://ex.com/a",
696            "GET",
697            None,
698            VerifyOptions::default(),
699        )
700        .await
701        .unwrap_err();
702        matches!(err, DpopError::Replay);
703    }
704
705    #[tokio::test]
706    async fn signature_tamper_detected() {
707        let (sk, x, y) = gen_es256_key();
708        let now = OffsetDateTime::now_utc().unix_timestamp();
709        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
710        let p = serde_json::json!({"jti":"jt","iat":now,"htm":"GET","htu":"https://ex.com/a"});
711        let mut jws = make_jws(&sk, h, p);
712
713        // Flip one byte in the payload section (keep base64url valid length)
714        let bytes = unsafe { jws.as_bytes_mut() }; // alternative: rebuild string
715                                                   // Find the second '.' and flip a safe ASCII char before it
716        let mut dot_count = 0usize;
717        for i in 0..bytes.len() {
718            if bytes[i] == b'.' {
719                dot_count += 1;
720                if dot_count == 2 && i > 10 {
721                    bytes[i - 5] ^= 0x01; // tiny flip
722                    break;
723                }
724            }
725        }
726
727        let mut store = MemoryStore::default();
728        let err = verify_proof(
729            &mut store,
730            &jws,
731            "https://ex.com/a",
732            "GET",
733            None,
734            VerifyOptions::default(),
735        )
736        .await
737        .unwrap_err();
738        matches!(err, DpopError::InvalidSignature);
739    }
740
741    #[tokio::test]
742    async fn method_mismatch_rejected() {
743        let (sk, x, y) = gen_es256_key();
744        let now = OffsetDateTime::now_utc().unix_timestamp();
745        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
746        let p = serde_json::json!({"jti":"jm","iat":now,"htm":"POST","htu":"https://ex.com/a"});
747        let jws = make_jws(&sk, h, p);
748
749        let mut store = MemoryStore::default();
750        let err = verify_proof(
751            &mut store,
752            &jws,
753            "https://ex.com/a",
754            "GET",
755            None,
756            VerifyOptions::default(),
757        )
758        .await
759        .unwrap_err();
760        matches!(err, DpopError::HtmMismatch);
761    }
762
763    #[test]
764    fn normalize_helpers_examples() {
765        // sanity checks for helpers used by verify_proof
766        assert_eq!(
767            normalize_htu("https://EX.com:443/a/./b/../c?x=1#frag").unwrap(),
768            "https://ex.com/a/c"
769        );
770        assert_eq!(normalize_method("get").unwrap(), "GET");
771        assert!(normalize_method("CUSTOM").is_err());
772    }
773
774    #[tokio::test]
775    async fn jti_too_long_rejected() {
776        let (sk, x, y) = gen_es256_key();
777        let now = OffsetDateTime::now_utc().unix_timestamp();
778        let too_long = "x".repeat(513);
779        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
780        let p = serde_json::json!({"jti":too_long,"iat":now,"htm":"GET","htu":"https://ex.com/a"});
781        let jws = make_jws(&sk, h, p);
782
783        let mut store = MemoryStore::default();
784        let err = verify_proof(
785            &mut store,
786            &jws,
787            "https://ex.com/a",
788            "GET",
789            None,
790            VerifyOptions::default(),
791        )
792        .await
793        .unwrap_err();
794        matches!(err, DpopError::JtiTooLong);
795    }
796    // ----------------------- Nonce: RequireEqual -------------------------------
797
798    #[tokio::test]
799    async fn nonce_require_equal_ok() {
800        let (sk, x, y) = gen_es256_key();
801        let now = OffsetDateTime::now_utc().unix_timestamp();
802        let expected_htu = "https://ex.com/a";
803        let expected_htm = "GET";
804
805        let expected_nonce = "nonce-123";
806        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
807        let p = serde_json::json!({
808            "jti":"n-reqeq-ok",
809            "iat":now,
810            "htm":expected_htm,
811            "htu":expected_htu,
812            "nonce": expected_nonce
813        });
814        let jws = make_jws(&sk, h, p);
815
816        let mut store = MemoryStore::default();
817        let opts = VerifyOptions {
818            max_age_secs: 300,
819            future_skew_secs: 5,
820            nonce_mode: NonceMode::RequireEqual {
821                expected_nonce: expected_nonce.to_string(),
822            },
823        };
824        assert!(
825            verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
826                .await
827                .is_ok()
828        );
829    }
830
831    #[tokio::test]
832    async fn nonce_require_equal_missing_claim() {
833        let (sk, x, y) = gen_es256_key();
834        let now = OffsetDateTime::now_utc().unix_timestamp();
835        let expected_htu = "https://ex.com/a";
836        let expected_htm = "GET";
837
838        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
839        let p = serde_json::json!({
840            "jti":"n-reqeq-miss",
841            "iat":now,
842            "htm":expected_htm,
843            "htu":expected_htu
844        });
845        let jws = make_jws(&sk, h, p);
846
847        let mut store = MemoryStore::default();
848        let opts = VerifyOptions {
849            max_age_secs: 300,
850            future_skew_secs: 5,
851            nonce_mode: NonceMode::RequireEqual {
852                expected_nonce: "x".into(),
853            },
854        };
855        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
856            .await
857            .unwrap_err();
858        matches!(err, DpopError::MissingNonce);
859    }
860
861    #[tokio::test]
862    async fn nonce_require_equal_mismatch_yields_usedpopnonce() {
863        let (sk, x, y) = gen_es256_key();
864        let now = OffsetDateTime::now_utc().unix_timestamp();
865        let expected_htu = "https://ex.com/a";
866        let expected_htm = "GET";
867
868        let claim_nonce = "client-value";
869        let expected_nonce = "server-expected";
870        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
871        let p = serde_json::json!({
872            "jti":"n-reqeq-mis",
873            "iat":now,
874            "htm":expected_htm,
875            "htu":expected_htu,
876            "nonce": claim_nonce
877        });
878        let jws = make_jws(&sk, h, p);
879
880        let mut store = MemoryStore::default();
881        let opts = VerifyOptions {
882            max_age_secs: 300,
883            future_skew_secs: 5,
884            nonce_mode: NonceMode::RequireEqual {
885                expected_nonce: expected_nonce.into(),
886            },
887        };
888        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
889            .await
890            .unwrap_err();
891        // Server should respond with UseDpopNonce carrying a fresh/expected nonce
892        if let DpopError::UseDpopNonce { nonce } = err {
893            assert_eq!(nonce, expected_nonce);
894        } else {
895            panic!("expected UseDpopNonce, got {err:?}");
896        }
897    }
898
899    // -------------------------- Nonce: HMAC ------------------------------------
900
901    #[tokio::test]
902    async fn nonce_hmac_ok_bound_all() {
903        let (sk, x, y) = gen_es256_key();
904        let now = OffsetDateTime::now_utc().unix_timestamp();
905        let expected_htu = "https://ex.com/a";
906        let expected_htm = "GET";
907
908        // Compute jkt from header jwk x/y to match verifier's jkt
909        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
910
911        let secret: Arc<[u8]> = Arc::from(&b"supersecret"[..]);
912        let ctx = crate::nonce::NonceCtx {
913            htu: Some(expected_htu),
914            htm: Some(expected_htm),
915            jkt: Some(&jkt),
916        };
917        let nonce = issue_nonce(&secret, now, &ctx);
918
919        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
920        let p = serde_json::json!({
921            "jti":"n-hmac-ok",
922            "iat":now,
923            "htm":expected_htm,
924            "htu":expected_htu,
925            "nonce": nonce
926        });
927        let jws = make_jws(&sk, h, p);
928
929        let mut store = MemoryStore::default();
930        let opts = VerifyOptions {
931            max_age_secs: 300,
932            future_skew_secs: 5,
933            nonce_mode: NonceMode::Hmac {
934                secret: secret.clone(),
935                max_age_secs: 300,
936                bind_htu_htm: true,
937                bind_jkt: true,
938            },
939        };
940        assert!(
941            verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
942                .await
943                .is_ok()
944        );
945    }
946
947    #[tokio::test]
948    async fn nonce_hmac_missing_claim_prompts_use_dpop_nonce() {
949        let (sk, x, y) = gen_es256_key();
950        let now = OffsetDateTime::now_utc().unix_timestamp();
951        let expected_htu = "https://ex.com/a";
952        let expected_htm = "GET";
953
954        let secret: Arc<[u8]> = Arc::from(&b"supersecret"[..]);
955
956        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
957        let p = serde_json::json!({
958            "jti":"n-hmac-miss",
959            "iat":now,
960            "htm":expected_htm,
961            "htu":expected_htu
962        });
963        let jws = make_jws(&sk, h, p);
964
965        let mut store = MemoryStore::default();
966        let opts = VerifyOptions {
967            max_age_secs: 300,
968            future_skew_secs: 5,
969            nonce_mode: NonceMode::Hmac {
970                secret: secret.clone(),
971                max_age_secs: 300,
972                bind_htu_htm: true,
973                bind_jkt: true,
974            },
975        };
976        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
977            .await
978            .unwrap_err();
979        matches!(err, DpopError::UseDpopNonce { .. });
980    }
981
982    #[tokio::test]
983    async fn nonce_hmac_wrong_htu_prompts_use_dpop_nonce() {
984        let (sk, x, y) = gen_es256_key();
985        let now = OffsetDateTime::now_utc().unix_timestamp();
986        let expected_htm = "GET";
987        let expected_htu = "https://ex.com/correct";
988
989        // Bind nonce to a different HTU to force mismatch
990        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
991        let secret: Arc<[u8]> = Arc::from(&b"k"[..]);
992        let ctx_wrong = crate::nonce::NonceCtx {
993            htu: Some("https://ex.com/wrong"),
994            htm: Some(expected_htm),
995            jkt: Some(&jkt),
996        };
997        let nonce = issue_nonce(&secret, now, &ctx_wrong);
998
999        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1000        let p = serde_json::json!({
1001            "jti":"n-hmac-htu-mis",
1002            "iat":now,
1003            "htm":expected_htm,
1004            "htu":expected_htu,
1005            "nonce": nonce
1006        });
1007        let jws = make_jws(&sk, h, p);
1008
1009        let mut store = MemoryStore::default();
1010        let opts = VerifyOptions {
1011            max_age_secs: 300,
1012            future_skew_secs: 5,
1013            nonce_mode: NonceMode::Hmac {
1014                secret: secret.clone(),
1015                max_age_secs: 300,
1016                bind_htu_htm: true,
1017                bind_jkt: true,
1018            },
1019        };
1020        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1021            .await
1022            .unwrap_err();
1023        matches!(err, DpopError::UseDpopNonce { .. });
1024    }
1025
1026    #[tokio::test]
1027    async fn nonce_hmac_wrong_jkt_prompts_use_dpop_nonce() {
1028        // Create two keys; mint nonce with jkt from key A, but sign proof with key B
1029        let (_sk_a, x_a, y_a) = gen_es256_key();
1030        let (sk_b, x_b, y_b) = gen_es256_key();
1031        let now = OffsetDateTime::now_utc().unix_timestamp();
1032        let expected_htu = "https://ex.com/a";
1033        let expected_htm = "GET";
1034
1035        let jkt_a = thumbprint_ec_p256(&x_a, &y_a).unwrap();
1036        let secret: Arc<[u8]> = Arc::from(&b"secret-2"[..]);
1037        let ctx = crate::nonce::NonceCtx {
1038            htu: Some(expected_htu),
1039            htm: Some(expected_htm),
1040            jkt: Some(&jkt_a), // bind nonce to A's jkt
1041        };
1042        let nonce = issue_nonce(&secret, now, &ctx);
1043
1044        // Build proof with key B (=> jkt != jkt_a)
1045        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x_b,"y":y_b}});
1046        let p = serde_json::json!({
1047            "jti":"n-hmac-jkt-mis",
1048            "iat":now,
1049            "htm":expected_htm,
1050            "htu":expected_htu,
1051            "nonce": nonce
1052        });
1053        let jws = make_jws(&sk_b, h, p);
1054
1055        let mut store = MemoryStore::default();
1056        let opts = VerifyOptions {
1057            max_age_secs: 300,
1058            future_skew_secs: 5,
1059            nonce_mode: NonceMode::Hmac {
1060                secret: secret.clone(),
1061                max_age_secs: 300,
1062                bind_htu_htm: true,
1063                bind_jkt: true,
1064            },
1065        };
1066        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1067            .await
1068            .unwrap_err();
1069        matches!(err, DpopError::UseDpopNonce { .. });
1070    }
1071
1072    #[tokio::test]
1073    async fn nonce_hmac_stale_prompts_use_dpop_nonce() {
1074        let (sk, x, y) = gen_es256_key();
1075        let now = OffsetDateTime::now_utc().unix_timestamp();
1076        let expected_htu = "https://ex.com/a";
1077        let expected_htm = "GET";
1078
1079        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1080        let secret: Arc<[u8]> = Arc::from(&b"secret-3"[..]);
1081        // Issue with ts older than max_age
1082        let issued_ts = now - 400;
1083        let nonce = issue_nonce(
1084            &secret,
1085            issued_ts,
1086            &crate::nonce::NonceCtx {
1087                htu: Some(expected_htu),
1088                htm: Some(expected_htm),
1089                jkt: Some(&jkt),
1090            },
1091        );
1092
1093        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1094        let p = serde_json::json!({
1095            "jti":"n-hmac-stale",
1096            "iat":now,
1097            "htm":expected_htm,
1098            "htu":expected_htu,
1099            "nonce": nonce
1100        });
1101        let jws = make_jws(&sk, h, p);
1102
1103        let mut store = MemoryStore::default();
1104        let opts = VerifyOptions {
1105            max_age_secs: 300,
1106            future_skew_secs: 5,
1107            nonce_mode: NonceMode::Hmac {
1108                secret: secret.clone(),
1109                max_age_secs: 300,
1110                bind_htu_htm: true,
1111                bind_jkt: true,
1112            },
1113        };
1114        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1115            .await
1116            .unwrap_err();
1117        matches!(err, DpopError::UseDpopNonce { .. });
1118    }
1119
1120    #[tokio::test]
1121    async fn nonce_hmac_future_skew_prompts_use_dpop_nonce() {
1122        let (sk, x, y) = gen_es256_key();
1123        let now = OffsetDateTime::now_utc().unix_timestamp();
1124        let expected_htu = "https://ex.com/a";
1125        let expected_htm = "GET";
1126
1127        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1128        let secret: Arc<[u8]> = Arc::from(&b"secret-4"[..]);
1129        // Issue with ts in the future beyond 5s tolerance
1130        let issued_ts = now + 10;
1131        let nonce = issue_nonce(
1132            &secret,
1133            issued_ts,
1134            &crate::nonce::NonceCtx {
1135                htu: Some(expected_htu),
1136                htm: Some(expected_htm),
1137                jkt: Some(&jkt),
1138            },
1139        );
1140
1141        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1142        let p = serde_json::json!({
1143            "jti":"n-hmac-future",
1144            "iat":now,
1145            "htm":expected_htm,
1146            "htu":expected_htu,
1147            "nonce": nonce
1148        });
1149        let jws = make_jws(&sk, h, p);
1150
1151        let mut store = MemoryStore::default();
1152        let opts = VerifyOptions {
1153            max_age_secs: 300,
1154            future_skew_secs: 5,
1155            nonce_mode: NonceMode::Hmac {
1156                secret: secret.clone(),
1157                max_age_secs: 300,
1158                bind_htu_htm: true,
1159                bind_jkt: true,
1160            },
1161        };
1162        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1163            .await
1164            .unwrap_err();
1165        matches!(err, DpopError::UseDpopNonce { .. });
1166    }
1167}