Releases: report-uri/dbsc-php
Release list
v0.2.0 — challenge mismatch is a retryable, non-terminal refresh outcome
Splits challenge mismatch out of the terminal failure path. A challenge mismatch is now a benign, retryable refresh outcome (a fresh 403 + challenge), not a JwtInvalidException that revokes the session and logs the user out.
Why it's safe: refresh() reaches the challenge comparison only after the JWT signature has already verified against the device-bound key. A validly-signed JWT proves possession of the device private key, so a mismatch at that point can only be a benign race — an idle session, a concurrent refresh, a lost 403 — never a forged or stolen-cookie signal. Treating it as terminal only ever punished the loser of a race (usually the legitimate user); a bad signature still dies earlier as a terminal JwtInvalidException, unchanged. This also closes a replay-driven logout: a spent-but-signed JWT replayed after rotation used to force-log-out the real user, and is now benign.
New RetryableRefreshException marker. MissingChallengeException, ChallengeExpiredException, and the new ChallengeMismatchException all implement it — catch the marker once instead of enumerating the classes:
try {
emit($dbsc->refresh($jwt, $ctx));
} catch (RetryableRefreshException) {
emit($dbsc->issueRefreshChallenge($ctx)); // benign: 403, browser retries
} catch (DbscException $e) {
emit($dbsc->revoke($ctx, enforcementTerminated: true)); // terminal: stolen-cookie signal
}Distinct audit event for benign retries. All three retryable paths now log DbscServer::EVENT_REFRESH_RETRYABLE (dbscRefreshRetryable); EVENT_REFRESH_FAILED (dbscRefreshFailed) is now reserved for terminal failures (bad signature, unknown session). Ordinary concurrency no longer inflates the signal an integrator alerts on.
⚠️ Upgrading from 0.1.x — read this
Nothing breaks silently: the common broad-catch pattern stays compile-safe and keeps today's fail-safe (terminal) behaviour on mismatch until you opt in, and the two patterns that do break fail loudly and immediately (at compile time or on the first refresh), not weeks later in production. Three things to check:
-
Mismatch now throws
ChallengeMismatchException, notJwtInvalidException. If you catchJwtInvalidExceptionspecifically to handle a challenge mismatch, it no longer lands there. Both stillextends DbscException, so a broadcatch (DbscException) { revoke(...) }keeps working — but it also keeps the old behaviour (a benign mismatch still logs the user out) until you add acatch (RetryableRefreshException)ahead of it. That extra catch is the whole point of the release; without it the upgrade is a no-op. -
The
dbscRefreshRetryableaudit event is new, andMissingChallengeException/ChallengeExpiredExceptionmoved onto it (they loggeddbscRefreshFailedin 0.1.x). If yourAuditLoggerInterfaceimplementation does an exhaustivematch/switchover theEVENT_*constants and throws on an unknown value, add an arm forEVENT_REFRESH_RETRYABLEor it will throw on the first benign refresh. A logger that records the event string as-is needs no change. -
Monitoring: any alert keyed on
dbscRefreshFailedvolume will see it drop (benign missing/expired/mismatch no longer count toward it) and a newdbscRefreshRetryablestream appear. This is the intended split — re-point dashboards accordingly, and keep alerting ondbscRefreshFailedsince it now means a genuine terminal failure.
No other API removals. ChallengeMismatchException, RetryableRefreshException, and EVENT_REFRESH_RETRYABLE are additive; records written by 0.1.x decode unchanged.
v0.1.5 — add allowed_refresh_initiators session-config key
Adds the DBSC allowed_refresh_initiators session-config key (spec) — out-of-scope hosts trusted to trigger a refresh on a cross-site-initiated navigation, which Chrome otherwise refuses as a timing side-channel mitigation.
Two ways to set it, three-state resolution:
- Fleet-wide default —
Config(allowedRefreshInitiators: ['rp.example', '*.example.com']). - Per request —
RequestContext(..., allowedRefreshInitiators: [...]).nullfalls back to theConfigvalue,[]forces the key off for that request even whenConfigsets a default, and a non-empty list wins.
Emitted through the single instructionsJson() funnel, so the key rides register, refresh, and sessionInstructionsJson() alike. Empty/whitespace-only entries are trimmed and dropped, and when the effective list is empty the key is omitted entirely — output is byte-identical to v0.1.4 unless you opt in.
No format validation, by design. Entries pass through verbatim (wildcards included). This is an allow-list: a malformed or dead entry only ever loses trust, never grants it, and validating a fleet-wide Config value would let a single typo break config loading for every session — worse than the silent dead-entry it would guard against. Consistent with how the rest of the library treats config.
Security note. Each listed host regains the authentication-state timing oracle this mitigation removes — list only relying parties you trust.
No API removals; the new Config and RequestContext parameters are optional with safe defaults, so existing call sites are unaffected.
Thanks to @joostdebruijn for the contribution.
v0.1.4 — optional single-phase first refresh with challenge-rotation overlap
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
200delivers 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.
v0.1.3 — accept the immediately-previous bound cookie within its lifetime
The bound cookie rotates on every /dbsc/refresh. The refresh round-trip is a propagation window during which a normal, already-dispatched request still carries the pre-rotation value; an enforcement gate built on boundCookieMatches() saw that as a mismatch and tore the session down, even though the request was legitimate. The window equals the refresh round-trip, so the failure is latency-proportional — frequent behind a CDN/WAN, near-invisible on loopback.
boundCookieMatches() now also accepts the single immediately-previous cookie value, but only until the instant that value would itself have expired in the browser (cookieIssuedAt + cookieMaxAgeSeconds). No arbitrary grace constant.
- Single-depth history — two-rotations-ago never matches.
- A stolen cookie still cannot complete a refresh without the device-bound key, so it still hard-fails at the next refresh.
- Required
Bindingfields stay strictly type-checked: the v0.1.2 corrupt-state fail-closed contract is unchanged. The new fields are optional with safe defaults — records written by v0.1.0–v0.1.2 decode without throwing and simply have no previous-value overlap until their next refresh. Never a lockout, never fail-open.
No API removals; Binding::withRotatedCookieAndChallenge() gains $now and $cookieMaxAgeSeconds parameters.
v0.1.2 — fail closed on a corrupt stored DBSC record
Security / correctness
Fail closed on a corrupt stored DBSC record.
A stored binding (or pending-registration) record that was present but unparseable previously decoded to null, and the recommended host gate treats null as "no binding → degrade to cookie auth". A corrupt binding therefore silently downgraded a hard-DBSC session to plain cookie auth, re-opening the stolen-cookie hole DBSC exists to close. The docblocks claimed this "failed closed"; it did not.
Not client-triggerable. The write path always emits valid, well-typed JSON; the only client-derived field (publicKeyPem) is structurally validated first. The realistic trigger is an internal phpredis-serializer / truncation / cross-version-schema event. This is a defense-in-depth and contract-correctness fix.
Changes
- New
Exception\CorruptStateException(extendsDbscException). Binding::fromJson()/PendingRegistration::fromJson()now throwCorruptStateExceptioninstead of returningnull; return type?self→self.nullis reserved for "no record".StoreInterfacedocuments the absent-vs-corrupt contract explicitly.DbscServer::revoke()swallowsCorruptStateExceptionso a corrupt record is still deleted and audited rather than aborting teardown;@throws CorruptStateExceptionadded to the store-reading public methods.
Upgrading
Source/binary compatible for the recommended host wiring: CorruptStateException extends DbscException, so the gate read fails closed (uncaught → 500), the refresh terminal path's existing catch (… | DbscException) revokes + logs out, and register → 401. InMemoryStore is unchanged. If you call Binding::fromJson() / PendingRegistration::fromJson() directly, note they no longer return null — wrap in try/catch (CorruptStateException).
Full Changelog: v0.1.1...v0.1.2
v0.1.1
Test-only maintenance release. Supersedes v0.1.0 — recommended over v0.1.0.
No library changes: src/ is byte-identical to v0.1.0. This release only makes the bundled test harness deterministic.
Fixed
_test/run-tests.php: the fake device built its JWK EC coordinates straight fromopenssl_pkey_get_details(), which strips leading zero bytes, so ~1/256 generated keys produced a <32-bytex/yand the (correct, RFC 7518-compliant) verifier rejected them — an intermittent test failure. The fake device now left-pads coordinates to the fixed 32-byte width, exactly as a spec-compliant client (and real Chrome) does. Verified deterministic over 300 consecutive runs and CI on PHP 8.1–8.4. (#3)
Note
The strict fixed-width JWK check in JwtVerifier is intentional and unchanged — it correctly rejects non-spec-compliant keys.
Still 0.x — the packaging API (RequestContext / DbscResponse / StoreInterface) may change before v1.0.0; see the README.
v0.1.0
First tagged release of dbsc-php — a small, framework-agnostic PHP server library for Device Bound Session Credentials (DBSC), extracted from Report URI's production integration.
0.x — the public API may still change. The protocol/crypto logic is production-proven, but the packaging surface (RequestContext, DbscResponse, StoreInterface) has not yet been exercised by an external consumer; expect possible adjustments before v1.0.0.
Highlights
- Pure HTTP-header DBSC server: ES256 JWT verification, registration/refresh state machine, enforcement-gate primitives.
- Framework-agnostic — never touches superglobals, headers, or cookies; you map
RequestContext⇄DbscResponse. - Pluggable
StoreInterface(+ bundledInMemoryStore); zero runtime dependencies beyondext-openssl/ext-json. - Wire-protocol corrections from real-browser integration baked in (see README → Wire-protocol notes).
- Self-contained test harness (
_test/run-tests.php, 22 checks) and reference front controller (_test/server.php).
See the README for the flow, the recommended enforcement gate, and the storage requirement.