Skip to content

v0.1.4 — optional single-phase first refresh with challenge-rotation overlap

Choose a tag to compare

@ScottHelme ScottHelme released this 19 May 17:01

Steady-state DBSC refreshes are already single-phase (every refresh 200 hands back the next challenge). Only the registration → first-refresh window costs an extra 403+challenge round-trip. This release adds an opt-in way to remove that round-trip, plus the overlap guard that makes it safe.

DbscServer::advertiseRefreshChallenge($binding, $ctx) attaches the binding's seed Secure-Session-Challenge to an ordinary authenticated response so the browser holds a challenge when its first /dbsc/refresh fires and skips the 403. Delivered exactly once — on emission it records a one-way Binding::$challengeAdvertised mark (single store write) and no-ops thereafter, and once hasRefreshed or the challenge is empty/expired. Per spec it can never ride the registration response (§9.2.1 / §8.7); it takes a Binding, which only exists post-registration, so that misuse is structurally impossible.

Challenge-rotation overlap. A reactive 403 can rotate the challenge while a concurrent response advertised the pre-rotation value; the browser's first refresh would then challenge-mismatch in refresh()JwtInvalidException, the terminal revoke-and-logout path, not a benign 403 retry. refresh() now also accepts the single immediately-previous challenge, constant-time, until its own TTL (previousChallengeTime + challengeTtlSeconds).

  • Single-depth — a challenge two rotations ago never matches.
  • Deliberately not retained across a successful refresh: the 200 delivers the new challenge synchronously, so there is no propagation window to bridge and the spent challenge stays non-replayable. This asymmetry vs the bound-cookie overlap is intentional.
  • Same security envelope as the v0.1.3 cookie overlap: the challenge is a replay nonce inside a JWT the device-bound key must still sign, so no attack surface widens.

No API removals. New Binding fields (hasRefreshed, challengeAdvertised, previousChallenge, previousChallengeTime) are optional with safe defaults — records written by v0.1.0–v0.1.3 decode without throwing and behave exactly as before until their next refresh. The v0.1.2 corrupt-state fail-closed contract is unchanged. advertiseRefreshChallenge() is additive.