fix(auth): bind step-up challenge consumption to the acting Agent (HT-75) - #96
Conversation
…-75) Found by an independent Codex review of PR #94, after that PR merged. `verifyAuthenticationCeremony` consumed the `webauthn_challenges` row immediately after the token's signature/ceremony check, and only then resolved the credential and applied `requireAgentId`. But `requireAgentId` was compared against the CREDENTIAL's owner — never against the Agent the challenge itself was minted for, even though both the signed token and the DB row already carry that `agentId`. Consequence: anyone holding a victim's step-up challenge token could spend it from their own session with any garbage `response`. The consume succeeded, verification then failed, and the victim's own legitimate verify came back `challenge_expired`. Not an authentication bypass — the assertion is still cryptographically verified and the credential-owner check still holds — but a griefing DoS against a real user's step-up. Fixed at both layers, matching spec §7's "two independent layers, not duplicated logic": - Application: reject when the token's `agentId` is not the acting Agent, BEFORE the consume runs. - Database: `consumeChallenge` takes an optional `expectedAgentId` that adds `AND agent_id = $3` to the UPDATE predicate. The login ceremony is deliberately unaffected: its challenges are minted with `agent_id IS NULL` (discoverable credential, spec §4.3), so the binding is conditional on `requireAgentId` rather than unconditional. Regression test proven to catch the original bug: with both layers reverted, it fails on the burn assertion (the victim's row is already consumed). One pre-existing step-up test was minting its challenge with `agentId: null`, which the new check would have short-circuited — it now mints for the impersonator so it still exercises the credential-owner check it is named for. Gates: typecheck, lint, test:coverage all exit 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughStep-up WebAuthn challenges now require the token’s Agent binding to match the acting Agent before cryptographic verification and consumption. Challenge storage supports conditional Agent-scoped consumption, while login challenge behavior remains unchanged. Tests cover mismatch rejection and challenge preservation. ChangesAgent-scoped step-up verification
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant Caller
participant verifyAuthenticationCeremony
participant verifyAuthenticationResponse
participant WebAuthnStore
participant Database
Caller->>verifyAuthenticationCeremony: Submit step-up response with requireAgentId
verifyAuthenticationCeremony->>verifyAuthenticationCeremony: Check token Agent binding
verifyAuthenticationCeremony->>verifyAuthenticationResponse: Verify cryptographic response
verifyAuthenticationCeremony->>WebAuthnStore: Consume challenge for requireAgentId
WebAuthnStore->>Database: Match nonce, ceremony, and agent_id
Database-->>WebAuthnStore: Consumption result
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
…me too Codex review of the previous commit, findings 2 and 5. Finding 5 (the real one): the step-up ceremony test could not fail if the new `AND agent_id = $3` clause were deleted. The app-layer check returns first, so the store's clause was never reached — dead weight no test would notice losing. Two direct store-level tests added: a step-up row minted for one Agent is not consumable under another (proven to fail when the clause is removed), and a login row with `agent_id IS NULL` stays consumable when no Agent is expected, which guards the SQL branch itself. This also corrects an overclaim in the previous commit's PR description. Disabling the app layer and watching the test fail showed the DB clause firing in THAT configuration; it did not show the committed test covering the clause. Those are different properties and only the first was verified. Finding 2: registration's consume omitted the Agent argument, so its binding rested solely on the app-level check above it. No live bug — that check is correct — but the DB layer was not defense in depth the way step-up's now is. Passed through for consistency. Not addressed, deliberately: `verifyAuthenticationCeremony` still accepts `ceremony: 'step-up'` with no `requireAgentId`, which would silently disable the binding. No caller does this. Making it unrepresentable is a type-level signature change beyond this fix's scope — noted in the PR for TJ. Gates: typecheck, lint, test:coverage all exit 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…T-75) (#97) Codex review of PR #96, finding 4 — the one item that PR deliberately left open. VerifyAuthenticationCeremonyParams was a single shape with an optional requireAgentId, so `{ ceremony: 'step-up' }` with no Agent type-checked fine. That combination silently disables BOTH halves of the binding added in #96 — the pre-consume check and consumeChallenge's AND agent_id = $3 — reopening the challenge-burn DoS. No caller does it; nothing but this type stopped one from starting. Now a discriminated union: 'step-up' requires requireAgentId, and 'authentication' forbids it via `requireAgentId?: never` (its challenges are minted with agent_id IS NULL, so binding one would match no row and break every login). No caller changes were needed — both existing call sites already passed the correct shape, which is the evidence that this tightens the contract without narrowing real behavior. Verified with a negative type test: both `step-up` without requireAgentId and `authentication` with it are now compile errors (TS2322). Gates: typecheck, lint, test:coverage all exit 0. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The defect
Found by an independent Codex review of #94, run after that PR had already merged. This fixes it on
main.verifyAuthenticationCeremonyconsumed thewebauthn_challengesrow right after the token's signature/ceremony check, then resolved the credential and appliedrequireAgentId. ButrequireAgentIdwas compared against the credential's owner — never against the Agent the challenge was minted for, even though both the signed token and the DB row already carry thatagentId.consumeChallengefiltered onnonce+ceremonyonly.Exploit sequence:
T.Tand POSTs to/api/v1/auth/step-up/webauthn/verifyfrom their own session with any garbageresponse.challenge_expired.Severity — my read, stated plainly: this is not an authentication bypass. The assertion is still cryptographically verified and the credential-owner check still holds. It's a griefing DoS, and it presupposes the attacker already has the victim's token (XSS, log leak, shared machine). Worth fixing because the binding data was already persisted and unused — the fix is nearly free.
Fix — both layers
Matching spec §7's "two independent layers, not duplicated logic":
agentId≠ the acting Agent, before the consumeconsumeChallengetakes optionalexpectedAgentId, addingAND agent_id = $3to the UPDATE predicateLogin is deliberately unaffected. Its challenges are minted with
agent_id IS NULL(discoverable credential, spec §4.3), so the binding is conditional onrequireAgentId. Binding unconditionally would break every login consume, since= NULLmatches nothing in SQL.Verification
The regression test was proven to catch the original bug, not just asserted to:
expected false to be true, i.e. the victim's row was already consumed.One pre-existing test needed adjusting, called out for review:
'step-up requireAgentId mismatch is rejected before running cryptographic verification'minted its challenge withagentId: null, so the new check would have short-circuited it and it would have passed for the wrong reason. It now mints for the impersonator, so it still exercises the credential-owner check it's named for.Gates:
typecheck,lint,test:coverageall exit 0 locally.Note on process
#94 merged while this review was still running. The
deleteCredentialTOCTOU that CodeRabbit found on that PR was fixed before merge (08e05fb) and Codex independently confirmed that fix is sound. This is a separate defect that only the Codex pass found — consistent with the delegation ladder's premise that different vendors catch disjoint defects.🤖 Generated with Claude Code
Summary by CodeRabbit