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