Skip to content

Commit 13676b7

Browse files
PieterKasclaude
andcommitted
server/dpop: round-4 nonce/clock hardening
- Refresh the held DPoP nonce from every response's DPoP-Nonce header (RFC 9449 §8.2 newest-wins) instead of capturing it once, so a server that rotates or single-uses its nonce no longer turns the negative probes into false "not testable" failures (or vacuous passes). (#1) - Widen the stale iat probe to -303 s (matching the future side) so the ±1 s quantization of the whole-second Date header can't pull it onto the ±300 s boundary and be false-accepted. (#2) - Update the isNonceChallenge comment and the untestable message: probes now carry the held nonce, so a use_dpop_nonce challenge is about the nonce lifetime (rotated/stale/single-use), not a missing nonce. (#3) - Fixture: parse integer env vars NaN-safely (intEnv helper) so a malformed DPOP_CLOCK_OFFSET_SECONDS / DPOP_IAT_SKEW_SECONDS falls back to its default instead of silently disabling the iat window. (#4) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 692db64 commit 13676b7

2 files changed

Lines changed: 37 additions & 19 deletions

File tree

‎examples/servers/typescript/sep-1932-compliant-server.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,14 @@ import { createHash } from 'node:crypto';
2727
const ISSUER_JWK = JSON.parse(process.env.DPOP_ISSUER_JWK || '{}');
2828
const ISSUER = process.env.DPOP_ISSUER || 'https://auth.example.com';
2929
const AUDIENCE = process.env.DPOP_AUDIENCE || '';
30-
const IAT_SKEW = parseInt(process.env.DPOP_IAT_SKEW_SECONDS || '300', 10);
30+
// Parse an integer env var, falling back to the default on absent OR malformed
31+
// input — a bare parseInt would yield NaN, which silently disables the iat
32+
// window (`Math.abs(...) > NaN` is false) or the clock skew (NaN is falsy).
33+
const intEnv = (name: string, def: number): number => {
34+
const n = parseInt(process.env[name] ?? '', 10);
35+
return Number.isNaN(n) ? def : n;
36+
};
37+
const IAT_SKEW = intEnv('DPOP_IAT_SKEW_SECONDS', 300);
3138
const REQUIRE_NONCE = process.env.DPOP_REQUIRE_NONCE === '1';
3239
const NONCE = process.env.DPOP_NONCE || 'conformance-test-nonce';
3340
// Buggy-nonce mode (negative-test fixture only): challenge when no nonce is
@@ -43,7 +50,7 @@ const NONCE_FIRST = process.env.DPOP_NONCE_FIRST === '1';
4350
// Clock-offset mode (negative-test fixture only): shift the server's notion of
4451
// "now" — and its `Date` header — by N seconds to simulate a skewed server
4552
// clock. Used to prove the scenario anchors its iat probes to the server's Date.
46-
const CLOCK_OFFSET = parseInt(process.env.DPOP_CLOCK_OFFSET_SECONDS || '0', 10);
53+
const CLOCK_OFFSET = intEnv('DPOP_CLOCK_OFFSET_SECONDS', 0);
4754

4855
// Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3 step 5).
4956
const ASYMMETRIC_ALGS = [

‎src/scenarios/server/auth/dpop.ts‎

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,12 @@ function properlyRejected(res: Response): boolean {
100100
return res.statusCode === 401 && hasDpopChallenge(res.wwwAuthenticate);
101101
}
102102

103-
// A `use_dpop_nonce` challenge means the server is demanding a nonce before it
104-
// will look at the proof. The negative probes are sent without a nonce, so such
105-
// a 401 is a rejection for the MISSING NONCE, not for the injected defect — it
106-
// cannot be attributed to the defect and must not count as a proper rejection.
103+
// A `use_dpop_nonce` challenge means the server is demanding a (different) nonce
104+
// before it will look at the proof. Negative probes carry the held nonce, so
105+
// such a 401 means the server did not accept that nonce (it rotated or
106+
// single-uses it, or wants a fresh one) — the rejection is about the nonce
107+
// lifetime, NOT the injected defect, so it cannot be attributed to the defect
108+
// and must not count as a proper rejection.
107109
function isNonceChallenge(res: Response): boolean {
108110
return (
109111
res.statusCode === 401 &&
@@ -192,7 +194,7 @@ function rejectionCheck(
192194
id,
193195
name,
194196
description,
195-
`server answered with a DPoP nonce challenge (use_dpop_nonce), so this rejection cannot be attributed to the ${String(details.case ?? 'injected')} defect rather than the missing nonce`,
197+
`server answered with a DPoP nonce challenge (use_dpop_nonce) despite the probe carrying the held nonce, so this rejection is about the nonce (rotated/stale/single-use) and cannot be attributed to the ${String(details.case ?? 'injected')} defect`,
196198
SPEC_REFERENCES
197199
);
198200
}
@@ -258,6 +260,15 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and
258260
jkt: kp.thumbprint
259261
});
260262

263+
// The server-provided nonce (RFC 9449 §8/§9), if the server requires one.
264+
// `send` refreshes it from every response's DPoP-Nonce header (newest wins,
265+
// RFC 9449 §8.2), and the local `buildDpopProof` wrapper folds the current
266+
// value into every subsequent proof — including the negatives — so a server
267+
// that checks the nonce first still evaluates the injected defect rather
268+
// than merely re-challenging. Refreshing (vs capturing once) keeps this
269+
// correct against servers that rotate or single-use their nonces.
270+
let heldNonce: string | undefined;
271+
261272
const send = async (
262273
authz: string,
263274
dpop: string | string[] | undefined
@@ -296,15 +307,11 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and
296307
const date = Number.isNaN(parsedDate)
297308
? undefined
298309
: Math.floor(parsedDate / 1000);
310+
// Newest-wins (RFC 9449 §8.2): carry the latest nonce into the next probe.
311+
if (dpopNonce) heldNonce = dpopNonce;
299312
return { statusCode: res.statusCode, wwwAuthenticate, dpopNonce, date };
300313
};
301314

302-
// The server-provided nonce captured from the baseline handshake (RFC 9449
303-
// §9), if the server requires one. Once set, the local `buildDpopProof`
304-
// wrapper folds it into every subsequent proof — including the negatives —
305-
// so a server that checks the nonce first still evaluates the injected
306-
// defect rather than merely challenging for a nonce.
307-
let heldNonce: string | undefined;
308315
const buildDpopProof = (
309316
opts: Parameters<typeof baseBuildDpopProof>[0]
310317
): Promise<string> =>
@@ -333,7 +340,8 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and
333340
first.dpopNonce &&
334341
first.wwwAuthenticate.includes('use_dpop_nonce')
335342
) {
336-
heldNonce = first.dpopNonce;
343+
// `send` has already refreshed heldNonce from the challenge response,
344+
// so the retry's proof carries it.
337345
return send(`DPoP ${token}`, await validProof());
338346
}
339347
return first;
@@ -637,12 +645,15 @@ algorithms, the 401 challenge format, token audience validation under DPoP, and
637645
}
638646

639647
// ---- iat acceptance window ----
640-
// Probes sit just outside the ±5-minute window (±300 s). The future probe
641-
// uses +303 s (not +301) because `iat` is whole-seconds and the proof drifts
642-
// ~1 s toward "now" in transit; +301 could read as +300 (in-window) at the
643-
// server and false-accept. The stale side only ages further, so −301 is safe.
648+
// Probes sit just outside the ±5-minute window (±300 s), ±303 s on both
649+
// sides. The 3 s margin absorbs two sources of ±1 s error that a bare ±301
650+
// would not: the whole-second `Date` header makes `serverClockOffset`
651+
// quantized to ±1 s, and `iat` is whole-seconds and drifts ~1 s toward "now"
652+
// in transit. At ±301 either could pull the probe onto the ±300 boundary and
653+
// be false-accepted; ±303 stays safely outside for a conformant server while
654+
// still being well inside a rejection for any sane implementation.
644655
for (const { label, name, iatDelta } of [
645-
{ label: 'stale', name: 'RejectsStaleIat', iatDelta: -301 },
656+
{ label: 'stale', name: 'RejectsStaleIat', iatDelta: -303 },
646657
{ label: 'future', name: 'RejectsFutureIat', iatDelta: 303 }
647658
]) {
648659
const description = `Server rejects a proof whose iat is ${label} — just outside the ±5-minute window (RFC 9449 §4.3 / SEP-1932)`;

0 commit comments

Comments
 (0)