/** * T13 · THE permanent cross-tenant tripwire (INV1). A green build MUST be impossible if * cross-tenant isolation regresses. Runs on every push/PR via .github/workflows/relay-tripwire.yml. * NEVER delete or skip this test. * * v0.9 unit variant (this file): in-memory port fakes; asserts every A→B path returns 403 — * onUpgrade, onReattach, a 1000-host fuzz, and aud-confusion. The v0.10 full-stack variant adds the * `cross-tenant-attempt` audit + alert assertions (needs T4 + T14, already exercised here via the * audit sink) and the real P1/P3 wiring. */ import { describe, it, expect, beforeEach } from 'vitest' import { randomUUID } from 'node:crypto' import { onUpgrade, onReattach, type EnforceDeps, type UpgradeContext } from '../../src/index.js' import { resetDpopCacheForTest } from '../../src/capability/verify.js' import type { StepUpPolicy } from '../../src/types.js' import { setupP5SigningKey, makeHost, fakeHostRegistry, fakeSessionRegistry, fakeRevocationStore, fakeTokenBucket, fakeAuditSink, issueWithDpop, uuid, } from '../_helpers.js' const NOW = 1_700_000_000 const AUD_A = 'alice.term.example.com' const AUD_B = 'bob.term.example.com' const ORIGIN_A = `https://${AUD_A}` const NEVER: StepUpPolicy = { required: false, maxAgeSeconds: Number.MAX_SAFE_INTEGER, requiredMethod: 'passkey' } describe('PERMANENT TRIPWIRE — device A can never reach host B (INV1)', () => { let signingKey: CryptoKey const hostA = uuid() const hostB = uuid() let deps: EnforceDeps let audit: ReturnType beforeEach(async () => { ;({ signingKey } = await setupP5SigningKey()) resetDpopCacheForTest() audit = fakeAuditSink() deps = { hosts: fakeHostRegistry([makeHost('acct-A', hostA), makeHost('acct-B', hostB)]), sessions: fakeSessionRegistry([{ sessionId: 'sess-B', hostId: hostB, accountId: 'acct-B' }]), revocation: fakeRevocationStore(), buckets: fakeTokenBucket(), audit, stepUpPolicyFor: () => NEVER, } }) function ctx(bundle: { raw: string; dpop: UpgradeContext['dpop'] }, over: Partial): UpgradeContext { return { capabilityRaw: bundle.raw, originHeader: ORIGIN_A, expectedAud: AUD_A, requestedHostId: hostA, requiredRight: 'attach', remoteAddrHash: 'ip', activeSessionCount: 0, dpop: bundle.dpop, principal: null, ...over, } } it('onUpgrade with A token but requestedHostId = hostB → 403', async () => { const b = await issueWithDpop(signingKey, { accountId: 'acct-A', host: hostB, aud: AUD_A, now: NOW }) const out = await onUpgrade(ctx(b, { requestedHostId: hostB }), deps, [ORIGIN_A], NOW) expect(out).toMatchObject({ ok: false, status: 403 }) }) it('onReattach with A token but a session owned by B → 403', async () => { const b = await issueWithDpop(signingKey, { accountId: 'acct-A', host: hostA, aud: AUD_A, now: NOW }) const out = await onReattach({ ...ctx(b, {}), sessionId: 'sess-B' }, deps, [ORIGIN_A], NOW) expect(out).toMatchObject({ ok: false, status: 403 }) }) it('fuzz: 1000 random host_ids not owned by A → all 403', async () => { for (let i = 0; i < 1000; i++) { resetDpopCacheForTest() const foreign = randomUUID() const b = await issueWithDpop(signingKey, { accountId: 'acct-A', host: foreign, aud: AUD_A, now: NOW }) const out = await onUpgrade(ctx(b, { requestedHostId: foreign }), deps, [ORIGIN_A], NOW) expect(out.ok).toBe(false) if (!out.ok) expect(out.status).toBe(403) } }) it('aud confusion: A token replayed at bob.term. → 403/401', async () => { const b = await issueWithDpop(signingKey, { accountId: 'acct-A', host: hostA, aud: AUD_A, now: NOW }) // present the A-audience token on B's subdomain const out = await onUpgrade( ctx(b, { expectedAud: AUD_B, requestedHostId: hostA, originHeader: ORIGIN_A }), deps, [ORIGIN_A], NOW, ) expect(out.ok).toBe(false) }) it('every A→B attempt records a deny (audit trail present, INV10)', async () => { const b = await issueWithDpop(signingKey, { accountId: 'acct-A', host: hostB, aud: AUD_A, now: NOW }) await onUpgrade(ctx(b, { requestedHostId: hostB }), deps, [ORIGIN_A], NOW) expect(audit.events.every((e) => e.outcome === 'deny')).toBe(true) expect(audit.events.some((e) => e.action === 'cross-tenant-attempt')).toBe(true) }) })