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.
227 lines
8.9 KiB
TypeScript
227 lines
8.9 KiB
TypeScript
/**
|
||
* 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' })
|
||
})
|
||
})
|
||
})
|