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