Skip to content

fix(auth): bind step-up challenge consumption to the acting Agent (HT-75) - #96

Merged
zaridan merged 2 commits into
mainfrom
fix/ht-75-stepup-challenge-binding
Jul 19, 2026
Merged

fix(auth): bind step-up challenge consumption to the acting Agent (HT-75)#96
zaridan merged 2 commits into
mainfrom
fix/ht-75-stepup-challenge-binding

Conversation

@zaridan

@zaridan zaridan commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

The defect

Found by an independent Codex review of #94, run after that PR had already merged. This fixes it on main.

verifyAuthenticationCeremony consumed the webauthn_challenges row right after the token's signature/ceremony check, then resolved the credential and applied requireAgentId. But requireAgentId was 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 that agentId.

consumeChallenge filtered on nonce + ceremony only.

Exploit sequence:

  1. Victim starts step-up, receives challenge token T.
  2. Attacker obtains T and POSTs to /api/v1/auth/step-up/webauthn/verify from their own session with any garbage response.
  3. Server consumes the victim's challenge row, then fails verification.
  4. Victim's own legitimate verify now returns 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":

Layer Change
Application Reject when the token's agentId ≠ the acting Agent, before the consume
Database consumeChallenge takes optional expectedAgentId, adding AND agent_id = $3 to the UPDATE predicate

Login is deliberately unaffected. Its challenges are minted with agent_id IS NULL (discoverable credential, spec §4.3), so the binding is conditional on requireAgentId. Binding unconditionally would break every login consume, since = NULL matches nothing in SQL.

Verification

The regression test was proven to catch the original bug, not just asserted to:

  • With both layers reverted to the original code, it fails on the burn assertion — expected false to be true, i.e. the victim's row was already consumed.
  • With only the app layer disabled, it still fails (on the reason) — confirming the DB layer is genuinely independent, not decorative.

One pre-existing test needed adjusting, called out for review: 'step-up requireAgentId mismatch is rejected before running cryptographic verification' minted its challenge with agentId: 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:coverage all exit 0 locally.

Note on process

#94 merged while this review was still running. The deleteCredential TOCTOU 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

  • Security Enhancements
    • Step-up authentication challenges are now tied to the intended Agent.
    • Attempts to use a challenge with another Agent’s session are rejected.
    • Invalid attempts do not consume the legitimate challenge, allowing it to be used correctly.

…-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>
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d8f045f8-8b73-4616-a13f-e005cbf1db93

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Step-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.

Changes

Agent-scoped step-up verification

Layer / File(s) Summary
Agent-scoped challenge consumption
src/store/webauthn.ts
consumeChallenge accepts an optional expected Agent and adds an agent_id predicate and parameter when provided; unscoped login consumption retains its existing bindings.
Step-up verification enforcement and tests
src/auth/webauthn-ceremony.ts, src/auth/webauthn-ceremony.test.ts
Step-up verification rejects mismatched Agent bindings before cryptographic verification, passes the expected Agent to challenge consumption, and tests that mismatched attempts leave the correctly bound challenge consumable.

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
Loading

Possibly related PRs

  • Helpthread/helpthread#94: Touches the same WebAuthn verification and challenge-consumption paths, including Agent-scoped step-up enforcement.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: step-up challenge consumption is bound to the acting Agent.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ht-75-stepup-challenge-binding

Comment @coderabbitai help to get the list of available commands.

…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>
@zaridan
zaridan merged commit 46f036b into main Jul 19, 2026
5 checks passed
@zaridan
zaridan deleted the fix/ht-75-stepup-challenge-binding branch July 19, 2026 23:54
zaridan added a commit that referenced this pull request Jul 20, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant