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

@@ -11,6 +11,7 @@ import {
} from '../src/capability/verify.js'
import { jwkThumbprint } from '../src/crypto/thumbprint.js'
import { CapabilityError } from '../src/capability/errors.js'
import { encodeBase64UrlBytes } from 'relay-contracts'
import { setupP5SigningKey, makeEphemeral, principal, uuid } from './_helpers.js'
const NOW = 1_700_000_000
@@ -150,5 +151,31 @@ describe('capability token (§4.3)', () => {
expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(true)
expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(false)
})
// F4: a proof whose jwk.x decodes to a NON-32-byte blob makes importEd25519PublicRaw throw.
// verifyDpopProof must be TOTALLY fail-safe and RESOLVE to false, never reject.
it('resolves false (never throws) when the proof key is not a valid 32-byte Ed25519 key', async () => {
// A 16-byte "key" — importEd25519PublicRaw will reject this raw length.
const shortBlob = new Uint8Array(16).fill(7)
// cnf.jkt is computed over the SAME 16-byte blob so the thumbprint check passes and
// execution reaches the risky importEd25519PublicRaw call.
const cnfJkt = await jwkThumbprint(shortBlob)
const raw = await issueFor(signingKey, { cnfJkt })
const tok = await verifyCapabilityToken(raw, AUD, NOW)
const htu = 'https://alice.term.example.com/ws'
const enc = (o: unknown) => encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(o)))
const h = enc({ typ: 'dpop+ed25519', jwk: { crv: 'Ed25519', kty: 'OKP', x: encodeBase64UrlBytes(shortBlob) } })
const p = enc({ htu, htm: 'GET', jti: uuid(), iat: NOW })
const s = encodeBase64UrlBytes(new Uint8Array(64)) // any signature bytes
const proofJws = `${h}.${p}.${s}`
const verify = verifyDpopProof(tok, { proofJws, htu, htm: 'GET' }, NOW)
await expect(verify).resolves.toBe(false)
})
})
it('rejects a malformed cnf.jkt at issue (not a 43-char base64url thumbprint)', async () => {
await expect(issueFor(signingKey, { cnfJkt: 'short' })).rejects.toMatchObject({ reason: 'bad_cnf' })
})
})