agentguard_auth/
dpop.rs

1//! DPoP (RFC 9449) proof-of-possession verifier.
2//!
3//! Verifies the DPoP proof JWT in the `DPoP` header against the access
4//! token's `cnf.jkt` claim (RFC 9449 §4.2). Replay protection via
5//! [`JtiTracker`].
6//!
7//! Verification steps (RFC 9449 §4.2 + RFC 7638):
8//! 1. Parse the compact JWS; require 3 segments.
9//! 2. Require header `typ == "dpop+jwt"` and `alg` in the whitelist.
10//! 3. Extract the public key from header `jwk`. Require `kty=OKP`,
11//!    `crv=Ed25519`, `x` is 32 base64url-encoded bytes.
12//! 4. Compute the JWK thumbprint per RFC 7638 and require it equals
13//!    `expected_jkt` (the `cnf.jkt` value from the access token).
14//! 5. Verify the EdDSA signature over `header.payload` using the `jwk`.
15//! 6. Verify `htm`, `htu`, `ath` (SHA-256 base64url of the access token).
16//! 7. Verify `jti` uniqueness via [`JtiTracker`] (SHA-256 keyed).
17
18use crate::error::{AuthError, Result};
19use crate::jti::JtiTracker;
20use async_trait::async_trait;
21use base64::Engine as _;
22use ed25519_dalek::{Signature, Verifier, VerifyingKey};
23use sha2::{Digest, Sha256};
24use std::sync::Arc;
25use std::time::Duration;
26
27/// Whitelist of DPoP proof `alg` values. RFC 9449 §4.2 only allows
28/// asymmetric algorithms; HS* is forbidden.
29const DPOP_ALG_EDDSA: &str = "EdDSA";
30
31/// Atomic replay-state port used by [`DpopVerifier`].
32///
33/// Implementations must make `check_and_record` atomic across every verifier
34/// instance in the deployment scope and retain an accepted identifier for at
35/// least the proof validity window. Storage failures must return an error;
36/// they must never be treated as a fresh proof. This method is synchronous, so
37/// implementations must not block an async runtime worker on remote I/O.
38pub trait DpopReplayStore: Send + Sync {
39    fn check_and_record(&self, jti: &[u8; 16]) -> Result<()>;
40}
41
42impl DpopReplayStore for JtiTracker {
43    fn check_and_record(&self, jti: &[u8; 16]) -> Result<()> {
44        JtiTracker::check_and_record(self, jti)
45    }
46}
47
48/// Async replay-state port for stores that perform remote I/O.
49///
50/// Implementations have the same atomicity, expiry, and fail-closed
51/// requirements as [`DpopReplayStore`], but can perform I/O without blocking a
52/// Tokio worker. Use [`DpopVerifier::new_async`] with this port.
53#[async_trait]
54pub trait AsyncDpopReplayStore: Send + Sync {
55    async fn check_and_record(&self, jti: &[u8; 16]) -> Result<()>;
56}
57
58#[derive(Clone)]
59enum ReplayStore {
60    Sync(Arc<dyn DpopReplayStore>),
61    Async(Arc<dyn AsyncDpopReplayStore>),
62}
63
64/// Verifies DPoP proof JWTs.
65#[derive(Clone)]
66pub struct DpopVerifier {
67    replay_store: ReplayStore,
68    pub allowed_clock_skew: Duration,
69}
70
71impl DpopVerifier {
72    pub fn new(jti_tracker: Arc<dyn DpopReplayStore>) -> Self {
73        Self {
74            replay_store: ReplayStore::Sync(jti_tracker),
75            allowed_clock_skew: Duration::from_secs(30),
76        }
77    }
78
79    /// Construct a verifier with an asynchronous replay store.
80    pub fn new_async(replay_store: Arc<dyn AsyncDpopReplayStore>) -> Self {
81        Self {
82            replay_store: ReplayStore::Async(replay_store),
83            allowed_clock_skew: Duration::from_secs(30),
84        }
85    }
86
87    /// Verify a DPoP proof.
88    ///
89    /// - `dpop_header`: the `DPoP` header value (compact JWS)
90    /// - `access_token`: the access token bound to this DPoP proof. Used only
91    ///   to compute the `ath` claim (RFC 9449 §4.2: SHA-256 base64url-no-pad).
92    /// - `htm`: HTTP method (e.g. `POST`)
93    /// - `htu`: HTTP URI (must match exactly, RFC 9449 §4.2 strict equality)
94    /// - `expected_jkt`: the JWK SHA-256 thumbprint (RFC 7638) of the public
95    ///   key the access token is bound to. Extracted from the access token's
96    ///   `cnf.jkt` claim by the caller. **Required** — DPoP without a known
97    ///   jkt cannot establish proof-of-possession.
98    ///
99    /// # Errors
100    /// Returns `AuthError::DpopInvalid` on any structural, alg, or jkt
101    /// problem, `AuthError::DpopReplay` on `jti` reuse, or
102    /// `AuthError::DpopCapacityExceeded` if replay tracking is full.
103    pub fn verify(
104        &self,
105        dpop_header: &str,
106        access_token: &str,
107        htm: &str,
108        htu: &str,
109        expected_jkt: &str,
110    ) -> Result<()> {
111        let jti_key = self.verify_proof(dpop_header, access_token, htm, htu, expected_jkt)?;
112        match &self.replay_store {
113            ReplayStore::Sync(store) => store.check_and_record(&jti_key),
114            ReplayStore::Async(_) => Err(AuthError::DpopInvalid(
115                "asynchronous replay store requires verify_async".into(),
116            )),
117        }
118    }
119
120    /// Verify a DPoP proof using either sync or async replay storage.
121    ///
122    /// Cryptographic validation remains synchronous; remote replay-state I/O
123    /// is awaited without blocking an async runtime worker.
124    pub async fn verify_async(
125        &self,
126        dpop_header: &str,
127        access_token: &str,
128        htm: &str,
129        htu: &str,
130        expected_jkt: &str,
131    ) -> Result<()> {
132        let jti_key = self.verify_proof(dpop_header, access_token, htm, htu, expected_jkt)?;
133        match &self.replay_store {
134            ReplayStore::Sync(store) => store.check_and_record(&jti_key),
135            ReplayStore::Async(store) => store.check_and_record(&jti_key).await,
136        }
137    }
138
139    fn verify_proof(
140        &self,
141        dpop_header: &str,
142        access_token: &str,
143        htm: &str,
144        htu: &str,
145        expected_jkt: &str,
146    ) -> Result<[u8; 16]> {
147        // 1. Parse the compact JWS into (header, claims, signing_input, signature).
148        let (header, claims, signing_input, signature) = Self::parse_proof(dpop_header)?;
149        // 2. Validate header typ + alg, extract jwk.
150        let jwk = self.check_header(&header)?;
151        // 3. Compute JWK thumbprint and verify it matches expected_jkt.
152        let raw_key = parse_ed25519_jwk(jwk)?;
153        let thumbprint = jwk_thumbprint_ed25519(jwk)?;
154        if !constant_time_eq(thumbprint.as_bytes(), expected_jkt.as_bytes()) {
155            return Err(AuthError::DpopInvalid("jkt mismatch".into()));
156        }
157        // 4. Verify the EdDSA signature using the jwk.
158        verify_dpop_signature(&raw_key, &signing_input, &signature)?;
159        // 5. Validate htm / htu / ath claims.
160        self.check_htm_htu_ath(&claims, htm, htu, access_token)?;
161        // 6. Replay protection: hash the jti to a 16-byte key.
162        let jti_key = jti_to_replay_key(claims.get("jti").and_then(|v| v.as_str()))?;
163        Ok(jti_key)
164    }
165
166    /// Parse the compact JWS into header, claims, signing input, signature.
167    fn parse_proof(
168        dpop_header: &str,
169    ) -> Result<(serde_json::Value, serde_json::Value, Vec<u8>, Vec<u8>)> {
170        let parts: Vec<&str> = dpop_header.split('.').collect();
171        if parts.len() != 3 {
172            return Err(AuthError::DpopInvalid("expected 3 segments".into()));
173        }
174        let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
175            .decode(parts[0])
176            .map_err(|e| AuthError::DpopInvalid(format!("header b64: {}", e)))?;
177        let header: serde_json::Value = serde_json::from_slice(&header_bytes)
178            .map_err(|e| AuthError::DpopInvalid(format!("header json: {}", e)))?;
179        let claims_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
180            .decode(parts[1])
181            .map_err(|e| AuthError::DpopInvalid(format!("claims b64: {}", e)))?;
182        let claims: serde_json::Value = serde_json::from_slice(&claims_bytes)
183            .map_err(|e| AuthError::DpopInvalid(format!("claims json: {}", e)))?;
184        let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
185            .decode(parts[2])
186            .map_err(|e| AuthError::DpopInvalid(format!("sig b64: {}", e)))?;
187        let signing_input = format!("{}.{}", parts[0], parts[1]).into_bytes();
188        Ok((header, claims, signing_input, signature))
189    }
190
191    /// Validate header `typ == "dpop+jwt"` and `alg == "EdDSA"`. Return the
192    /// `jwk` object for downstream use.
193    fn check_header<'a>(&self, header: &'a serde_json::Value) -> Result<&'a serde_json::Value> {
194        let header_typ = header.get("typ").and_then(|v| v.as_str()).unwrap_or("");
195        if header_typ != "dpop+jwt" {
196            return Err(AuthError::DpopInvalid(format!(
197                "header typ must be 'dpop+jwt', got {:?}",
198                header_typ
199            )));
200        }
201        let header_alg = header
202            .get("alg")
203            .and_then(|v| v.as_str())
204            .ok_or_else(|| AuthError::DpopInvalid("missing alg".into()))?;
205        if header_alg != DPOP_ALG_EDDSA {
206            return Err(AuthError::DpopInvalid(format!(
207                "alg {:?} not in DPoP whitelist",
208                header_alg
209            )));
210        }
211        header
212            .get("jwk")
213            .ok_or_else(|| AuthError::DpopInvalid("missing jwk in header".into()))
214    }
215
216    /// Validate htm / htu / ath claims.
217    fn check_htm_htu_ath(
218        &self,
219        claims: &serde_json::Value,
220        htm: &str,
221        htu: &str,
222        access_token: &str,
223    ) -> Result<()> {
224        let claim_htm = claims.get("htm").and_then(|v| v.as_str()).unwrap_or("");
225        if claim_htm != htm {
226            return Err(AuthError::DpopInvalid(format!(
227                "htm mismatch: expected {}, got {}",
228                htm, claim_htm
229            )));
230        }
231        let claim_htu = claims.get("htu").and_then(|v| v.as_str()).unwrap_or("");
232        if claim_htu != htu {
233            return Err(AuthError::DpopHtuMismatch {
234                expected: htu.to_string(),
235                actual: claim_htu.to_string(),
236            });
237        }
238        let mut hasher = Sha256::new();
239        hasher.update(access_token.as_bytes());
240        let expected_ath =
241            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
242        let claim_ath = claims.get("ath").and_then(|v| v.as_str()).unwrap_or("");
243        if claim_ath != expected_ath {
244            return Err(AuthError::DpopInvalid("ath mismatch".into()));
245        }
246        Ok(())
247    }
248}
249
250/// Verify an EdDSA signature over `signing_input` using the Ed25519
251/// verifying key. The signature is in JWS format (raw 64 bytes).
252fn verify_dpop_signature(raw_key: &[u8; 32], signing_input: &[u8], signature: &[u8]) -> Result<()> {
253    let vk = VerifyingKey::from_bytes(raw_key)
254        .map_err(|e| AuthError::DpopInvalid(format!("jwk key: {}", e)))?;
255    let sig = Signature::from_slice(signature)
256        .map_err(|e| AuthError::DpopInvalid(format!("ed25519 sig: {}", e)))?;
257    vk.verify(signing_input, &sig)
258        .map_err(|_| AuthError::DpopInvalid("signature verification failed".into()))
259}
260
261/// Hash a DPoP `jti` string to a 16-byte replay-detection key. Per RFC 9449
262/// the jti is an opaque case-sensitive string; SHA-256 is sufficient since
263/// the JtiTracker is the authoritative store, this hash just buckets.
264fn jti_to_replay_key(jti: Option<&str>) -> Result<[u8; 16]> {
265    let jti_str = jti.ok_or_else(|| AuthError::DpopInvalid("missing jti".into()))?;
266    let mut hasher = Sha256::new();
267    hasher.update(jti_str.as_bytes());
268    let digest = hasher.finalize();
269    let mut key = [0u8; 16];
270    key.copy_from_slice(&digest[..16]);
271    Ok(key)
272}
273
274/// Parse the JWK members required for Ed25519 (`kty`, `crv`, `x`) and return
275/// the raw 32-byte public key.
276fn parse_ed25519_jwk(jwk: &serde_json::Value) -> Result<[u8; 32]> {
277    let obj = jwk
278        .as_object()
279        .ok_or_else(|| AuthError::DpopInvalid("jwk must be a JSON object".into()))?;
280    let kty = obj
281        .get("kty")
282        .and_then(|v| v.as_str())
283        .ok_or_else(|| AuthError::DpopInvalid("jwk missing kty".into()))?;
284    if kty != "OKP" {
285        return Err(AuthError::DpopInvalid(format!(
286            "jwk kty must be OKP, got {}",
287            kty
288        )));
289    }
290    let crv = obj
291        .get("crv")
292        .and_then(|v| v.as_str())
293        .ok_or_else(|| AuthError::DpopInvalid("jwk missing crv".into()))?;
294    if crv != "Ed25519" {
295        return Err(AuthError::DpopInvalid(format!(
296            "jwk crv must be Ed25519, got {}",
297            crv
298        )));
299    }
300    let x = obj
301        .get("x")
302        .and_then(|v| v.as_str())
303        .ok_or_else(|| AuthError::DpopInvalid("jwk missing x".into()))?;
304    let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
305        .decode(x)
306        .map_err(|e| AuthError::DpopInvalid(format!("jwk x b64: {}", e)))?;
307    if raw.len() != 32 {
308        return Err(AuthError::DpopInvalid(format!(
309            "jwk x must be 32 bytes, got {}",
310            raw.len()
311        )));
312    }
313    let mut out = [0u8; 32];
314    out.copy_from_slice(&raw);
315    Ok(out)
316}
317
318/// Compute the JWK thumbprint per RFC 7638 for an Ed25519 public key.
319///
320/// Required members (in lex order): `crv`, `kty`, `x`. Delegates to
321/// [`agentguard_core::jwk::thumbprint_ed25519`] so the JWT and DPoP
322/// paths share a single implementation.
323fn jwk_thumbprint_ed25519(jwk: &serde_json::Value) -> Result<String> {
324    let obj = jwk
325        .as_object()
326        .ok_or_else(|| AuthError::DpopInvalid("jwk not object".into()))?;
327    let crv = obj
328        .get("crv")
329        .and_then(|v| v.as_str())
330        .ok_or_else(|| AuthError::DpopInvalid("jwk missing crv".into()))?;
331    let x = obj
332        .get("x")
333        .and_then(|v| v.as_str())
334        .ok_or_else(|| AuthError::DpopInvalid("jwk missing x".into()))?;
335    Ok(agentguard_core::jwk::thumbprint_ed25519(x, crv))
336}
337
338/// Constant-time byte slice equality. Used for jkt comparison to avoid
339/// leaking the expected thumbprint length/content via timing.
340fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
341    // ponytail: use the upstream-audited subtle crate rather than
342    // a hand-rolled XOR fold. The result is the same algorithm but
343    // every release of `subtle` is reviewed by the RustCrypto
344    // team, and the constant-time guarantees are documented and
345    // unit-tested.
346    use subtle::ConstantTimeEq;
347    a.ct_eq(b).into()
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use ed25519_dalek::{Signer, SigningKey};
354    use rand::rngs::OsRng;
355    use std::collections::HashSet;
356
357    struct RejectingReplayStore;
358
359    impl DpopReplayStore for RejectingReplayStore {
360        fn check_and_record(&self, _jti: &[u8; 16]) -> Result<()> {
361            Err(AuthError::Other("shared replay store unavailable".into()))
362        }
363    }
364
365    #[derive(Default)]
366    struct AsyncSetReplayStore(std::sync::Mutex<HashSet<[u8; 16]>>);
367
368    #[async_trait]
369    impl AsyncDpopReplayStore for AsyncSetReplayStore {
370        async fn check_and_record(&self, jti: &[u8; 16]) -> Result<()> {
371            let mut seen = self.0.lock().expect("test replay store lock poisoned");
372            if !seen.insert(*jti) {
373                return Err(AuthError::DpopReplay("proof already used".into()));
374            }
375            Ok(())
376        }
377    }
378
379    fn make_dpop(signer: &SigningKey, jwk_x_b64: &str, jti: &str, claims_extras: &str) -> String {
380        let header = serde_json::json!({
381            "alg": "EdDSA",
382            "typ": "dpop+jwt",
383            "jwk": {"kty": "OKP", "crv": "Ed25519", "x": jwk_x_b64},
384        });
385        let h = base64::engine::general_purpose::URL_SAFE_NO_PAD
386            .encode(serde_json::to_vec(&header).unwrap());
387        let now = chrono::Utc::now().timestamp();
388        let claims_json = if claims_extras.is_empty() {
389            format!(r#"{{"jti":"{jti}","htm":"POST","htu":"https://example.com/x","iat":{now}}}"#)
390        } else {
391            format!(
392                r#"{{"jti":"{jti}","htm":"POST","htu":"https://example.com/x","iat":{now},{extra}}}"#,
393                extra = claims_extras
394            )
395        };
396        let p = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims_json.as_bytes());
397        let signing_input = format!("{}.{}", h, p);
398        let sig = signer.sign(signing_input.as_bytes());
399        let s = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes());
400        format!("{}.{}.{}", h, p, s)
401    }
402
403    fn make_jkt(pub_key: &[u8; 32]) -> String {
404        let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_key);
405        let canonical = format!(r#"{{"crv":"Ed25519","kty":"OKP","x":"{}"}}"#, x);
406        let mut h = Sha256::new();
407        h.update(canonical.as_bytes());
408        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(h.finalize())
409    }
410
411    #[test]
412    fn valid_dpop_accepted() {
413        let mut csprng = OsRng;
414        let signer = SigningKey::generate(&mut csprng);
415        let pub_bytes = signer.verifying_key().to_bytes();
416        let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
417        let jkt = make_jkt(&pub_bytes);
418        let mut hasher = Sha256::new();
419        hasher.update(b"access-token");
420        let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
421        let extras = format!(r#""ath":"{}""#, ath);
422        let dpop = make_dpop(&signer, &x, "abc", &extras);
423
424        let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
425        let v = DpopVerifier::new(tracker);
426        v.verify(&dpop, "access-token", "POST", "https://example.com/x", &jkt)
427            .unwrap();
428    }
429
430    #[test]
431    fn htu_mismatch_rejected() {
432        let mut csprng = OsRng;
433        let signer = SigningKey::generate(&mut csprng);
434        let pub_bytes = signer.verifying_key().to_bytes();
435        let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
436        let jkt = make_jkt(&pub_bytes);
437        let dpop = make_dpop(&signer, &x, "abc", "");
438        let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
439        let v = DpopVerifier::new(tracker);
440        let err = v
441            .verify(&dpop, "tok", "POST", "https://example.com/y", &jkt)
442            .unwrap_err();
443        assert!(
444            matches!(err, AuthError::DpopHtuMismatch { .. }),
445            "expected DpopHtuMismatch, got {:?}",
446            err
447        );
448    }
449
450    #[test]
451    fn htm_mismatch_rejected() {
452        let mut csprng = OsRng;
453        let signer = SigningKey::generate(&mut csprng);
454        let pub_bytes = signer.verifying_key().to_bytes();
455        let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
456        let jkt = make_jkt(&pub_bytes);
457        let dpop = make_dpop(&signer, &x, "abc", "");
458        let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
459        let v = DpopVerifier::new(tracker);
460        let err = v
461            .verify(&dpop, "tok", "GET", "https://example.com/x", &jkt)
462            .unwrap_err();
463        assert!(matches!(err, AuthError::DpopInvalid(_)));
464    }
465
466    #[test]
467    fn ath_mismatch_rejected() {
468        let mut csprng = OsRng;
469        let signer = SigningKey::generate(&mut csprng);
470        let pub_bytes = signer.verifying_key().to_bytes();
471        let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
472        let jkt = make_jkt(&pub_bytes);
473        let mut hasher = Sha256::new();
474        hasher.update(b"real-token");
475        let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
476        let extras = format!(r#""ath":"{}""#, ath);
477        let dpop = make_dpop(&signer, &x, "abc", &extras);
478        let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
479        let v = DpopVerifier::new(tracker);
480        let err = v
481            .verify(
482                &dpop,
483                "different-token",
484                "POST",
485                "https://example.com/x",
486                &jkt,
487            )
488            .unwrap_err();
489        assert!(matches!(err, AuthError::DpopInvalid(_)));
490    }
491
492    #[test]
493    fn missing_jti_rejected() {
494        let mut csprng = OsRng;
495        let signer = SigningKey::generate(&mut csprng);
496        let pub_bytes = signer.verifying_key().to_bytes();
497        let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
498        let jkt = make_jkt(&pub_bytes);
499        // Build DPoP with empty jti by hand.
500        let header = serde_json::json!({
501            "alg": "EdDSA",
502            "typ": "dpop+jwt",
503            "jwk": {"kty": "OKP", "crv": "Ed25519", "x": x},
504        });
505        let h = base64::engine::general_purpose::URL_SAFE_NO_PAD
506            .encode(serde_json::to_vec(&header).unwrap());
507        let claims = serde_json::json!({
508            "htm": "POST",
509            "htu": "https://example.com/x",
510            "iat": chrono::Utc::now().timestamp(),
511        });
512        let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
513            .encode(serde_json::to_vec(&claims).unwrap());
514        let signing_input = format!("{}.{}", h, p);
515        let sig = signer.sign(signing_input.as_bytes());
516        let s = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes());
517        let dpop = format!("{}.{}.{}", h, p, s);
518        let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
519        let v = DpopVerifier::new(tracker);
520        let err = v
521            .verify(&dpop, "tok", "POST", "https://example.com/x", &jkt)
522            .unwrap_err();
523        assert!(matches!(err, AuthError::DpopInvalid(_)));
524    }
525
526    #[test]
527    fn malformed_dpop_header_rejected() {
528        let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
529        let v = DpopVerifier::new(tracker);
530        let err = v
531            .verify("only.two", "token", "POST", "https://example.com/x", "any")
532            .unwrap_err();
533        assert!(matches!(err, AuthError::DpopInvalid(_)));
534    }
535
536    #[test]
537    fn jkt_mismatch_rejected() {
538        // Right shape, but the access token's cnf.jkt doesn't match the
539        // proof's jwk thumbprint — attacker can't bind a different key.
540        let mut csprng = OsRng;
541        let signer = SigningKey::generate(&mut csprng);
542        let pub_bytes = signer.verifying_key().to_bytes();
543        let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
544        let mut hasher = Sha256::new();
545        hasher.update(b"tok");
546        let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
547        let extras = format!(r#""ath":"{}""#, ath);
548        let dpop = make_dpop(&signer, &x, "abc", &extras);
549        let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
550        let v = DpopVerifier::new(tracker);
551        let wrong_jkt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
552        let err = v
553            .verify(&dpop, "tok", "POST", "https://example.com/x", wrong_jkt)
554            .unwrap_err();
555        assert!(matches!(err, AuthError::DpopInvalid(_)));
556    }
557
558    #[test]
559    fn signature_tamper_rejected() {
560        // Real signature, but the signature segment is replaced with a
561        // different valid EdDSA signature from a different key. The
562        // verifier must reject because the signature won't match the jwk.
563        let mut csprng = OsRng;
564        let signer = SigningKey::generate(&mut csprng);
565        let pub_bytes = signer.verifying_key().to_bytes();
566        let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
567        let jkt = make_jkt(&pub_bytes);
568        let mut hasher = Sha256::new();
569        hasher.update(b"tok");
570        let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
571        let extras = format!(r#""ath":"{}""#, ath);
572        let mut dpop = make_dpop(&signer, &x, "abc", &extras);
573        // Flip a character in the signature segment.
574        let idx = dpop.rfind('.').unwrap() + 1;
575        let mut chars: Vec<char> = dpop.chars().collect();
576        let sig_start = dpop[..idx].chars().count();
577        let c = chars[sig_start];
578        chars[sig_start] = if c == 'A' { 'B' } else { 'A' };
579        dpop = chars.into_iter().collect();
580        let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
581        let v = DpopVerifier::new(tracker);
582        let err = v
583            .verify(&dpop, "tok", "POST", "https://example.com/x", &jkt)
584            .unwrap_err();
585        assert!(matches!(err, AuthError::DpopInvalid(_)));
586    }
587
588    #[test]
589    fn replay_rejected() {
590        // Same DPoP replayed a second time within the TTL window.
591        let mut csprng = OsRng;
592        let signer = SigningKey::generate(&mut csprng);
593        let pub_bytes = signer.verifying_key().to_bytes();
594        let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
595        let jkt = make_jkt(&pub_bytes);
596        let mut hasher = Sha256::new();
597        hasher.update(b"tok");
598        let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
599        let extras = format!(r#""ath":"{}""#, ath);
600        let dpop = make_dpop(&signer, &x, "unique-jti", &extras);
601        let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
602        let v = DpopVerifier::new(tracker);
603        v.verify(&dpop, "tok", "POST", "https://example.com/x", &jkt)
604            .unwrap();
605        let err = v
606            .verify(&dpop, "tok", "POST", "https://example.com/x", &jkt)
607            .unwrap_err();
608        assert!(matches!(err, AuthError::DpopReplay(_)));
609    }
610
611    #[test]
612    fn replay_tracker_capacity_fails_closed_for_new_proofs() {
613        let mut csprng = OsRng;
614        let signer = SigningKey::generate(&mut csprng);
615        let pub_bytes = signer.verifying_key().to_bytes();
616        let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
617        let jkt = make_jkt(&pub_bytes);
618        let mut hasher = Sha256::new();
619        hasher.update(b"tok");
620        let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
621        let extras = format!(r#""ath":"{}""#, ath);
622        let tracker = Arc::new(JtiTracker::with_capacity(Duration::from_secs(60), 1).unwrap());
623        let verifier = DpopVerifier::new(tracker);
624        let first = make_dpop(&signer, &x, "first-proof", &extras);
625        let second = make_dpop(&signer, &x, "second-proof", &extras);
626
627        verifier
628            .verify(&first, "tok", "POST", "https://example.com/x", &jkt)
629            .unwrap();
630        assert!(matches!(
631            verifier.verify(&second, "tok", "POST", "https://example.com/x", &jkt),
632            Err(AuthError::DpopCapacityExceeded)
633        ));
634        assert!(matches!(
635            verifier.verify(&first, "tok", "POST", "https://example.com/x", &jkt),
636            Err(AuthError::DpopReplay(_))
637        ));
638    }
639
640    #[test]
641    fn verifier_propagates_custom_replay_store_failures() {
642        let mut csprng = OsRng;
643        let signer = SigningKey::generate(&mut csprng);
644        let pub_bytes = signer.verifying_key().to_bytes();
645        let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
646        let jkt = make_jkt(&pub_bytes);
647        let mut hasher = Sha256::new();
648        hasher.update(b"tok");
649        let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
650        let extras = format!(r#""ath":"{}""#, ath);
651        let dpop = make_dpop(&signer, &x, "store-error-proof", &extras);
652        let verifier = DpopVerifier::new(Arc::new(RejectingReplayStore));
653
654        assert!(matches!(
655            verifier.verify(&dpop, "tok", "POST", "https://example.com/x", &jkt),
656            Err(AuthError::Other(message)) if message == "shared replay store unavailable"
657        ));
658    }
659
660    #[tokio::test]
661    async fn async_verifier_uses_store_and_rejects_replays() {
662        let mut csprng = OsRng;
663        let signer = SigningKey::generate(&mut csprng);
664        let pub_bytes = signer.verifying_key().to_bytes();
665        let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
666        let jkt = make_jkt(&pub_bytes);
667        let mut hasher = Sha256::new();
668        hasher.update(b"tok");
669        let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize());
670        let dpop = make_dpop(&signer, &x, "async-proof", &format!(r#""ath":"{}""#, ath));
671        let verifier = DpopVerifier::new_async(Arc::new(AsyncSetReplayStore::default()));
672
673        verifier
674            .verify_async(&dpop, "tok", "POST", "https://example.com/x", &jkt)
675            .await
676            .unwrap();
677        assert!(matches!(
678            verifier
679                .verify_async(&dpop, "tok", "POST", "https://example.com/x", &jkt)
680                .await,
681            Err(AuthError::DpopReplay(_))
682        ));
683        assert!(matches!(
684            verifier.verify(&dpop, "tok", "POST", "https://example.com/x", &jkt),
685            Err(AuthError::DpopInvalid(message)) if message.contains("verify_async")
686        ));
687    }
688
689    #[test]
690    fn jwk_kty_ec_rejected() {
691        // A DPoP proof whose jwk is kty=EC (not OKP) must reject —
692        // we only support Ed25519. RFC 9449 §4.2 allows kty=EC but
693        // the verifier restricts to OKP/Ed25519.
694        let mut csprng = OsRng;
695        let signer = SigningKey::generate(&mut csprng);
696        let pub_bytes = signer.verifying_key().to_bytes();
697        let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
698        let header = serde_json::json!({
699            "alg": "EdDSA",
700            "typ": "dpop+jwt",
701            "jwk": {
702                "kty": "EC",
703                "crv": "Ed25519", // intentionally wrong combination
704                "x": x,
705            }
706        });
707        let h = base64::engine::general_purpose::URL_SAFE_NO_PAD
708            .encode(serde_json::to_vec(&header).unwrap());
709        let claims = serde_json::json!({
710            "jti": "some-jti",
711            "htm": "POST",
712            "htu": "https://example.com/x",
713            "iat": chrono::Utc::now().timestamp(),
714            "ath": "x",
715        });
716        let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
717            .encode(serde_json::to_vec(&claims).unwrap());
718        let signing_input = format!("{h}.{p}");
719        let sig = signer.sign(signing_input.as_bytes());
720        let s = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes());
721        let token = format!("{signing_input}.{s}");
722        let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
723        let v = DpopVerifier::new(tracker);
724        let err = v
725            .verify(&token, "x", "POST", "https://example.com/x", "jkt")
726            .unwrap_err();
727        assert!(matches!(err, AuthError::DpopInvalid(_)));
728    }
729
730    #[test]
731    fn alg_hs256_dpop_rejected() {
732        // Algorithm-confusion: a DPoP proof claiming alg=HS256 must
733        // reject (we only support EdDSA). Craft a token where the
734        // header alg is HS256 but the signature is computed as if it
735        // were EdDSA — both must reject.
736        let mut csprng = OsRng;
737        let signer = SigningKey::generate(&mut csprng);
738        let pub_bytes = signer.verifying_key().to_bytes();
739        let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_bytes);
740        let header = serde_json::json!({
741            "alg": "HS256",
742            "typ": "dpop+jwt",
743            "jwk": {"kty": "OKP", "crv": "Ed25519", "x": x},
744        });
745        let h = base64::engine::general_purpose::URL_SAFE_NO_PAD
746            .encode(serde_json::to_vec(&header).unwrap());
747        let claims = serde_json::json!({
748            "jti": "j",
749            "htm": "POST",
750            "htu": "https://example.com/x",
751            "iat": chrono::Utc::now().timestamp(),
752            "ath": "x",
753        });
754        let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
755            .encode(serde_json::to_vec(&claims).unwrap());
756        let signing_input = format!("{h}.{p}");
757        let sig = signer.sign(signing_input.as_bytes());
758        let s = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes());
759        let token = format!("{signing_input}.{s}");
760        let tracker = Arc::new(JtiTracker::new(Duration::from_secs(60)));
761        let v = DpopVerifier::new(tracker);
762        assert!(v
763            .verify(&token, "x", "POST", "https://example.com/x", "jkt")
764            .is_err());
765    }
766}