fix(relay-auth): close 5 pre-production security findings (F1–F5)

F1 (HIGH): bind step-up freshness to the required method (stepUpMethod) so a
  login-time or weaker (TOTP) factor can no longer satisfy a passkey requirement.
F2 (HIGH): make the step-up gate host-driven and fail-closed on connect+reattach
  (deny when policy.required and the principal is missing/stale/wrong-method).
F3 (MED): bind the WebAuthn assertion to the stored credential (credentialId
  cross-check + forward publicKey/credentialId to the verifier).
F4 (LOW): make verifyDpopProof totally fail-safe (no unhandled throw), wrap the
  authz boundary into a clean audited 401, and validate cnfJkt format at issue.
F5 (LOW): return newSignCount from finishAuthentication so the caller can persist
  the advanced counter (WebAuthn clone detection).

All fixes ship regression tests. relay-auth: 122 tests green, tsc clean.
This commit is contained in:
Yaojia Wang
2026-07-02 16:41:18 +02:00
parent 1529d2c94c
commit a09c131539
15 changed files with 248 additions and 60 deletions

View File

@@ -124,10 +124,14 @@ export async function runEnforcement(
await emitDeny(deps, ctx, base, 'too_many_sessions', now)
return deny(403, 'too_many_sessions')
}
// 5. Step-up gate (Finding-3, v0.10) — only when a session principal is present.
if (ctx.principal !== null) {
const host = await deps.hosts.getById(outcome.hostId)
if (host !== null && needsStepUp(ctx.principal, deps.stepUpPolicyFor(host), now)) {
// 5. Step-up gate (Finding-3/F1/F2) — HOST-DRIVEN and FAIL-CLOSED on connect AND reattach.
// When a host REQUIRES step-up, DENY unless an authenticated principal proves a FRESH step-up
// of the required method. A missing host, a null principal, or a stale/wrong-method step-up all
// fail closed. When the policy does not require step-up, this gate is a no-op.
const host = await deps.hosts.getById(outcome.hostId)
const policy = deps.stepUpPolicyFor(host as HostRecord)
if (policy.required) {
if (host === null || ctx.principal === null || needsStepUp(ctx.principal, policy, now)) {
await emitDeny(deps, ctx, 'stepup', 'step_up_required', now)
return deny(403, 'step_up_required')
}