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, VerifyingKey};
13
14// Constants for signature and token validation
15const ECDSA_P256_SIGNATURE_LENGTH: usize = 64;
16#[cfg(feature = "eddsa")]
17const ED25519_SIGNATURE_LENGTH: usize = 64;
18const JTI_HASH_LENGTH: usize = 32;
19const JTI_MAX_LENGTH: usize = 512;
20
21#[derive(Deserialize)]
22struct DpopHeader {
23    typ: String,
24    alg: String,
25    jwk: Jwk,
26}
27
28#[derive(Deserialize)]
29#[serde(untagged)]
30enum Jwk {
31    EcP256 {
32        kty: String,
33        crv: String,
34        x: String,
35        y: String,
36    },
37    #[cfg(feature = "eddsa")]
38    OkpEd25519 { kty: String, crv: String, x: String },
39}
40
41#[derive(Clone, Debug)]
42pub enum NonceMode {
43    Disabled,
44    /// Require exact equality against `expected_nonce`
45    RequireEqual {
46        expected_nonce: String, // the nonce you previously issued
47    },
48    /// Stateless HMAC-based nonces: encode ts+rand+ctx and MAC it
49    Hmac {
50        secret: std::sync::Arc<[u8]>, // server secret
51        max_age_secs: i64,            // window (e.g., 300)
52        bind_htu_htm: bool,
53        bind_jkt: bool,
54    },
55}
56
57#[derive(Debug, Clone)]
58pub struct VerifyOptions {
59    pub max_age_secs: i64,
60    pub future_skew_secs: i64,
61    pub nonce_mode: NonceMode,
62}
63impl Default for VerifyOptions {
64    fn default() -> Self {
65        Self {
66            max_age_secs: 300,
67            future_skew_secs: 5,
68            nonce_mode: NonceMode::Disabled,
69        }
70    }
71}
72
73#[derive(Debug)]
74pub struct VerifiedDpop {
75    pub jkt: String,
76    pub jti: String,
77    pub iat: i64,
78}
79
80/// Helper struct for type-safe JTI hash handling
81struct JtiHash([u8; JTI_HASH_LENGTH]);
82
83impl JtiHash {
84    /// Create a JTI hash from the SHA-256 digest
85    fn from_jti(jti: &str) -> Self {
86        let mut hasher = Sha256::new();
87        hasher.update(jti.as_bytes());
88        let digest = hasher.finalize();
89        let mut hash = [0u8; JTI_HASH_LENGTH];
90        hash.copy_from_slice(&digest[..JTI_HASH_LENGTH]);
91        JtiHash(hash)
92    }
93
94    /// Get the inner array
95    fn as_array(&self) -> [u8; JTI_HASH_LENGTH] {
96        self.0
97    }
98}
99
100/// Parsed DPoP token structure
101struct DpopToken {
102    header: DpopHeader,
103    payload_b64: String,
104    signature_bytes: Vec<u8>,
105    signing_input: String,
106}
107
108/// Structured DPoP claims
109#[derive(Deserialize)]
110struct DpopClaims {
111    jti: String,
112    iat: i64,
113    htm: String,
114    htu: String,
115    #[serde(default)]
116    ath: Option<String>,
117    #[serde(default)]
118    nonce: Option<String>,
119}
120
121/// Main DPoP verifier with builder pattern
122pub struct DpopVerifier {
123    options: VerifyOptions,
124}
125
126impl DpopVerifier {
127    /// Create a new DPoP verifier with default options
128    pub fn new() -> Self {
129        Self {
130            options: VerifyOptions::default(),
131        }
132    }
133
134    /// Set the maximum age for DPoP proofs
135    pub fn with_max_age(mut self, max_age_secs: i64) -> Self {
136        self.options.max_age_secs = max_age_secs;
137        self
138    }
139
140    /// Set the future skew tolerance
141    pub fn with_future_skew(mut self, future_skew_secs: i64) -> Self {
142        self.options.future_skew_secs = future_skew_secs;
143        self
144    }
145
146    /// Set the nonce mode
147    pub fn with_nonce_mode(mut self, nonce_mode: NonceMode) -> Self {
148        self.options.nonce_mode = nonce_mode;
149        self
150    }
151
152    /// Verify a DPoP proof
153    pub async fn verify<S: ReplayStore + ?Sized>(
154        &self,
155        store: &mut S,
156        dpop_compact_jws: &str,
157        expected_htu: &str,
158        expected_htm: &str,
159        maybe_access_token: Option<&str>,
160    ) -> Result<VerifiedDpop, DpopError> {
161        // Parse the token
162        let token = self.parse_token(dpop_compact_jws)?;
163        
164        // Validate header
165        self.validate_header(&token.header)?;
166        
167        // Verify signature and compute JKT
168        let jkt = self.verify_signature_and_compute_jkt(&token)?;
169        
170        // Parse claims
171        let claims: DpopClaims = {
172            let bytes = B64.decode(&token.payload_b64).map_err(|_| DpopError::MalformedJws)?;
173            serde_json::from_slice(&bytes).map_err(|_| DpopError::MalformedJws)?
174        };
175        
176        // Validate JTI length
177        if claims.jti.len() > JTI_MAX_LENGTH {
178            return Err(DpopError::JtiTooLong);
179        }
180        
181        // Validate HTTP binding (HTM/HTU)
182        let (expected_htm_normalized, expected_htu_normalized) = 
183            self.validate_http_binding(&claims, expected_htm, expected_htu)?;
184        
185        // Validate access token binding if present
186        if let Some(access_token) = maybe_access_token {
187            self.validate_access_token_binding(&claims, access_token)?;
188        }
189        
190        // Check timestamp freshness
191        self.check_timestamp_freshness(claims.iat)?;
192        
193        // Validate nonce if required
194        self.validate_nonce_if_required(
195            &claims,
196            &expected_htu_normalized,
197            &expected_htm_normalized,
198            &jkt,
199        )?;
200        
201        // Prevent replay
202        let jti_hash = JtiHash::from_jti(&claims.jti);
203        self.prevent_replay(store, jti_hash, &claims, &jkt).await?;
204        
205        Ok(VerifiedDpop {
206            jkt,
207            jti: claims.jti,
208            iat: claims.iat,
209        })
210    }
211
212    /// Parse compact JWS into token components
213    fn parse_token(&self, dpop_compact_jws: &str) -> Result<DpopToken, DpopError> {
214        let mut jws_parts = dpop_compact_jws.split('.');
215        let (header_b64, payload_b64, signature_b64) = match (jws_parts.next(), jws_parts.next(), jws_parts.next()) {
216            (Some(h), Some(p), Some(s)) if jws_parts.next().is_none() => (h, p, s),
217            _ => return Err(DpopError::MalformedJws),
218        };
219
220        // Decode JOSE header
221        let header: DpopHeader = {
222            let bytes = B64.decode(header_b64).map_err(|_| DpopError::MalformedJws)?;
223            let val: serde_json::Value =
224                serde_json::from_slice(&bytes).map_err(|_| DpopError::MalformedJws)?;
225            // MUST NOT include private JWK material
226            if val.get("jwk").and_then(|j| j.get("d")).is_some() {
227                return Err(DpopError::BadJwk("jwk must not include 'd'"));
228            }
229            serde_json::from_value(val).map_err(|_| DpopError::MalformedJws)?
230        };
231
232        let signing_input = format!("{}.{}", header_b64, payload_b64);
233        let signature_bytes = B64.decode(signature_b64).map_err(|_| DpopError::InvalidSignature)?;
234
235        Ok(DpopToken {
236            header,
237            payload_b64: payload_b64.to_string(),
238            signature_bytes,
239            signing_input,
240        })
241    }
242
243    /// Validate the DPoP header (typ and alg checks)
244    fn validate_header(&self, header: &DpopHeader) -> Result<(), DpopError> {
245        if header.typ != "dpop+jwt" {
246            return Err(DpopError::MalformedJws);
247        }
248        Ok(())
249    }
250
251    /// Verify signature and compute JKT (JSON Key Thumbprint)
252    fn verify_signature_and_compute_jkt(&self, token: &DpopToken) -> Result<String, DpopError> {
253        let jkt = match (token.header.alg.as_str(), &token.header.jwk) {
254            ("ES256", Jwk::EcP256 { kty, crv, x, y }) if kty == "EC" && crv == "P-256" => {
255                if token.signature_bytes.len() != ECDSA_P256_SIGNATURE_LENGTH {
256                    return Err(DpopError::InvalidSignature);
257                }
258
259                let verifying_key: VerifyingKey = verifying_key_from_p256_xy(x, y)?;
260                let signature = p256::ecdsa::Signature::from_slice(&token.signature_bytes)
261                    .map_err(|_| DpopError::InvalidSignature)?;
262                verifying_key.verify(token.signing_input.as_bytes(), &signature)
263                    .map_err(|_| DpopError::InvalidSignature)?;
264                // compute EC thumbprint
265                thumbprint_ec_p256(x, y)?
266            }
267
268            #[cfg(feature = "eddsa")]
269            ("EdDSA", Jwk::OkpEd25519 { kty, crv, x }) if kty == "OKP" && crv == "Ed25519" => {
270                use ed25519_dalek::{Signature as EdSig, VerifyingKey as EdVk};
271                use signature::Verifier as _;
272
273                if token.signature_bytes.len() != ED25519_SIGNATURE_LENGTH {
274                    return Err(DpopError::InvalidSignature);
275                }
276
277                let verifying_key: EdVk = crate::jwk::verifying_key_from_okp_ed25519(x)?;
278                let signature = EdSig::from_slice(&token.signature_bytes)
279                    .map_err(|_| DpopError::InvalidSignature)?;
280                verifying_key.verify(token.signing_input.as_bytes(), &signature)
281                    .map_err(|_| DpopError::InvalidSignature)?;
282                crate::jwk::thumbprint_okp_ed25519(x)?
283            }
284
285            ("EdDSA", _) => return Err(DpopError::BadJwk("expect OKP/Ed25519 for EdDSA")),
286            ("ES256", _) => return Err(DpopError::BadJwk("expect EC/P-256 for ES256")),
287            ("none", _) => return Err(DpopError::InvalidAlg("none".into())),
288            (a, _) if a.starts_with("HS") => return Err(DpopError::InvalidAlg(a.into())),
289            (other, _) => return Err(DpopError::UnsupportedAlg(other.into())),
290        };
291
292        Ok(jkt)
293    }
294
295    /// Validate HTTP method and URI binding
296    fn validate_http_binding(
297        &self,
298        claims: &DpopClaims,
299        expected_htm: &str,
300        expected_htu: &str,
301    ) -> Result<(String, String), DpopError> {
302        // Strict method & URI checks (normalize both sides, then exact compare)
303        let expected_htm_normalized = normalize_method(expected_htm)?;
304        let actual_htm_normalized = normalize_method(&claims.htm)?;
305        if actual_htm_normalized != expected_htm_normalized {
306            return Err(DpopError::HtmMismatch);
307        }
308
309        let expected_htu_normalized = normalize_htu(expected_htu)?;
310        let actual_htu_normalized = normalize_htu(&claims.htu)?;
311        if actual_htu_normalized != expected_htu_normalized {
312            return Err(DpopError::HtuMismatch);
313        }
314
315        Ok((expected_htm_normalized, expected_htu_normalized))
316    }
317
318    /// Validate access token hash binding
319    fn validate_access_token_binding(
320        &self,
321        claims: &DpopClaims,
322        access_token: &str,
323    ) -> Result<(), DpopError> {
324        // Compute expected SHA-256 bytes of the exact token octets
325        let expected_hash = Sha256::digest(access_token.as_bytes());
326        
327        // Decode provided ath (must be base64url no-pad)
328        let ath_b64 = claims.ath.as_ref().ok_or(DpopError::MissingAth)?;
329        let actual_hash = B64
330            .decode(ath_b64.as_bytes())
331            .map_err(|_| DpopError::AthMalformed)?;
332        
333        // Constant-time compare of raw digests
334        if actual_hash.len() != expected_hash.len() || !bool::from(actual_hash.ct_eq(&expected_hash[..])) {
335            return Err(DpopError::AthMismatch);
336        }
337
338        Ok(())
339    }
340
341    /// Check timestamp freshness with configured limits
342    fn check_timestamp_freshness(&self, iat: i64) -> Result<(), DpopError> {
343        let current_time = OffsetDateTime::now_utc().unix_timestamp();
344        if iat > current_time + self.options.future_skew_secs {
345            return Err(DpopError::FutureSkew);
346        }
347        if current_time - iat > self.options.max_age_secs {
348            return Err(DpopError::Stale);
349        }
350        Ok(())
351    }
352
353    /// Validate nonce if required by configuration
354    fn validate_nonce_if_required(
355        &self,
356        claims: &DpopClaims,
357        expected_htu_normalized: &str,
358        expected_htm_normalized: &str,
359        jkt: &str,
360    ) -> Result<(), DpopError> {
361        match &self.options.nonce_mode {
362            NonceMode::Disabled => { /* do nothing */ }
363            NonceMode::RequireEqual { expected_nonce } => {
364                let nonce_value = claims.nonce.as_ref().ok_or(DpopError::MissingNonce)?;
365                if nonce_value != expected_nonce {
366                    let fresh_nonce = expected_nonce.to_string();
367                    return Err(DpopError::UseDpopNonce { nonce: fresh_nonce });
368                }
369            }
370            NonceMode::Hmac {
371                secret,
372                max_age_secs,
373                bind_htu_htm,
374                bind_jkt,
375            } => {
376                let nonce_value = match &claims.nonce {
377                    Some(s) => s.as_str(),
378                    None => {
379                        // Missing → ask client to retry with nonce
380                        let current_time = time::OffsetDateTime::now_utc().unix_timestamp();
381                        let nonce_ctx = crate::nonce::NonceCtx {
382                            htu: if *bind_htu_htm {
383                                Some(expected_htu_normalized)
384                            } else {
385                                None
386                            },
387                            htm: if *bind_htu_htm {
388                                Some(expected_htm_normalized)
389                            } else {
390                                None
391                            },
392                            jkt: if *bind_jkt { Some(jkt) } else { None },
393                        };
394                        let fresh_nonce = crate::nonce::issue_nonce(secret, current_time, &nonce_ctx)?;
395                        return Err(DpopError::UseDpopNonce { nonce: fresh_nonce });
396                    }
397                };
398                
399                let current_time = time::OffsetDateTime::now_utc().unix_timestamp();
400                let nonce_ctx = crate::nonce::NonceCtx {
401                    htu: if *bind_htu_htm {
402                        Some(expected_htu_normalized)
403                    } else {
404                        None
405                    },
406                    htm: if *bind_htu_htm {
407                        Some(expected_htm_normalized)
408                    } else {
409                        None
410                    },
411                    jkt: if *bind_jkt { Some(jkt) } else { None },
412                };
413                
414                if crate::nonce::verify_nonce(secret, nonce_value, current_time, *max_age_secs, &nonce_ctx).is_err() {
415                    // On invalid/stale → emit NEW nonce so client can retry immediately
416                    let fresh_nonce = crate::nonce::issue_nonce(secret, current_time, &nonce_ctx)?;
417                    return Err(DpopError::UseDpopNonce { nonce: fresh_nonce });
418                }
419            }
420        }
421        Ok(())
422    }
423
424    /// Prevent replay attacks using the replay store
425    async fn prevent_replay<S: ReplayStore + ?Sized>(
426        &self,
427        store: &mut S,
428        jti_hash: JtiHash,
429        claims: &DpopClaims,
430        jkt: &str,
431    ) -> Result<(), DpopError> {
432        let is_first_use = store
433            .insert_once(
434                jti_hash.as_array(),
435                ReplayContext {
436                    jkt: Some(jkt),
437                    htm: Some(&claims.htm),
438                    htu: Some(&claims.htu),
439                    iat: claims.iat,
440                },
441            )
442            .await?;
443        
444        if !is_first_use {
445            return Err(DpopError::Replay);
446        }
447
448        Ok(())
449    }
450}
451
452impl Default for DpopVerifier {
453    fn default() -> Self {
454        Self::new()
455    }
456}
457
458/// Verify DPoP proof and record the jti to prevent replays.
459/// 
460/// # Deprecated
461/// This function is maintained for backward compatibility. New code should use `DpopVerifier` instead.
462/// See the `DpopVerifier` documentation for usage examples.
463#[deprecated(since = "2.0.0", note = "Use DpopVerifier instead")]
464pub async fn verify_proof<S: ReplayStore + ?Sized>(
465    store: &mut S,
466    dpop_compact_jws: &str,
467    expected_htu: &str,
468    expected_htm: &str,
469    maybe_access_token: Option<&str>,
470    opts: VerifyOptions,
471) -> Result<VerifiedDpop, DpopError> {
472    let verifier = DpopVerifier {
473        options: opts,
474    };
475    verifier.verify(store, dpop_compact_jws, expected_htu, expected_htm, maybe_access_token).await
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::jwk::thumbprint_ec_p256;
482    use crate::nonce::issue_nonce;
483    use p256::ecdsa::{signature::Signer, Signature, SigningKey};
484    use rand_core::OsRng;
485    use std::sync::Arc;
486
487    // ---- helpers ----------------------------------------------------------------
488
489    fn gen_es256_key() -> (SigningKey, String, String) {
490        let signing_key = SigningKey::random(&mut OsRng);
491        let verifying_key = VerifyingKey::from(&signing_key);
492        let encoded_point = verifying_key.to_encoded_point(false);
493        let x_coordinate = B64.encode(encoded_point.x().unwrap());
494        let y_coordinate = B64.encode(encoded_point.y().unwrap());
495        (signing_key, x_coordinate, y_coordinate)
496    }
497
498    fn make_jws(
499        signing_key: &SigningKey,
500        header_json: serde_json::Value,
501        claims_json: serde_json::Value,
502    ) -> String {
503        let header_bytes = serde_json::to_vec(&header_json).unwrap();
504        let payload_bytes = serde_json::to_vec(&claims_json).unwrap();
505        let header_b64 = B64.encode(header_bytes);
506        let payload_b64 = B64.encode(payload_bytes);
507        let signing_input = format!("{header_b64}.{payload_b64}");
508        let signature: Signature = signing_key.sign(signing_input.as_bytes());
509        let signature_b64 = B64.encode(signature.to_bytes());
510        format!("{header_b64}.{payload_b64}.{signature_b64}")
511    }
512
513    #[derive(Default)]
514    struct MemoryStore(std::collections::HashSet<[u8; 32]>);
515
516    #[async_trait::async_trait]
517    impl ReplayStore for MemoryStore {
518        async fn insert_once(
519            &mut self,
520            jti_hash: [u8; 32],
521            _ctx: ReplayContext<'_>,
522        ) -> Result<bool, DpopError> {
523            Ok(self.0.insert(jti_hash))
524        }
525    }
526    // ---- tests ------------------------------------------------------------------
527    #[test]
528    fn thumbprint_has_expected_length_and_no_padding() {
529        // 32 zero bytes -> base64url = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" (43 chars)
530        let x = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
531        let y = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
532        let t1 = thumbprint_ec_p256(x, y).expect("thumbprint");
533        let t2 = thumbprint_ec_p256(x, y).expect("thumbprint");
534        // deterministic and base64url w/out '=' padding; sha256 -> 43 chars
535        assert_eq!(t1, t2);
536        assert_eq!(t1.len(), 43);
537        assert!(!t1.contains('='));
538    }
539
540    #[test]
541    fn decoding_key_rejects_wrong_sizes() {
542        // 31-byte x (trimmed), 32-byte y
543        let bad_x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 31]);
544        let good_y = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 32]);
545        let res = crate::jwk::verifying_key_from_p256_xy(&bad_x, &good_y);
546        assert!(res.is_err(), "expected error for bad y");
547
548        // 32-byte x, 33-byte y
549        let good_x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 32]);
550        let bad_y = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 33]);
551        let res = crate::jwk::verifying_key_from_p256_xy(&good_x, &bad_y);
552        assert!(res.is_err(), "expected error for bad y");
553    }
554
555    #[tokio::test]
556    async fn replay_store_trait_basic() {
557        use async_trait::async_trait;
558        use std::collections::HashSet;
559
560        struct MemoryStore(HashSet<[u8; 32]>);
561
562        #[async_trait]
563        impl ReplayStore for MemoryStore {
564            async fn insert_once(
565                &mut self,
566                jti_hash: [u8; 32],
567                _ctx: ReplayContext<'_>,
568            ) -> Result<bool, DpopError> {
569                Ok(self.0.insert(jti_hash))
570            }
571        }
572
573        let mut s = MemoryStore(HashSet::new());
574        let first = s
575            .insert_once(
576                [42u8; 32],
577                ReplayContext {
578                    jkt: Some("j"),
579                    htm: Some("POST"),
580                    htu: Some("https://ex"),
581                    iat: 0,
582                },
583            )
584            .await
585            .unwrap();
586        let second = s
587            .insert_once(
588                [42u8; 32],
589                ReplayContext {
590                    jkt: Some("j"),
591                    htm: Some("POST"),
592                    htu: Some("https://ex"),
593                    iat: 0,
594                },
595            )
596            .await
597            .unwrap();
598        assert!(first);
599        assert!(!second); // replay detected
600    }
601    #[tokio::test]
602    async fn verify_valid_es256_proof() {
603        let (sk, x, y) = gen_es256_key();
604        let now = OffsetDateTime::now_utc().unix_timestamp();
605        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
606        let p = serde_json::json!({"jti":"j1","iat":now,"htm":"GET","htu":"https://api.example.com/resource"});
607        let jws = make_jws(&sk, h, p);
608
609        let mut store = MemoryStore::default();
610        let res = verify_proof(
611            &mut store,
612            &jws,
613            "https://api.example.com/resource",
614            "GET",
615            None,
616            VerifyOptions::default(),
617        )
618        .await;
619        assert!(res.is_ok(), "{res:?}");
620    }
621
622    #[tokio::test]
623    async fn method_normalization_allows_lowercase_claim() {
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        let p = serde_json::json!({"jti":"j2","iat":now,"htm":"get","htu":"https://ex.com/a"});
628        let jws = make_jws(&sk, h, p);
629
630        let mut store = MemoryStore::default();
631        assert!(verify_proof(
632            &mut store,
633            &jws,
634            "https://ex.com/a",
635            "GET",
636            None,
637            VerifyOptions::default()
638        )
639        .await
640        .is_ok());
641    }
642
643    #[tokio::test]
644    async fn htu_normalizes_dot_segments_and_default_ports_and_strips_qf() {
645        let (sk, x, y) = gen_es256_key();
646        let now = OffsetDateTime::now_utc().unix_timestamp();
647        // claim has :443, dot-segment, query and fragment
648        let claim_htu = "https://EX.COM:443/a/../b?q=1#frag";
649        let expect_htu = "https://ex.com/b";
650        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
651        let p = serde_json::json!({"jti":"j3","iat":now,"htm":"GET","htu":claim_htu});
652        let jws = make_jws(&sk, h, p);
653
654        let mut store = MemoryStore::default();
655        assert!(verify_proof(
656            &mut store,
657            &jws,
658            expect_htu,
659            "GET",
660            None,
661            VerifyOptions::default()
662        )
663        .await
664        .is_ok());
665    }
666
667    #[tokio::test]
668    async fn htu_path_case_mismatch_fails() {
669        let (sk, x, y) = gen_es256_key();
670        let now = OffsetDateTime::now_utc().unix_timestamp();
671        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
672        let p = serde_json::json!({"jti":"j4","iat":now,"htm":"GET","htu":"https://ex.com/API"});
673        let jws = make_jws(&sk, h, p);
674
675        let mut store = MemoryStore::default();
676        let err = verify_proof(
677            &mut store,
678            &jws,
679            "https://ex.com/api",
680            "GET",
681            None,
682            VerifyOptions::default(),
683        )
684        .await
685        .unwrap_err();
686        matches!(err, DpopError::HtuMismatch);
687    }
688
689    #[tokio::test]
690    async fn alg_none_rejected() {
691        let (sk, x, y) = gen_es256_key();
692        let now = OffsetDateTime::now_utc().unix_timestamp();
693        // still sign, but "alg":"none" must be rejected before/independent of signature
694        let h = serde_json::json!({"typ":"dpop+jwt","alg":"none","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
695        let p = serde_json::json!({"jti":"j5","iat":now,"htm":"GET","htu":"https://ex.com/a"});
696        let jws = make_jws(&sk, h, p);
697
698        let mut store = MemoryStore::default();
699        let err = verify_proof(
700            &mut store,
701            &jws,
702            "https://ex.com/a",
703            "GET",
704            None,
705            VerifyOptions::default(),
706        )
707        .await
708        .unwrap_err();
709        matches!(err, DpopError::InvalidAlg(_));
710    }
711
712    #[tokio::test]
713    async fn alg_hs256_rejected() {
714        let (sk, x, y) = gen_es256_key();
715        let now = OffsetDateTime::now_utc().unix_timestamp();
716        let h = serde_json::json!({"typ":"dpop+jwt","alg":"HS256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
717        let p = serde_json::json!({"jti":"j6","iat":now,"htm":"GET","htu":"https://ex.com/a"});
718        let jws = make_jws(&sk, h, p);
719
720        let mut store = MemoryStore::default();
721        let err = verify_proof(
722            &mut store,
723            &jws,
724            "https://ex.com/a",
725            "GET",
726            None,
727            VerifyOptions::default(),
728        )
729        .await
730        .unwrap_err();
731        matches!(err, DpopError::InvalidAlg(_));
732    }
733
734    #[tokio::test]
735    async fn jwk_with_private_d_rejected() {
736        let (sk, x, y) = gen_es256_key();
737        let now = OffsetDateTime::now_utc().unix_timestamp();
738        // inject "d" (any string) -> must be rejected
739        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y,"d":"AAAA"}});
740        let p = serde_json::json!({"jti":"j7","iat":now,"htm":"GET","htu":"https://ex.com/a"});
741        let jws = make_jws(&sk, h, p);
742
743        let mut store = MemoryStore::default();
744        let err = verify_proof(
745            &mut store,
746            &jws,
747            "https://ex.com/a",
748            "GET",
749            None,
750            VerifyOptions::default(),
751        )
752        .await
753        .unwrap_err();
754        matches!(err, DpopError::BadJwk(_));
755    }
756
757    #[tokio::test]
758    async fn ath_binding_ok_and_mismatch_and_padded_rejected() {
759        let (sk, x, y) = gen_es256_key();
760        let now = OffsetDateTime::now_utc().unix_timestamp();
761        let at = "access.token.string";
762        let ath = B64.encode(Sha256::digest(at.as_bytes()));
763        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
764
765        // OK
766        let p_ok = serde_json::json!({"jti":"j8","iat":now,"htm":"GET","htu":"https://ex.com/a","ath":ath});
767        let jws_ok = make_jws(&sk, h.clone(), p_ok);
768        let mut store = MemoryStore::default();
769        assert!(verify_proof(
770            &mut store,
771            &jws_ok,
772            "https://ex.com/a",
773            "GET",
774            Some(at),
775            VerifyOptions::default()
776        )
777        .await
778        .is_ok());
779
780        // Mismatch
781        let p_bad = serde_json::json!({"jti":"j9","iat":now,"htm":"GET","htu":"https://ex.com/a","ath":ath});
782        let jws_bad = make_jws(&sk, h.clone(), p_bad);
783        let mut store2 = MemoryStore::default();
784        let err = verify_proof(
785            &mut store2,
786            &jws_bad,
787            "https://ex.com/a",
788            "GET",
789            Some("different.token"),
790            VerifyOptions::default(),
791        )
792        .await
793        .unwrap_err();
794        matches!(err, DpopError::AthMismatch);
795
796        // Padded ath should be rejected as malformed (engine is URL_SAFE_NO_PAD)
797        let ath_padded = format!("{ath}==");
798        let p_pad = serde_json::json!({"jti":"j10","iat":now,"htm":"GET","htu":"https://ex.com/a","ath":ath_padded});
799        let jws_pad = make_jws(&sk, h.clone(), p_pad);
800        let mut store3 = MemoryStore::default();
801        let err = verify_proof(
802            &mut store3,
803            &jws_pad,
804            "https://ex.com/a",
805            "GET",
806            Some(at),
807            VerifyOptions::default(),
808        )
809        .await
810        .unwrap_err();
811        matches!(err, DpopError::AthMalformed);
812    }
813
814    #[tokio::test]
815    async fn freshness_future_skew_and_stale() {
816        let (sk, x, y) = gen_es256_key();
817        let now = OffsetDateTime::now_utc().unix_timestamp();
818        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
819
820        // Future skew just over limit
821        let p_future =
822            serde_json::json!({"jti":"jf","iat":now + 6,"htm":"GET","htu":"https://ex.com/a"});
823        let jws_future = make_jws(&sk, h.clone(), p_future);
824        let mut store1 = MemoryStore::default();
825        let opts = VerifyOptions {
826            max_age_secs: 300,
827            future_skew_secs: 5,
828            nonce_mode: NonceMode::Disabled,
829        };
830        let err = verify_proof(
831            &mut store1,
832            &jws_future,
833            "https://ex.com/a",
834            "GET",
835            None,
836            opts,
837        )
838        .await
839        .unwrap_err();
840        matches!(err, DpopError::FutureSkew);
841
842        // Stale just over limit
843        let p_stale =
844            serde_json::json!({"jti":"js","iat":now - 301,"htm":"GET","htu":"https://ex.com/a"});
845        let jws_stale = make_jws(&sk, h.clone(), p_stale);
846        let mut store2 = MemoryStore::default();
847        let opts = VerifyOptions {
848            max_age_secs: 300,
849            future_skew_secs: 5,
850            nonce_mode: NonceMode::Disabled,
851        };
852        let err = verify_proof(
853            &mut store2,
854            &jws_stale,
855            "https://ex.com/a",
856            "GET",
857            None,
858            opts,
859        )
860        .await
861        .unwrap_err();
862        matches!(err, DpopError::Stale);
863    }
864
865    #[tokio::test]
866    async fn replay_same_jti_is_rejected() {
867        let (sk, x, y) = gen_es256_key();
868        let now = OffsetDateTime::now_utc().unix_timestamp();
869        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
870        let p = serde_json::json!({"jti":"jr","iat":now,"htm":"GET","htu":"https://ex.com/a"});
871        let jws = make_jws(&sk, h, p);
872
873        let mut store = MemoryStore::default();
874        let ok1 = verify_proof(
875            &mut store,
876            &jws,
877            "https://ex.com/a",
878            "GET",
879            None,
880            VerifyOptions::default(),
881        )
882        .await;
883        assert!(ok1.is_ok());
884        let err = verify_proof(
885            &mut store,
886            &jws,
887            "https://ex.com/a",
888            "GET",
889            None,
890            VerifyOptions::default(),
891        )
892        .await
893        .unwrap_err();
894        matches!(err, DpopError::Replay);
895    }
896
897    #[tokio::test]
898    async fn signature_tamper_detected() {
899        let (sk, x, y) = gen_es256_key();
900        let now = OffsetDateTime::now_utc().unix_timestamp();
901        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
902        let p = serde_json::json!({"jti":"jt","iat":now,"htm":"GET","htu":"https://ex.com/a"});
903        let mut jws = make_jws(&sk, h, p);
904
905        // Flip one byte in the payload section (keep base64url valid length)
906        let bytes = unsafe { jws.as_bytes_mut() }; // alternative: rebuild string
907                                                   // Find the second '.' and flip a safe ASCII char before it
908        let mut dot_count = 0usize;
909        for i in 0..bytes.len() {
910            if bytes[i] == b'.' {
911                dot_count += 1;
912                if dot_count == 2 && i > 10 {
913                    bytes[i - 5] ^= 0x01; // tiny flip
914                    break;
915                }
916            }
917        }
918
919        let mut store = MemoryStore::default();
920        let err = verify_proof(
921            &mut store,
922            &jws,
923            "https://ex.com/a",
924            "GET",
925            None,
926            VerifyOptions::default(),
927        )
928        .await
929        .unwrap_err();
930        matches!(err, DpopError::InvalidSignature);
931    }
932
933    #[tokio::test]
934    async fn method_mismatch_rejected() {
935        let (sk, x, y) = gen_es256_key();
936        let now = OffsetDateTime::now_utc().unix_timestamp();
937        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
938        let p = serde_json::json!({"jti":"jm","iat":now,"htm":"POST","htu":"https://ex.com/a"});
939        let jws = make_jws(&sk, h, p);
940
941        let mut store = MemoryStore::default();
942        let err = verify_proof(
943            &mut store,
944            &jws,
945            "https://ex.com/a",
946            "GET",
947            None,
948            VerifyOptions::default(),
949        )
950        .await
951        .unwrap_err();
952        matches!(err, DpopError::HtmMismatch);
953    }
954
955    #[test]
956    fn normalize_helpers_examples() {
957        // sanity checks for helpers used by verify_proof
958        assert_eq!(
959            normalize_htu("https://EX.com:443/a/./b/../c?x=1#frag").unwrap(),
960            "https://ex.com/a/c"
961        );
962        assert_eq!(normalize_method("get").unwrap(), "GET");
963        assert!(normalize_method("CUSTOM").is_err());
964    }
965
966    #[tokio::test]
967    async fn jti_too_long_rejected() {
968        let (sk, x, y) = gen_es256_key();
969        let now = OffsetDateTime::now_utc().unix_timestamp();
970        let too_long = "x".repeat(513);
971        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
972        let p = serde_json::json!({"jti":too_long,"iat":now,"htm":"GET","htu":"https://ex.com/a"});
973        let jws = make_jws(&sk, h, p);
974
975        let mut store = MemoryStore::default();
976        let err = verify_proof(
977            &mut store,
978            &jws,
979            "https://ex.com/a",
980            "GET",
981            None,
982            VerifyOptions::default(),
983        )
984        .await
985        .unwrap_err();
986        matches!(err, DpopError::JtiTooLong);
987    }
988    // ----------------------- Nonce: RequireEqual -------------------------------
989
990    #[tokio::test]
991    async fn nonce_require_equal_ok() {
992        let (sk, x, y) = gen_es256_key();
993        let now = OffsetDateTime::now_utc().unix_timestamp();
994        let expected_htu = "https://ex.com/a";
995        let expected_htm = "GET";
996
997        let expected_nonce = "nonce-123";
998        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
999        let p = serde_json::json!({
1000            "jti":"n-reqeq-ok",
1001            "iat":now,
1002            "htm":expected_htm,
1003            "htu":expected_htu,
1004            "nonce": expected_nonce
1005        });
1006        let jws = make_jws(&sk, h, p);
1007
1008        let mut store = MemoryStore::default();
1009        let opts = VerifyOptions {
1010            max_age_secs: 300,
1011            future_skew_secs: 5,
1012            nonce_mode: NonceMode::RequireEqual {
1013                expected_nonce: expected_nonce.to_string(),
1014            },
1015        };
1016        assert!(
1017            verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1018                .await
1019                .is_ok()
1020        );
1021    }
1022
1023    #[tokio::test]
1024    async fn nonce_require_equal_missing_claim() {
1025        let (sk, x, y) = gen_es256_key();
1026        let now = OffsetDateTime::now_utc().unix_timestamp();
1027        let expected_htu = "https://ex.com/a";
1028        let expected_htm = "GET";
1029
1030        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1031        let p = serde_json::json!({
1032            "jti":"n-reqeq-miss",
1033            "iat":now,
1034            "htm":expected_htm,
1035            "htu":expected_htu
1036        });
1037        let jws = make_jws(&sk, h, p);
1038
1039        let mut store = MemoryStore::default();
1040        let opts = VerifyOptions {
1041            max_age_secs: 300,
1042            future_skew_secs: 5,
1043            nonce_mode: NonceMode::RequireEqual {
1044                expected_nonce: "x".into(),
1045            },
1046        };
1047        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1048            .await
1049            .unwrap_err();
1050        matches!(err, DpopError::MissingNonce);
1051    }
1052
1053    #[tokio::test]
1054    async fn nonce_require_equal_mismatch_yields_usedpopnonce() {
1055        let (sk, x, y) = gen_es256_key();
1056        let now = OffsetDateTime::now_utc().unix_timestamp();
1057        let expected_htu = "https://ex.com/a";
1058        let expected_htm = "GET";
1059
1060        let claim_nonce = "client-value";
1061        let expected_nonce = "server-expected";
1062        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1063        let p = serde_json::json!({
1064            "jti":"n-reqeq-mis",
1065            "iat":now,
1066            "htm":expected_htm,
1067            "htu":expected_htu,
1068            "nonce": claim_nonce
1069        });
1070        let jws = make_jws(&sk, h, p);
1071
1072        let mut store = MemoryStore::default();
1073        let opts = VerifyOptions {
1074            max_age_secs: 300,
1075            future_skew_secs: 5,
1076            nonce_mode: NonceMode::RequireEqual {
1077                expected_nonce: expected_nonce.into(),
1078            },
1079        };
1080        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1081            .await
1082            .unwrap_err();
1083        // Server should respond with UseDpopNonce carrying a fresh/expected nonce
1084        if let DpopError::UseDpopNonce { nonce } = err {
1085            assert_eq!(nonce, expected_nonce);
1086        } else {
1087            panic!("expected UseDpopNonce, got {err:?}");
1088        }
1089    }
1090
1091    // -------------------------- Nonce: HMAC ------------------------------------
1092
1093    #[tokio::test]
1094    async fn nonce_hmac_ok_bound_all() {
1095        let (sk, x, y) = gen_es256_key();
1096        let now = OffsetDateTime::now_utc().unix_timestamp();
1097        let expected_htu = "https://ex.com/a";
1098        let expected_htm = "GET";
1099
1100        // Compute jkt from header jwk x/y to match verifier's jkt
1101        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1102
1103        let secret: Arc<[u8]> = Arc::from(&b"supersecret"[..]);
1104        let ctx = crate::nonce::NonceCtx {
1105            htu: Some(expected_htu),
1106            htm: Some(expected_htm),
1107            jkt: Some(&jkt),
1108        };
1109        let nonce = issue_nonce(&secret, now, &ctx).expect("issue_nonce");
1110
1111        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1112        let p = serde_json::json!({
1113            "jti":"n-hmac-ok",
1114            "iat":now,
1115            "htm":expected_htm,
1116            "htu":expected_htu,
1117            "nonce": nonce
1118        });
1119        let jws = make_jws(&sk, h, p);
1120
1121        let mut store = MemoryStore::default();
1122        let opts = VerifyOptions {
1123            max_age_secs: 300,
1124            future_skew_secs: 5,
1125            nonce_mode: NonceMode::Hmac {
1126                secret: secret.clone(),
1127                max_age_secs: 300,
1128                bind_htu_htm: true,
1129                bind_jkt: true,
1130            },
1131        };
1132        assert!(
1133            verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1134                .await
1135                .is_ok()
1136        );
1137    }
1138
1139    #[tokio::test]
1140    async fn nonce_hmac_missing_claim_prompts_use_dpop_nonce() {
1141        let (sk, x, y) = gen_es256_key();
1142        let now = OffsetDateTime::now_utc().unix_timestamp();
1143        let expected_htu = "https://ex.com/a";
1144        let expected_htm = "GET";
1145
1146        let secret: Arc<[u8]> = Arc::from(&b"supersecret"[..]);
1147
1148        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1149        let p = serde_json::json!({
1150            "jti":"n-hmac-miss",
1151            "iat":now,
1152            "htm":expected_htm,
1153            "htu":expected_htu
1154        });
1155        let jws = make_jws(&sk, h, p);
1156
1157        let mut store = MemoryStore::default();
1158        let opts = VerifyOptions {
1159            max_age_secs: 300,
1160            future_skew_secs: 5,
1161            nonce_mode: NonceMode::Hmac {
1162                secret: secret.clone(),
1163                max_age_secs: 300,
1164                bind_htu_htm: true,
1165                bind_jkt: true,
1166            },
1167        };
1168        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1169            .await
1170            .unwrap_err();
1171        matches!(err, DpopError::UseDpopNonce { .. });
1172    }
1173
1174    #[tokio::test]
1175    async fn nonce_hmac_wrong_htu_prompts_use_dpop_nonce() {
1176        let (sk, x, y) = gen_es256_key();
1177        let now = OffsetDateTime::now_utc().unix_timestamp();
1178        let expected_htm = "GET";
1179        let expected_htu = "https://ex.com/correct";
1180
1181        // Bind nonce to a different HTU to force mismatch
1182        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1183        let secret: Arc<[u8]> = Arc::from(&b"k"[..]);
1184        let ctx_wrong = crate::nonce::NonceCtx {
1185            htu: Some("https://ex.com/wrong"),
1186            htm: Some(expected_htm),
1187            jkt: Some(&jkt),
1188        };
1189        let nonce = issue_nonce(&secret, now, &ctx_wrong).expect("issue_nonce");
1190
1191        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1192        let p = serde_json::json!({
1193            "jti":"n-hmac-htu-mis",
1194            "iat":now,
1195            "htm":expected_htm,
1196            "htu":expected_htu,
1197            "nonce": nonce
1198        });
1199        let jws = make_jws(&sk, h, p);
1200
1201        let mut store = MemoryStore::default();
1202        let opts = VerifyOptions {
1203            max_age_secs: 300,
1204            future_skew_secs: 5,
1205            nonce_mode: NonceMode::Hmac {
1206                secret: secret.clone(),
1207                max_age_secs: 300,
1208                bind_htu_htm: true,
1209                bind_jkt: true,
1210            },
1211        };
1212        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1213            .await
1214            .unwrap_err();
1215        matches!(err, DpopError::UseDpopNonce { .. });
1216    }
1217
1218    #[tokio::test]
1219    async fn nonce_hmac_wrong_jkt_prompts_use_dpop_nonce() {
1220        // Create two keys; mint nonce with jkt from key A, but sign proof with key B
1221        let (_sk_a, x_a, y_a) = gen_es256_key();
1222        let (sk_b, x_b, y_b) = gen_es256_key();
1223        let now = OffsetDateTime::now_utc().unix_timestamp();
1224        let expected_htu = "https://ex.com/a";
1225        let expected_htm = "GET";
1226
1227        let jkt_a = thumbprint_ec_p256(&x_a, &y_a).unwrap();
1228        let secret: Arc<[u8]> = Arc::from(&b"secret-2"[..]);
1229        let ctx = crate::nonce::NonceCtx {
1230            htu: Some(expected_htu),
1231            htm: Some(expected_htm),
1232            jkt: Some(&jkt_a), // bind nonce to A's jkt
1233        };
1234        let nonce = issue_nonce(&secret, now, &ctx).expect("issue_nonce");
1235
1236        // Build proof with key B (=> jkt != jkt_a)
1237        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x_b,"y":y_b}});
1238        let p = serde_json::json!({
1239            "jti":"n-hmac-jkt-mis",
1240            "iat":now,
1241            "htm":expected_htm,
1242            "htu":expected_htu,
1243            "nonce": nonce
1244        });
1245        let jws = make_jws(&sk_b, h, p);
1246
1247        let mut store = MemoryStore::default();
1248        let opts = VerifyOptions {
1249            max_age_secs: 300,
1250            future_skew_secs: 5,
1251            nonce_mode: NonceMode::Hmac {
1252                secret: secret.clone(),
1253                max_age_secs: 300,
1254                bind_htu_htm: true,
1255                bind_jkt: true,
1256            },
1257        };
1258        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1259            .await
1260            .unwrap_err();
1261        matches!(err, DpopError::UseDpopNonce { .. });
1262    }
1263
1264    #[tokio::test]
1265    async fn nonce_hmac_stale_prompts_use_dpop_nonce() {
1266        let (sk, x, y) = gen_es256_key();
1267        let now = OffsetDateTime::now_utc().unix_timestamp();
1268        let expected_htu = "https://ex.com/a";
1269        let expected_htm = "GET";
1270
1271        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1272        let secret: Arc<[u8]> = Arc::from(&b"secret-3"[..]);
1273        // Issue with ts older than max_age
1274        let issued_ts = now - 400;
1275        let nonce = issue_nonce(
1276            &secret,
1277            issued_ts,
1278            &crate::nonce::NonceCtx {
1279                htu: Some(expected_htu),
1280                htm: Some(expected_htm),
1281                jkt: Some(&jkt),
1282            },
1283        ).expect("issue_nonce");
1284
1285        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1286        let p = serde_json::json!({
1287            "jti":"n-hmac-stale",
1288            "iat":now,
1289            "htm":expected_htm,
1290            "htu":expected_htu,
1291            "nonce": nonce
1292        });
1293        let jws = make_jws(&sk, h, p);
1294
1295        let mut store = MemoryStore::default();
1296        let opts = VerifyOptions {
1297            max_age_secs: 300,
1298            future_skew_secs: 5,
1299            nonce_mode: NonceMode::Hmac {
1300                secret: secret.clone(),
1301                max_age_secs: 300,
1302                bind_htu_htm: true,
1303                bind_jkt: true,
1304            },
1305        };
1306        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1307            .await
1308            .unwrap_err();
1309        matches!(err, DpopError::UseDpopNonce { .. });
1310    }
1311
1312    #[tokio::test]
1313    async fn nonce_hmac_future_skew_prompts_use_dpop_nonce() {
1314        let (sk, x, y) = gen_es256_key();
1315        let now = OffsetDateTime::now_utc().unix_timestamp();
1316        let expected_htu = "https://ex.com/a";
1317        let expected_htm = "GET";
1318
1319        let jkt = thumbprint_ec_p256(&x, &y).unwrap();
1320        let secret: Arc<[u8]> = Arc::from(&b"secret-4"[..]);
1321        // Issue with ts in the future beyond 5s tolerance
1322        let issued_ts = now + 10;
1323        let nonce = issue_nonce(
1324            &secret,
1325            issued_ts,
1326            &crate::nonce::NonceCtx {
1327                htu: Some(expected_htu),
1328                htm: Some(expected_htm),
1329                jkt: Some(&jkt),
1330            },
1331        ).expect("issue_nonce");
1332
1333        let h = serde_json::json!({"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","crv":"P-256","x":x,"y":y}});
1334        let p = serde_json::json!({
1335            "jti":"n-hmac-future",
1336            "iat":now,
1337            "htm":expected_htm,
1338            "htu":expected_htu,
1339            "nonce": nonce
1340        });
1341        let jws = make_jws(&sk, h, p);
1342
1343        let mut store = MemoryStore::default();
1344        let opts = VerifyOptions {
1345            max_age_secs: 300,
1346            future_skew_secs: 5,
1347            nonce_mode: NonceMode::Hmac {
1348                secret: secret.clone(),
1349                max_age_secs: 300,
1350                bind_htu_htm: true,
1351                bind_jkt: true,
1352            },
1353        };
1354        let err = verify_proof(&mut store, &jws, expected_htu, expected_htm, None, opts)
1355            .await
1356            .unwrap_err();
1357        matches!(err, DpopError::UseDpopNonce { .. });
1358    }
1359
1360    #[cfg(feature = "eddsa")]
1361    mod eddsa_tests {
1362        use super::*;
1363        use ed25519_dalek::Signer;
1364        use ed25519_dalek::{Signature as EdSig, SigningKey as EdSk, VerifyingKey as EdVk};
1365        use rand_core::OsRng;
1366
1367        fn gen_ed25519() -> (EdSk, String) {
1368            let sk = EdSk::generate(&mut OsRng);
1369            let vk = EdVk::from(&sk);
1370            let x_b64 = B64.encode(vk.as_bytes()); // 32-byte public key
1371            (sk, x_b64)
1372        }
1373
1374        fn make_jws_ed(sk: &EdSk, header: serde_json::Value, claims: serde_json::Value) -> String {
1375            let h = serde_json::to_vec(&header).unwrap();
1376            let p = serde_json::to_vec(&claims).unwrap();
1377            let h_b64 = B64.encode(h);
1378            let p_b64 = B64.encode(p);
1379            let signing_input = format!("{h_b64}.{p_b64}");
1380            let sig: EdSig = sk.sign(signing_input.as_bytes());
1381            let s_b64 = B64.encode(sig.to_bytes());
1382            format!("{h_b64}.{p_b64}.{s_b64}")
1383        }
1384
1385        #[tokio::test]
1386        async fn verify_valid_eddsa_proof() {
1387            let (sk, x) = gen_ed25519();
1388            let now = OffsetDateTime::now_utc().unix_timestamp();
1389            let h = serde_json::json!({"typ":"dpop+jwt","alg":"EdDSA","jwk":{"kty":"OKP","crv":"Ed25519","x":x}});
1390            let p =
1391                serde_json::json!({"jti":"ed-ok","iat":now,"htm":"GET","htu":"https://ex.com/a"});
1392            let jws = make_jws_ed(&sk, h, p);
1393
1394            let mut store = super::MemoryStore::default();
1395            assert!(verify_proof(
1396                &mut store,
1397                &jws,
1398                "https://ex.com/a",
1399                "GET",
1400                None,
1401                VerifyOptions::default(),
1402            )
1403            .await
1404            .is_ok());
1405        }
1406
1407        #[tokio::test]
1408        async fn eddsa_wrong_jwk_type_rejected() {
1409            let (sk, x) = gen_ed25519();
1410            let now = OffsetDateTime::now_utc().unix_timestamp();
1411            // bad: kty/crv don't match EdDSA expectations
1412            let h = serde_json::json!({"typ":"dpop+jwt","alg":"EdDSA","jwk":{"kty":"EC","crv":"P-256","x":x,"y":x}});
1413            let p = serde_json::json!({"jti":"ed-badjwk","iat":now,"htm":"GET","htu":"https://ex.com/a"});
1414            let jws = make_jws_ed(&sk, h, p);
1415
1416            let mut store = super::MemoryStore::default();
1417            let err = verify_proof(
1418                &mut store,
1419                &jws,
1420                "https://ex.com/a",
1421                "GET",
1422                None,
1423                VerifyOptions::default(),
1424            )
1425            .await
1426            .unwrap_err();
1427            matches!(err, DpopError::BadJwk(_));
1428        }
1429
1430        #[tokio::test]
1431        async fn eddsa_signature_tamper_detected() {
1432            let (sk, x) = gen_ed25519();
1433            let now = OffsetDateTime::now_utc().unix_timestamp();
1434            let h = serde_json::json!({"typ":"dpop+jwt","alg":"EdDSA","jwk":{"kty":"OKP","crv":"Ed25519","x":x}});
1435            let p = serde_json::json!({"jti":"ed-tamper","iat":now,"htm":"GET","htu":"https://ex.com/a"});
1436            let mut jws = make_jws_ed(&sk, h, p);
1437            // flip a byte in the header part (remain base64url-ish length)
1438            unsafe {
1439                let bytes = jws.as_bytes_mut();
1440                for i in 10..(bytes.len().min(40)) {
1441                    bytes[i] ^= 1;
1442                    break;
1443                }
1444            }
1445            let mut store = super::MemoryStore::default();
1446            let err = verify_proof(
1447                &mut store,
1448                &jws,
1449                "https://ex.com/a",
1450                "GET",
1451                None,
1452                VerifyOptions::default(),
1453            )
1454            .await
1455            .unwrap_err();
1456            matches!(err, DpopError::InvalidSignature);
1457        }
1458    }
1459}