1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (C) 2026 Matthew Jackson
//! RFC 9449 DPoP: sender-constrained access tokens.
//!
//! # What this buys, and it is the largest security change in this crate
//!
//! Without it every token this server issues is a BEARER token (RFC 6750 section 1: possession of
//! the string is the whole of the authorization). A token that leaks, into a log, a proxy, a crash
//! dump, a compromised resource server, is a token the finder can spend. DPoP binds the token to a
//! key the client holds: the client signs a fresh proof for every request, the token carries the
//! RFC 7638 thumbprint of that key in `cnf.jkt` (section 6), and a resource server that checks the
//! binding refuses a token presented by anyone who cannot sign for the key. A leaked DPoP-bound
//! token is worth nothing without the private key, which never leaves the client.
//!
//! # What this module does and does not do
//!
//! It validates a PROOF (section 4.3) and produces a thumbprint. It does not speak HTTP: the host
//! (or the optional `http` feature) reads the `DPoP` request header and hands the string in.
//!
//! Three things RFC 9449 defines that are NOT implemented here, called out rather than left to be
//! discovered:
//!
//! - The `ath` CLAIM (section 4.3 step 11), which binds a proof to the specific access token it is
//! presented with. This function verifies a proof against a method and a URI; it takes no access
//! token, so it cannot check `ath` and does not. That is the right scope for an AS — at the
//! token endpoint there is no access token yet — but it MATTERS for the resource-server use
//! [`verify_proof`] documents below, because without it a proof is bound to a key and a request
//! line but not to a token: an RS built only on this accepts token A presented with a proof
//! carrying no `ath`, or one whose `ath` hashes a different token B, as long as both were issued
//! to the same key. An RS MUST check `ath` itself, against the token it is about to accept.
//! This omission was undocumented until the 0.9.1 audit; the gap was in the docs, not the code.
//!
//! - SERVER-PROVIDED NONCES (section 8), the `DPoP-Nonce` response header and the `use_dpop_nonce`
//! error. They need the AS to mint, remember and return a nonce on a REJECTED request, which is a
//! response-header seam this crate's error type does not have and a second piece of server state.
//! Their purpose is to stop a client pre-generating proofs long in advance; the `iat` window here
//! bounds that to [`MAX_PROOF_AGE`] instead, which is the weaker but non-optional half of the
//! same defence.
//! - The `dpop_jkt` AUTHORIZATION REQUEST parameter (section 10), which binds an authorization code
//! to a key at the authorization endpoint so a stolen code cannot be redeemed by another client.
//! It belongs with the authorization request rather than here.
//!
//! # Single use is the point, again
//!
//! A proof is not a secret: it travels in a header on every request, so anything on the path sees
//! them constantly. Its whole value is in what a captured one cannot be used for, which is `htm`,
//! `htu`, `iat` and `jti` (section 4.3). [`verify_proof`] is PURE and returns the `jti` with the
//! deadline it must be remembered to; claiming it is [`crate::store::Storage::claim_replay_id`],
//! which `AuthorizationServer` calls on the same request. A verifier that checks the signature and
//! skips the `jti` has left the replay window wide open for exactly [`MAX_PROOF_AGE`].
use fmt;
use ;
use crate;
/// RFC 9449 section 4.2: the `typ` a DPoP proof MUST carry.
pub const DPOP_PROOF_TYP: &str = "dpop+jwt";
/// RFC 9449 section 5: the `token_type` a DPoP-bound token is issued with, and the HTTP
/// authentication scheme a client presents it under.
pub const DPOP_TOKEN_TYPE: &str = "DPoP";
/// RFC 9449 section 4: the request header a proof travels in.
pub const DPOP_HEADER: &str = "DPoP";
/// The JWS algorithms this server accepts on a proof, and exactly what it advertises as
/// `dpop_signing_alg_values_supported` (RFC 9449 section 5.1).
///
/// One entry, and the list must stay honest: RFC 9449 section 4.2 requires an ASYMMETRIC algorithm,
/// and this crate implements ES256 and nothing else (see `Cargo.toml` on why `p256` and not a JOSE
/// framework). Advertising an algorithm the verifier will refuse is worse than advertising fewer,
/// because a client that picks it has no way to find out except by failing.
pub const DPOP_SIGNING_ALG_VALUES_SUPPORTED: & = &;
/// How old a proof's `iat` may be (RFC 9449 section 4.3 (10): "within an acceptable window").
///
/// Five minutes. Two things are bought with the same number, which is why it is one constant: it
/// bounds how long a captured proof is worth replaying, and it bounds the replay cache, because a
/// proof older than this is refused on time alone and its `jti` no longer has to be remembered. RFC
/// 9449 section 11.1 notes that a server wanting a tighter bound should use the section 8 nonce
/// mechanism rather than shrinking this to the point where ordinary clock skew breaks clients.
pub const MAX_PROOF_AGE: Duration = from_secs;
/// How far a client's clock may be AHEAD of this server's before `iat` is refused. Granted in that
/// direction only.
///
/// The SAME constant `client-assertion` publishes for RFC 7523 assertions, re-exported rather than
/// duplicated: one value defined in `src/skew.rs`, which is where the reasoning for the number is.
/// It used to be a second `pub const` here whose doc comment said "for the same reason as in
/// `crate::client_assertion`", which is a comment admitting two numbers had to be kept equal by
/// hand.
pub use crateCLOCK_SKEW_LEEWAY;
/// The largest DPoP proof [`verify_proof`] will look at, in bytes, checked BEFORE it is parsed.
///
/// WHY THIS CRATE NEEDS ITS OWN. Every other credential this server checks arrives in a request
/// BODY, and the body is bounded (`MAX_BODY_BYTES` in `src/http.rs`). A proof arrives in a request
/// HEADER (RFC 9449 section 4), so that bound never applied to it, and the string went straight
/// into [`crate::jwt::CompactJws::parse`], which base64-decodes it and runs two JSON parses over
/// the result. The client is not authenticated at that point: the work is done for anybody who can
/// open a connection.
///
/// WHY 4 KiB, from what a proof actually contains. RFC 9449 section 4.2 fixes the shape: a header
/// with `typ`, `alg` and an embedded public JWK, which for the ES256 this crate implements is two
/// 43-character base64url P-256 coordinates plus `kty` and `crv`; a payload with `htm`, `htu`,
/// `iat` and `jti`, plus at most the section 8 `nonce` and the section 4.3 `ath`; and a 64-byte
/// signature. Base64url encoded that is a little over 500 bytes in practice, and 4096 leaves room
/// for a long `htu`, a verbose `kid`, and claims a future revision of the RFC may add, while still
/// refusing a megabyte of header before any of it is decoded. A proof this cap refuses is not a
/// proof any conforming client sends.
///
/// DEFENCE IN DEPTH, stated honestly: a host's HTTP server almost always caps total header size as
/// well (8 KiB per header line is a common default), so in most deployments this is the second line
/// and not the only one. It is here because this library never sees the socket and so cannot rely
/// on a limit it did not set, and because [`verify_proof`] is public: a host may hand it a string
/// from anywhere.
pub const MAX_PROOF_BYTES: usize = 4096;
/// The longest `jti` [`verify_proof`] will accept on a proof, in bytes.
///
/// WHY A SECOND CAP, when [`MAX_PROOF_BYTES`] already bounds the whole string. That one bounds the
/// WORK; this one bounds what is RETAINED, and they are different quantities. Everything else
/// `verify_proof` reads is dropped when it returns, but the `jti` is the one value the caller is
/// obliged to WRITE DOWN: `AuthorizationServer` puts it through
/// [`crate::store::Storage::claim_replay_id`] with a deadline of `iat + MAX_PROOF_AGE`, which with
/// the [`CLOCK_SKEW_LEEWAY`] a client may claim ahead is up to six minutes from now. Under a 4096
/// byte proof cap alone, a single request affords a `jti` of nearly 3 KiB, held in the host's store
/// three hundred times longer than the request that produced it took to refuse.
///
/// This is the ONE path in this crate on which a caller who has authenticated as nobody causes a
/// durable store write. The RFC 7523 sibling in `client-assertion` also claims a `jti`, but it
/// reaches that claim only after the assertion has verified under a REGISTERED client's key, so the
/// writes it can be made to perform are bounded by credentials a host issued. A DPoP proof verifies
/// under a key the caller generated for that request, which is no bound at all.
///
/// WHY 128, from what a `jti` is for. RFC 9449 section 4.2 asks only that the value be unique, and
/// section 11.1 explains why: it is what single use is enforced on. A UUID is 36 characters, a
/// base64url 128-bit random is 22, and the crate's own tests mint them shorter than that. 128 bytes
/// is several times what any conforming client needs and still small enough that the retained set is
/// bounded by request rate rather than by a length an attacker chose. A `jti` this cap refuses is
/// not a `jti` any conforming client sends.
pub const MAX_JTI_BYTES: usize = 128;
/// Why a DPoP proof was refused.
///
/// RFC 9449 section 5 makes `invalid_dpop_proof` the token-endpoint answer for all of these; the
/// distinction here is for the host's audit channel, not for the wire.
/// What a verified proof leaves the caller holding.
/// The `htu` of a request URI: everything before the query and the fragment.
///
/// RFC 9449 section 4.3 (7) compares `htu` against the request URI "ignoring any query and fragment
/// parts", so both sides go through this. Doing it to BOTH matters: a client that included its
/// query string in `htu` is conformant, and so is one that did not.
/// Verify one RFC 9449 section 4.3 DPoP proof.
///
/// `htm` is the request's method and `htu` its URI (the query and fragment are stripped here, so a
/// caller may pass either form). `now` is the server's clock.
///
/// The key comes from the proof itself, which looks like a violation of the rule stated in
/// `src/jwt.rs` that the verifier chooses the key, and is not. A proof only ever proves possession
/// of the key it advertises; it establishes no identity on its own. What makes it mean something is
/// the BINDING: the token issued alongside it carries that key's thumbprint in `cnf.jkt`, so a
/// later request has to prove possession of the same key. Section 4.3 (6) is what closes the gap,
/// by requiring the signature to verify under the proof's OWN key: an attacker who captures a proof
/// cannot substitute their key without invalidating the signature, and cannot re-sign without the
/// private half.
///
/// `verifier` is the ES256 backend, which after 0.9.0 is the host's to choose: enable `jwt-p256`
/// for [`crate::jwt::P256Verifier`], or pass your own. It is a PARAMETER rather than something
/// this function reaches for, because there is no "none" that could be safe here: a caller with no
/// verifier has nothing to pass and must refuse the request instead, which is what
/// `AuthorizationServer` does.
///
/// PUBLIC because [`VerifiedProof`] and [`DpopFailure`] are, and a type with no reachable producer
/// is a type a consumer can read about and never obtain. It is also the function a host needs
/// directly: RFC 9449 section 7 has the RESOURCE server check a proof on every request, and a host
/// whose resource server is in the same tree as its AS should not have to reimplement section 4.3
/// to do it. Nothing is retained here, so claiming the returned `jti` (see
/// [`crate::store::Storage::claim_replay_id`]) remains the caller's obligation either way.
///
/// A RESOURCE SERVER HAS ONE MORE CHECK TO MAKE, and this function cannot make it: section 4.3
/// step 11 requires that a proof presented WITH an access token carry an `ath` equal to the
/// base64url SHA-256 of that token. There is no access token in this signature, so `ath` is not
/// verified here. Checking it is the caller's obligation, exactly as claiming the `jti` is, and
/// skipping it leaves a proof bound to a key and a request line but not to a token. See the
/// module docs.
// The unit tests need a key that can SIGN, so they need `jwt-p256`, the built-in ES256 backend.
// `jwt` alone carries the `Es256Signer`/`Es256Verifier` seam and no curve arithmetic at all, and a
// test that cannot produce a signature cannot test a verifier.