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:
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user