/** * Auth (P5) attack matrix — dynamically re-validates the confirmed findings F1–F4 plus cross-tenant * isolation and capability single-use. Each case ASSERTS the defense holds through the REAL * relay-auth enforcement path. Pass an explicit `now` everywhere. */ import { describe, it, expect, beforeEach } from 'vitest' import { needsStepUp, recordStepUp, verifyCapabilityToken, verifyDpopProof, finishAuthentication, WebAuthnError, type WebAuthnVerifier, type AuthenticationResponse, type WebAuthnCredential, } from 'relay-auth' import { buildRelayWorld, DEFAULT_NOW, STRICT_PASSKEY, NO_STEP_UP, type RelayWorld, } from '../harness/world.js' const NOW = DEFAULT_NOW describe('adversarial — auth (P5)', () => { let world: RelayWorld beforeEach(async () => { world = await buildRelayWorld(NOW) }) // ── F2 · mandatory step-up is fail-closed on the new-session path ────────────────────────────── describe('F2 (dynamic): host-driven step-up gate is fail-closed', () => { it('STRICT policy + principal:null → DENIED 403 step_up_required', async () => { const { hostA } = world world.setStepUpPolicy(STRICT_PASSKEY) const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud }) const out = await world.upgrade({ raw: cap.raw, dpop: cap.dpop, host: hostA.hostId, aud: hostA.aud, origin: hostA.origin, principal: null, now: NOW, }) expect(out).toMatchObject({ ok: false, status: 403, reason: 'step_up_required' }) expect(world.audit.events.some((e) => e.action === 'stepup' && e.outcome === 'deny')).toBe(true) }) it('required:false policy + principal:null → allowed (non-step-up host preserved)', async () => { const { hostA } = world world.setStepUpPolicy(NO_STEP_UP) const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud }) const out = await world.upgrade({ raw: cap.raw, dpop: cap.dpop, host: hostA.hostId, aud: hostA.aud, origin: hostA.origin, principal: null, now: NOW, }) expect(out.ok).toBe(true) }) }) // ── F1 · step-up factor must be method-bound (a passkey requirement ≠ a TOTP step-up) ─────────── describe('F1 (dynamic): step-up freshness is bound to the required method', () => { it('a fresh TOTP step-up does NOT satisfy a STRICT passkey policy; a passkey step-up DOES', async () => { const { hostA } = world world.setStepUpPolicy(STRICT_PASSKEY) const totp = recordStepUp(world.principal('acct-A', { authAt: NOW }), 'totp', NOW) expect(needsStepUp(totp, STRICT_PASSKEY, NOW)).toBe(true) const capT = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud }) const deniedTotp = await world.upgrade({ raw: capT.raw, dpop: capT.dpop, host: hostA.hostId, aud: hostA.aud, origin: hostA.origin, principal: totp, now: NOW, }) expect(deniedTotp).toMatchObject({ ok: false, status: 403, reason: 'step_up_required' }) const passkey = recordStepUp(world.principal('acct-A', { authAt: NOW }), 'passkey', NOW) expect(needsStepUp(passkey, STRICT_PASSKEY, NOW)).toBe(false) const capP = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud }) const allowedPasskey = await world.upgrade({ raw: capP.raw, dpop: capP.dpop, host: hostA.hostId, aud: hostA.aud, origin: hostA.origin, principal: passkey, now: NOW, }) expect(allowedPasskey.ok).toBe(true) }) }) // ── F4 · a thumbprint-matching but malformed DPoP key must fail-safe (resolve false, never throw) ─ describe('F4 (dynamic): verifyDpopProof is totally fail-safe', () => { it('a proof whose jwk.x is a non-32-byte blob RESOLVES false and yields a clean denied outcome', async () => { const { hostA } = world const craft = await world.craftMalformedDpop({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud, }) // Direct: the verifier resolves to false — it never throws / rejects (audit-evasion + DoS fix). const tok = await verifyCapabilityToken(craft.raw, hostA.aud, NOW) await expect(verifyDpopProof(tok, craft.dpop, NOW)).resolves.toBe(false) // Via the full enforcement path: a clean denied AuthzOutcome, not an exception. const out = await world.upgrade({ raw: craft.raw, dpop: craft.dpop, host: hostA.hostId, aud: hostA.aud, origin: hostA.origin, now: NOW, }) expect(out).toMatchObject({ ok: false, reason: 'dpop_proof_failed' }) // Exactly one audited deny was emitted (no unhandled rejection, no audit evasion). expect(world.audit.events.some((e) => e.outcome === 'deny')).toBe(true) }) }) // ── F3 · WebAuthn assertion must be bound to the stored credential id ─────────────────────────── describe('F3: an assertion whose credentialId ≠ the stored credential is rejected', () => { const RP_ID = 'alice.term.example.com' const ORIGIN = 'https://alice.term.example.com' const cred: WebAuthnCredential = { credentialId: 'cred-1', accountId: 'acct-A', publicKey: new Uint8Array([1, 2, 3]), signCount: 5, transports: ['internal'], createdAt: '2026-01-01T00:00:00.000Z', } // A permissive verifier that would "verify" ANY assertion — the credentialId cross-check must // still reject before the verifier's verdict is trusted. const forgivingVerifier: WebAuthnVerifier = { verifyRegistration: async () => ({ verified: true, credentialId: 'cred-1', publicKey: new Uint8Array([1, 2, 3]), signCount: 0, transports: ['internal'], }), verifyAuthentication: async (_r, _c, _rp, _o, _pk, _cid, stored) => ({ verified: true, newSignCount: stored + 1, }), } it('rejects with WebAuthnError even though the mock verifier returns verified:true', async () => { const resp: AuthenticationResponse = { clientChallenge: 'chal', origin: ORIGIN, rpId: RP_ID, credentialId: 'other-cred', // ≠ cred.credentialId } await expect( finishAuthentication(cred, resp, 'chal', RP_ID, ORIGIN, forgivingVerifier, NOW), ).rejects.toBeInstanceOf(WebAuthnError) await expect( finishAuthentication(cred, resp, 'chal', RP_ID, ORIGIN, forgivingVerifier, NOW), ).rejects.toThrow('credential id mismatch') }) }) // ── Cross-tenant · a token for account A can never reach a host owned by account B ────────────── describe('cross-tenant isolation (INV1)', () => { it('an A-account token presented at host B (owned by B) is DENIED 403', async () => { const { hostA, hostB } = world // token.host === requestedHostId (hostB) so host-scope passes; the registry then shows hostB // belongs to acct-B ≠ token.sub (acct-A) → the cross_tenant gate fires. const cap = await world.issueCap({ accountId: hostA.accountId, host: hostB.hostId, aud: hostB.aud }) const out = await world.upgrade({ raw: cap.raw, dpop: cap.dpop, host: hostB.hostId, aud: hostB.aud, origin: hostB.origin, now: NOW, }) expect(out).toMatchObject({ ok: false, status: 403 }) if (!out.ok) expect(['cross_tenant', 'host_scope_mismatch']).toContain(out.reason) expect(world.audit.events.some((e) => e.action === 'cross-tenant-attempt' && e.outcome === 'deny')).toBe(true) }) }) // ── Capability single-use · a token+jti may be upgraded exactly once ──────────────────────────── describe('capability single-use (consumeOnce)', () => { it('the same token+jti upgraded twice → the second is denied token_replayed', async () => { const { hostA } = world const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud }) const first = await world.upgrade({ raw: cap.raw, dpop: cap.dpop, host: hostA.hostId, aud: hostA.aud, origin: hostA.origin, now: NOW, }) expect(first.ok).toBe(true) const second = await world.upgrade({ raw: cap.raw, dpop: await cap.newDpop(NOW), // fresh DPoP so the jti burn (not the DPoP cache) is what denies host: hostA.hostId, aud: hostA.aud, origin: hostA.origin, now: NOW, }) expect(second).toMatchObject({ ok: false, status: 403, reason: 'token_replayed' }) }) }) })