Files
web-terminal/e2e/tests/adversarial-auth.test.ts
Yaojia Wang ad5cf06207 test(relay): cross-package e2e adversarial security harness
New e2e/ package wires the REAL P5(auth)+P4(crypto)+P2(agent) exports through
in-memory seams and an untrusted-relay attacker vantage (RelaySpy). Dynamically
re-validates F1–F4/F6 plus MITM/reflection/replay/reorder/INV2/cross-tenant/
single-use — every assertion runs the production code path. 21 tests green, tsc
clean. node_modules via symlinks (gitignored); no npm install needed.
2026-07-02 16:41:19 +02:00

227 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Auth (P5) attack matrix — dynamically re-validates the confirmed findings F1F4 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' })
})
})
})