diff --git a/e2e/harness/dpop.ts b/e2e/harness/dpop.ts new file mode 100644 index 0000000..d6bdd1b --- /dev/null +++ b/e2e/harness/dpop.ts @@ -0,0 +1,141 @@ +/** + * P5 signing-key setup + capability-token / DPoP bundle builder. These wire the REAL + * relay-auth issue + DPoP primitives. `resetVerifyKeyForTest`, `resetDpopCacheForTest`, + * `buildDpopProof`, `jwkThumbprint`, and the WebCrypto Ed25519 helpers are TEST-ONLY / internal, + * so they are deep-imported from `relay-auth/src/...` (relay-auth has no `exports` map, so subpath + * imports resolve through the e2e node_modules symlink). + */ +import { randomUUID } from 'node:crypto' +import { issueCapabilityToken, type DpopContext } from 'relay-auth' +import type { CapabilityRight } from 'relay-contracts' +import { encodeBase64UrlBytes } from 'relay-contracts' +import { + configureVerifyKey, + resetVerifyKeyForTest, +} from 'relay-auth/src/config/keys.js' +import { + generateEd25519KeyPair, + exportEd25519PublicRaw, +} from 'relay-auth/src/crypto/ed25519.js' +import { buildDpopProof, resetDpopCacheForTest } from 'relay-auth/src/capability/verify.js' +import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js' +import { principal } from './fakes.js' + +export { resetDpopCacheForTest, jwkThumbprint } + +export interface P5Key { + readonly signingKey: CryptoKey + readonly publicRaw: Uint8Array +} + +/** Configure the P5 verifying key globally and return the matching private signing key. */ +export async function setupP5SigningKey(): Promise { + resetVerifyKeyForTest() + const pair = await generateEd25519KeyPair() + configureVerifyKey(pair.publicKey) + const publicRaw = await exportEd25519PublicRaw(pair.publicKey) + return { signingKey: pair.privateKey, publicRaw } +} + +export interface Ephemeral { + readonly privateKey: CryptoKey + readonly publicRaw: Uint8Array +} + +export async function makeEphemeral(): Promise { + const pair = await generateEd25519KeyPair() + return { privateKey: pair.privateKey, publicRaw: await exportEd25519PublicRaw(pair.publicKey) } +} + +export interface IssueOpts { + readonly accountId: string + readonly host: string + readonly aud: string + readonly rights?: readonly CapabilityRight[] + readonly ttl?: number + readonly now: number + readonly htu?: string + readonly htm?: string +} + +/** + * A capability token bundled with a matching DPoP proof. `newDpop(at?)` mints a FRESH DPoP proof + * (new jti) bound to the SAME ephemeral key — needed to re-present the same token under a distinct + * DPoP (single-use jti / revocation tests) without tripping the DPoP replay cache. + */ +export interface CapBundle { + readonly raw: string + readonly dpop: DpopContext + readonly newDpop: (at?: number) => Promise +} + +export async function issueCapBundle(signingKey: CryptoKey, o: IssueOpts): Promise { + const eph = await makeEphemeral() + const cnfJkt = await jwkThumbprint(eph.publicRaw) + const raw = await issueCapabilityToken( + { + principal: principal(o.accountId), + aud: o.aud, + host: o.host, + rights: o.rights ?? ['attach'], + ttlSeconds: o.ttl ?? 45, + cnfJkt, + }, + signingKey, + o.now, + ) + const htu = o.htu ?? `https://${o.aud}/ws` + const htm = o.htm ?? 'GET' + const newDpop = async (at: number = o.now): Promise => ({ + proofJws: await buildDpopProof(eph.privateKey, eph.publicRaw, { + htu, + htm, + jti: randomUUID(), + iat: at, + }), + htu, + htm, + }) + return { raw, dpop: await newDpop(o.now), newDpop } +} + +/** + * F4 crafting: a capability whose `cnf.jkt` is the thumbprint of a NON-32-byte blob, plus a DPoP + * proof whose `header.jwk.x` decodes to that same blob. The thumbprint check therefore PASSES and + * execution reaches `importEd25519PublicRaw(blob)`, which throws on the bad length — exercising the + * fail-safe (must resolve to false, never reject). + */ +export interface MalformedOpts { + readonly accountId: string + readonly host: string + readonly aud: string + readonly now: number + readonly blobLen?: number +} + +export async function craftMalformedDpopBundle( + signingKey: CryptoKey, + o: MalformedOpts, +): Promise<{ raw: string; dpop: DpopContext }> { + const blob = new Uint8Array(o.blobLen ?? 16).fill(7) + const cnfJkt = await jwkThumbprint(blob) // 43-char base64url regardless of blob length + const raw = await issueCapabilityToken( + { + principal: principal(o.accountId), + aud: o.aud, + host: o.host, + rights: ['attach'], + ttlSeconds: 45, + cnfJkt, + }, + signingKey, + o.now, + ) + const htu = `https://${o.aud}/ws` + const enc = (x: unknown): string => + encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(x))) + const h = enc({ typ: 'dpop+ed25519', jwk: { crv: 'Ed25519', kty: 'OKP', x: encodeBase64UrlBytes(blob) } }) + const p = enc({ htu, htm: 'GET', jti: randomUUID(), iat: o.now }) + const s = encodeBase64UrlBytes(new Uint8Array(64)) // arbitrary signature bytes + return { raw, dpop: { proofJws: `${h}.${p}.${s}`, htu, htm: 'GET' } } +} diff --git a/e2e/harness/fakes.ts b/e2e/harness/fakes.ts new file mode 100644 index 0000000..3f3ac73 --- /dev/null +++ b/e2e/harness/fakes.ts @@ -0,0 +1,132 @@ +/** + * In-memory port fakes — the ONLY seams that stand in for the true I/O boundaries P1/P3 own + * (host registry / session registry / revocation store / token buckets / audit sink / revocation + * bus). Every security decision still runs through the REAL relay-auth exports; these fakes only + * supply storage. Adapted from `relay-auth/test/_helpers.ts` (same shapes, bare import paths). + */ +import type { HostRecord, KillSignal, RevocationBus } from 'relay-contracts' +import type { + AuthenticatedPrincipal, + AuditEvent, + AuditSink, + HostRegistryPort, + SessionRegistryPort, + RevocationStore, + TokenBucketStore, +} from 'relay-auth' + +/** An authenticated principal (the ONLY source of accountId, INV3). */ +export function principal( + accountId: string, + overrides: Partial = {}, +): AuthenticatedPrincipal { + return { + kind: 'human', + accountId, + principalId: 'cred-' + accountId, + amr: ['passkey'], + authAt: 1000, + stepUpAt: null, + ...overrides, + } +} + +/** A minimal online HostRecord for the authz registry (agentPubkey here is unused by authz). */ +export function makeHostRecord( + accountId: string, + hostId: string, + status: HostRecord['status'] = 'online', +): HostRecord { + return { + hostId, + accountId, + subdomain: 'sub-' + hostId, + agentPubkey: new Uint8Array(32), + enrollFpr: 'fpr-' + hostId, + status, + lastSeen: '2026-01-01T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + revokedAt: null, + } +} + +export function fakeHostRegistry(hosts: readonly HostRecord[]): HostRegistryPort { + const map = new Map(hosts.map((h) => [h.hostId, h])) + return { getById: async (id) => map.get(id) ?? null } +} + +export interface MutableSessionRegistry extends SessionRegistryPort { + add(entry: { sessionId: string; hostId: string; accountId: string }): void +} + +export function fakeSessionRegistry( + seed: readonly { sessionId: string; hostId: string; accountId: string }[] = [], +): MutableSessionRegistry { + const map = new Map(seed.map((s) => [s.sessionId, { hostId: s.hostId, accountId: s.accountId }])) + return { + getById: async (id) => map.get(id) ?? null, + add: (e) => { + map.set(e.sessionId, { hostId: e.hostId, accountId: e.accountId }) + }, + } +} + +export interface RevocationFake extends RevocationStore { + readonly revoked: Set + readonly consumed: Set +} + +export function fakeRevocationStore(): RevocationFake { + const revoked = new Set() + const consumed = new Set() + return { + revoked, + consumed, + isRevoked: async (jti) => revoked.has(jti), + revokeJti: async (jti) => { + revoked.add(jti) + }, + consumeOnce: async (jti) => { + if (consumed.has(jti)) return false + consumed.add(jti) + return true + }, + } +} + +export interface TokenBucketFake extends TokenBucketStore { + readonly blocked: Set + readonly calls: string[] +} + +/** Token bucket that always allows unless a key is added to `blocked`. */ +export function fakeTokenBucket(): TokenBucketFake { + const blocked = new Set() + const calls: string[] = [] + return { + blocked, + calls, + take: async (key) => { + calls.push(key) + return !blocked.has(key) + }, + } +} + +export interface AuditFake extends AuditSink { + readonly events: AuditEvent[] +} + +export function fakeAuditSink(): AuditFake { + const events: AuditEvent[] = [] + return { events, append: async (e) => void events.push(e) } +} + +export interface RevocationBusFake extends RevocationBus { + readonly published: KillSignal[] +} + +export function fakeRevocationBus(): RevocationBusFake { + const published: KillSignal[] = [] + return { published, publish: async (s) => void published.push(s) } +} diff --git a/e2e/harness/spy.ts b/e2e/harness/spy.ts new file mode 100644 index 0000000..a5d11e6 --- /dev/null +++ b/e2e/harness/spy.ts @@ -0,0 +1,34 @@ +/** + * RelaySpy — the untrusted-relay / attacker vantage. A passthrough that records every ciphertext + * byte it forwards (exactly what a malicious relay can see). INV2: a known plaintext marker must + * NEVER appear in `captured`. Mirrors the `relay-e2e/test/integration.test.ts` spy. + */ +import { MAX_FRAME_BYTES } from 'relay-e2e' + +export class RelaySpy { + readonly captured: Uint8Array[] = [] + + /** Forward a sealed payload, recording a copy of the ciphertext the relay observes. */ + forward(payload: Uint8Array): Uint8Array { + if (payload.length > MAX_FRAME_BYTES) { + throw new Error(`payload exceeds MAX_FRAME_BYTES (${payload.length} > ${MAX_FRAME_BYTES})`) + } + this.captured.push(payload.slice()) + return payload + } + + /** Latin1 concatenation of everything the relay saw — for substring canary scans. */ + snapshot(): string { + return this.captured.map((b) => Buffer.from(b).toString('latin1')).join(' ') + } + + /** True if `marker` appears in ANY captured frame (utf8 or latin1) — INV2 tripwire. */ + contains(marker: string): boolean { + if (this.snapshot().includes(marker)) return true + return this.captured.some( + (b) => + Buffer.from(b).toString('utf8').includes(marker) || + Buffer.from(b).toString('latin1').includes(marker), + ) + } +} diff --git a/e2e/harness/world.ts b/e2e/harness/world.ts new file mode 100644 index 0000000..18244a5 --- /dev/null +++ b/e2e/harness/world.ts @@ -0,0 +1,332 @@ +/** + * buildRelayWorld() — composes the REAL relay-auth (P5) + relay-e2e (P4) + agent (P2 replay) + * exports through the in-memory seams in ./fakes and exposes a clean flow API plus the untrusted + * "RelaySpy" attacker vantage. Only the true I/O boundaries are faked; every security check is the + * production code path. Pass an explicit `now` everywhere (no Date.now in assertions). + * + * The host identity used for the §4.4 handshake transcript signature is a real WebCrypto Ed25519 + * key (relay-auth's own crypto, deep-imported) so the harness needs no @noble import of its own and + * no relay-e2e deep import (relay-e2e's `exports` map blocks subpaths). + */ +import { randomUUID, randomBytes } from 'node:crypto' +import type { AeadAlg, E2EEnvelope, E2ESession, HostHello, HostRecord } from 'relay-contracts' +import { + createClientHandshake, + createHostHandshake, + createE2ESession, + MemoryDevicePinStore, + deriveContentKey, + sealReplayFrame, + openReplayCiphertext, + encodeEnvelope, +} from 'relay-e2e' +import { + onUpgrade, + onReattach, + revoke, + revokeToken, + signDeviceAuthProof, + verifyDeviceProof, + type AuthzOutcome, + type EnforceDeps, + type UpgradeContext, + type StepUpPolicy, + type RevocationScope, +} from 'relay-auth' +import { + generateEd25519KeyPair, + exportEd25519PublicRaw, + importEd25519PublicRaw, + signEd25519, + verifyEd25519, +} from 'relay-auth/src/crypto/ed25519.js' +import { createReplaySealer, type ReplaySealer } from 'agent' +import { + fakeAuditSink, + fakeHostRegistry, + fakeRevocationBus, + fakeRevocationStore, + fakeSessionRegistry, + fakeTokenBucket, + makeHostRecord, + principal, + type AuditFake, + type MutableSessionRegistry, + type RevocationBusFake, + type RevocationFake, + type TokenBucketFake, +} from './fakes.js' +import { + craftMalformedDpopBundle, + issueCapBundle, + resetDpopCacheForTest, + setupP5SigningKey, + type CapBundle, +} from './dpop.js' +import { RelaySpy } from './spy.js' + +export const DEFAULT_NOW = 1_700_000_000 +const REPLAY_ALG: AeadAlg = 'xchacha20-poly1305' + +export const NO_STEP_UP: StepUpPolicy = { + required: false, + maxAgeSeconds: Number.MAX_SAFE_INTEGER, + requiredMethod: 'passkey', +} +export const STRICT_PASSKEY: StepUpPolicy = { + required: true, + maxAgeSeconds: 300, + requiredMethod: 'passkey', +} + +/** A seeded host: authz identity (hostId/accountId/aud) + §4.4 e2e identity + replay secret. */ +export interface HostFixture { + readonly label: string + readonly hostId: string + readonly accountId: string + readonly aud: string + readonly origin: string + readonly agentPubkey: Uint8Array + readonly hostContentSecret: Uint8Array + readonly replayAlg: AeadAlg + /** WebCrypto private key backing the handshake transcript signer. */ + readonly signingPriv: CryptoKey +} + +async function makeHostFixture(label: string, accountId: string): Promise { + const kp = await generateEd25519KeyPair() + const agentPubkey = await exportEd25519PublicRaw(kp.publicKey) + const sub = `${label}.term.example.com` + return { + label, + hostId: randomUUID(), + accountId, + aud: sub, + origin: `https://${sub}`, + agentPubkey, + hostContentSecret: new Uint8Array(randomBytes(32)), + replayAlg: REPLAY_ALG, + signingPriv: kp.privateKey, + } +} + +export interface UpgradeArgs { + readonly raw: string + readonly dpop: UpgradeContext['dpop'] + readonly host: string + readonly aud: string + readonly origin: string + readonly now: number + readonly principal?: UpgradeContext['principal'] + readonly requiredRight?: UpgradeContext['requiredRight'] + readonly activeSessionCount?: number + readonly remoteAddrHash?: string +} + +export interface ReattachArgs extends UpgradeArgs { + readonly sessionId: string +} + +export interface HandshakeOpts { + readonly host?: HostFixture + readonly now?: number + /** MITM: pubkey the client verifies host_hello against (default = the real agentPubkey). */ + readonly verifyAgainstPubkey?: Uint8Array + /** MITM: mutate the host_hello the client receives (tampered transcript / hostEphPub). */ + readonly tamperHostHello?: (h: HostHello) => HostHello +} + +export interface EstablishedSessions { + readonly client: E2ESession + readonly host: E2ESession + readonly spy: RelaySpy + readonly agentPubkey: Uint8Array +} + +export interface RelayWorld { + readonly now: number + readonly signingKey: CryptoKey + readonly publicRaw: Uint8Array + readonly hostA: HostFixture + readonly hostB: HostFixture + readonly allowedOrigins: readonly string[] + readonly deps: EnforceDeps + readonly audit: AuditFake + readonly revocation: RevocationFake + readonly buckets: TokenBucketFake + readonly bus: RevocationBusFake + readonly sessions: MutableSessionRegistry + principal(accountId: string, over?: Parameters[1]): ReturnType + setStepUpPolicy(policy: StepUpPolicy): void + issueCap(o: { + accountId: string + host: string + aud: string + rights?: readonly UpgradeContext['requiredRight'][] + ttl?: number + now?: number + }): Promise + craftMalformedDpop(o: { + accountId: string + host: string + aud: string + now?: number + blobLen?: number + }): Promise<{ raw: string; dpop: UpgradeContext['dpop'] }> + upgrade(a: UpgradeArgs): Promise + reattach(a: ReattachArgs): Promise + addSession(e: { sessionId: string; hostId: string; accountId: string }): void + establishSession(opts?: HandshakeOpts): Promise + newReplaySealer(sessionId: string, host?: HostFixture): ReplaySealer + /** Browser-side re-derivation + open of a replay frame (throws on AEAD failure / wrong epoch). */ + openReplay(sessionId: string, epoch: string, env: E2EEnvelope, host?: HostFixture): Uint8Array + revokeToken(jti: string, exp?: number): Promise + revoke(scope: RevocationScope): Promise +} + +export async function buildRelayWorld(now: number = DEFAULT_NOW): Promise { + const { signingKey, publicRaw } = await setupP5SigningKey() + resetDpopCacheForTest() + + const hostA = await makeHostFixture('alice', 'acct-A') + const hostB = await makeHostFixture('bob', 'acct-B') + + const hostRecords: HostRecord[] = [ + makeHostRecord(hostA.accountId, hostA.hostId), + makeHostRecord(hostB.accountId, hostB.hostId), + ] + const hosts = fakeHostRegistry(hostRecords) + const sessions = fakeSessionRegistry([]) + const revocation = fakeRevocationStore() + const buckets = fakeTokenBucket() + const audit = fakeAuditSink() + const bus = fakeRevocationBus() + const allowedOrigins = [hostA.origin, hostB.origin] + + let stepUpPolicy: StepUpPolicy = NO_STEP_UP + const deps: EnforceDeps = { + hosts, + sessions, + revocation, + buckets, + audit, + stepUpPolicyFor: () => stepUpPolicy, + } + + function ctxFor(a: UpgradeArgs): UpgradeContext { + return { + capabilityRaw: a.raw, + originHeader: a.origin, + expectedAud: a.aud, + requestedHostId: a.host, + requiredRight: a.requiredRight ?? 'attach', + remoteAddrHash: a.remoteAddrHash ?? 'ip-hash', + activeSessionCount: a.activeSessionCount ?? 0, + dpop: a.dpop, + principal: a.principal ?? null, + } + } + + const replayCrypto = { deriveContentKey, sealReplayFrame } + + return { + now, + signingKey, + publicRaw, + hostA, + hostB, + allowedOrigins, + deps, + audit, + revocation, + buckets, + bus, + sessions, + principal, + setStepUpPolicy(policy) { + stepUpPolicy = policy + }, + issueCap(o) { + return issueCapBundle(signingKey, { + accountId: o.accountId, + host: o.host, + aud: o.aud, + rights: o.rights, + ttl: o.ttl, + now: o.now ?? now, + }) + }, + craftMalformedDpop(o) { + return craftMalformedDpopBundle(signingKey, { + accountId: o.accountId, + host: o.host, + aud: o.aud, + now: o.now ?? now, + blobLen: o.blobLen, + }) + }, + upgrade(a) { + return onUpgrade(ctxFor(a), deps, allowedOrigins, a.now) + }, + reattach(a) { + return onReattach({ ...ctxFor(a), sessionId: a.sessionId }, deps, allowedOrigins, a.now) + }, + addSession(e) { + sessions.add(e) + }, + async establishSession(opts: HandshakeOpts = {}): Promise { + const h = opts.host ?? hostA + const hsNow = opts.now ?? now + const client = createClientHandshake({ + aeadOffer: ['xchacha20-poly1305', 'aes-256-gcm'], + deviceAuthProofProvider: { + proofFor: (_hostId, binding) => + signDeviceAuthProof(principal(h.accountId), binding, signingKey, hsNow), + }, + verifier: { + verify: async (pub, transcript, sig) => + verifyEd25519(await importEd25519PublicRaw(pub), sig, transcript), + }, + pinStore: new MemoryDevicePinStore(), + hostId: h.hostId, + }) + const host = createHostHandshake({ + signer: { sign: (transcript) => signEd25519(h.signingPriv, transcript) }, + agentPubkey: h.agentPubkey, + supported: ['xchacha20-poly1305', 'aes-256-gcm'], + verifyDeviceProof: (proof, binding) => verifyDeviceProof(proof, binding, hsNow), + }) + const clientHello = await client.start() + const rawHostHello = await host.onClientHello(clientHello) + const hostHello = opts.tamperHostHello ? opts.tamperHostHello(rawHostHello) : rawHostHello + const clientResult = await client.onHostHello( + hostHello, + opts.verifyAgainstPubkey ?? h.agentPubkey, + ) + return { + client: createE2ESession('client', clientResult), + host: createE2ESession('host', host.result!), + spy: new RelaySpy(), + agentPubkey: h.agentPubkey, + } + }, + newReplaySealer(sessionId, host = hostA) { + return createReplaySealer(host.hostContentSecret, sessionId, host.replayAlg, replayCrypto) + }, + openReplay(sessionId, epoch, env, host = hostA) { + const key = deriveContentKey({ + hostContentSecret: host.hostContentSecret, + sessionId, + alg: host.replayAlg, + epoch, + }) + return openReplayCiphertext(key, encodeEnvelope(env)) + }, + async revokeToken(jti, exp = now + 60) { + await revokeToken(jti, exp, revocation) + }, + async revoke(scope) { + await revoke(scope, revocation, hosts, bus, now) + }, + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..4aada21 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "relay-e2e-suite", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Cross-package end-to-end + adversarial security test harness for the rendezvous-relay service. Wires the REAL P1/P2/P4/P5/P6 exports through in-memory seams and an untrusted-relay attacker vantage; dynamically re-validates the audit findings F1-F6 plus MITM/replay/reflect/INV2/cross-tenant.", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + } +} diff --git a/e2e/tests/adversarial-auth.test.ts b/e2e/tests/adversarial-auth.test.ts new file mode 100644 index 0000000..58bd275 --- /dev/null +++ b/e2e/tests/adversarial-auth.test.ts @@ -0,0 +1,226 @@ +/** + * 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' }) + }) + }) +}) diff --git a/e2e/tests/adversarial-transport.test.ts b/e2e/tests/adversarial-transport.test.ts new file mode 100644 index 0000000..08afaae --- /dev/null +++ b/e2e/tests/adversarial-transport.test.ts @@ -0,0 +1,115 @@ +/** + * Transport (P4) attack matrix, run from the untrusted-relay / attacker vantage (the RelaySpy). + * Each case ASSERTS the defense holds. Wires the REAL relay-e2e handshake/session + agent replay + * sealer through the harness. Pass an explicit `now` everywhere. + */ +import { describe, it, expect, beforeEach } from 'vitest' +import { FingerprintMismatchError } from 'relay-e2e' +import { buildRelayWorld, DEFAULT_NOW, type RelayWorld } from '../harness/world.js' + +const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s) +const fromUtf8 = (b: Uint8Array): string => new TextDecoder().decode(b) +const NOW = DEFAULT_NOW + +function flipByte(b: Uint8Array, i = 0): Uint8Array { + const c = b.slice() + c[i] = (c[i]! ^ 0xff) & 0xff + return c +} + +describe('adversarial — transport (P4)', () => { + let world: RelayWorld + beforeEach(async () => { + world = await buildRelayWorld(NOW) + }) + + describe('MITM: host_hello must verify against the registry key', () => { + it('rejects when the client verifies host_hello against a WRONG agentPubkey (no keys derived)', async () => { + // The malicious relay swaps in a key it controls; the client checks against hostB's real key. + await expect( + world.establishSession({ verifyAgainstPubkey: world.hostB.agentPubkey }), + ).rejects.toBeInstanceOf(FingerprintMismatchError) + }) + + it('rejects a tampered host_hello (flipped hostEphPub breaks the transcript signature)', async () => { + await expect( + world.establishSession({ + tamperHostHello: (h) => ({ ...h, hostEphPub: flipByte(h.hostEphPub) }), + }), + ).rejects.toBeInstanceOf(FingerprintMismatchError) + }) + }) + + it('reflection: a c2h frame fed back into the client’s own open is rejected (direction split)', async () => { + const { client } = await world.establishSession() + const c2h = client.seal(utf8('sudo rm -rf /')) + // Reflecting the client's own ciphertext back to it must fail: the client opens with the h2c + // read key + h2c aad label, so the tag over the c2h direction never verifies. + expect(() => client.open(c2h)).toThrow() + }) + + it('replay: delivering the same valid frame twice → the second open throws (SequenceGuard)', async () => { + const { client, host, spy } = await world.establishSession() + const f0 = spy.forward(client.seal(utf8('whoami'))) + expect(fromUtf8(host.open(f0))).toBe('whoami') + expect(() => host.open(f0)).toThrow() // seq 0 already consumed → strict-successor guard + }) + + it('reorder: delivering seq 1 before seq 0 → open throws (strict successor)', async () => { + const { client, host } = await world.establishSession() + const f0 = client.seal(utf8('cmd-0')) + const f1 = client.seal(utf8('cmd-1')) + expect(() => host.open(f1)).toThrow() // expected seq 0, got 1 + // and the in-order pair still works on a fresh pair (sanity) + expect(fromUtf8(host.open(f0))).toBe('cmd-0') + expect(fromUtf8(host.open(f1))).toBe('cmd-1') + }) + + it('INV2: the RelaySpy ciphertext never contains the plaintext marker', async () => { + const { client, host, spy } = await world.establishSession() + const marker = `TOP_SECRET_${crypto.randomUUID()}` + for (let i = 0; i < 3; i++) { + const wire = spy.forward(client.seal(utf8(`${marker}#${i}`))) + expect(fromUtf8(host.open(wire))).toBe(`${marker}#${i}`) + } + expect(spy.contains(marker)).toBe(false) + expect(spy.captured.length).toBe(3) + }) + + describe('F6 (dynamic): recoverable K_content must not reuse (key, nonce) across sealer generations', () => { + it('two generations for the same (secret, sessionId) get different epochs → different keys at seq 0', () => { + const sessionId = 'sess-restart-1' + const gen1 = world.newReplaySealer(sessionId) + const gen2 = world.newReplaySealer(sessionId) // simulates an agent restart / re-attach + + expect(gen2.epoch).not.toBe(gen1.epoch) + + const pt = utf8('IDENTICAL PLAINTEXT AT SEQ 0') + const e1 = gen1.seal(pt) + const e2 = gen2.seal(pt) + + // Same deterministic nonce (seq 0) … + expect(e1.seq).toBe(0n) + expect(e2.seq).toBe(0n) + expect(Buffer.from(e1.nonce).equals(Buffer.from(e2.nonce))).toBe(true) + // … yet a FRESH key per generation ⇒ ciphertext + tag differ (no (key, nonce) reuse). + expect(Buffer.from(e1.ciphertext).equals(Buffer.from(e2.ciphertext))).toBe(false) + expect(Buffer.from(e1.tag).equals(Buffer.from(e2.tag))).toBe(false) + + // Cross-generation open fails: gen1's epoch-derived key cannot open gen2's frame. + expect(() => world.openReplay(sessionId, gen1.epoch, e2)).toThrow() + + // Recoverability WITHIN a generation is preserved (same epoch ⇒ same key). + expect(fromUtf8(world.openReplay(sessionId, gen1.epoch, e1))).toBe('IDENTICAL PLAINTEXT AT SEQ 0') + expect(fromUtf8(world.openReplay(sessionId, gen2.epoch, e2))).toBe('IDENTICAL PLAINTEXT AT SEQ 0') + }) + + it('the replay ciphertext itself never contains the plaintext marker (INV2 on the replay path)', () => { + const sealer = world.newReplaySealer('sess-inv2') + const marker = 'REPLAY_MARKER_ABC' + const env = sealer.seal(utf8(marker)) + expect(Buffer.from(env.ciphertext).toString('latin1')).not.toContain(marker) + expect(Buffer.from(env.ciphertext).toString('utf8')).not.toContain(marker) + }) + }) +}) diff --git a/e2e/tests/flow.test.ts b/e2e/tests/flow.test.ts new file mode 100644 index 0000000..5bcbe0d --- /dev/null +++ b/e2e/tests/flow.test.ts @@ -0,0 +1,99 @@ +/** + * Happy-path full flow, end-to-end across P5 (auth) + P4 (E2E crypto) + P2 (replay), asserting each + * stage. Everything runs through the REAL package exports; only registries/buckets/sockets are faked. + */ +import { describe, it, expect, beforeEach } from 'vitest' +import { buildRelayWorld, DEFAULT_NOW, type RelayWorld } from '../harness/world.js' + +const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s) +const fromUtf8 = (b: Uint8Array): string => new TextDecoder().decode(b) +const NOW = DEFAULT_NOW + +describe('relay flow (happy path)', () => { + let world: RelayWorld + beforeEach(async () => { + world = await buildRelayWorld(NOW) + }) + + it('issueCap → upgrade allows (origin ok, DPoP bound, deny-by-default satisfied)', async () => { + const { hostA } = world + 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, + now: NOW, + }) + expect(out.ok).toBe(true) + expect(world.audit.events.at(-1)).toMatchObject({ outcome: 'allow', action: 'attach' }) + }) + + it('handshake establishes MATCHING session keys on both sides (client↔host round-trip)', async () => { + const { client, host } = await world.establishSession() + // Matching keys ⇒ bidirectional plaintext round-trips through the direction split. + const c2h = host.open(client.seal(utf8('ls -la'))) + expect(fromUtf8(c2h)).toBe('ls -la') + const h2c = client.open(host.seal(utf8('total 0'))) + expect(fromUtf8(h2c)).toBe('total 0') + }) + + it('seals client→host and host→client through the RelaySpy; the RelaySpy never sees plaintext (INV2)', async () => { + const { client, host, spy } = await world.establishSession() + const marker = `PLAINTEXT_${crypto.randomUUID()}` + + const up = spy.forward(client.seal(utf8(marker))) + expect(fromUtf8(host.open(up))).toBe(marker) + const down = spy.forward(host.seal(utf8(`reply_${marker}`))) + expect(fromUtf8(client.open(down))).toBe(`reply_${marker}`) + + expect(spy.contains(marker)).toBe(false) + expect(spy.captured.length).toBe(2) + }) + + it('reattach to an own-account session is allowed', async () => { + const { hostA } = world + const sessionId = crypto.randomUUID() + world.addSession({ sessionId, hostId: hostA.hostId, accountId: hostA.accountId }) + const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud }) + const out = await world.reattach({ + raw: cap.raw, + dpop: cap.dpop, + host: hostA.hostId, + aud: hostA.aud, + origin: hostA.origin, + sessionId, + now: NOW, + }) + expect(out.ok).toBe(true) + expect(world.audit.events.at(-1)).toMatchObject({ outcome: 'allow', action: 'reattach' }) + }) + + it('revokeToken then re-presenting the same jti (fresh DPoP) is denied', 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) + if (!first.ok) return + + await world.revokeToken(first.jti) + + const second = await world.upgrade({ + raw: cap.raw, + dpop: await cap.newDpop(NOW), // fresh DPoP jti so we reach the revocation gate, not the DPoP cache + host: hostA.hostId, + aud: hostA.aud, + origin: hostA.origin, + now: NOW, + }) + expect(second).toMatchObject({ ok: false, status: 403, reason: 'token_revoked' }) + }) +}) diff --git a/e2e/tests/smoke.test.ts b/e2e/tests/smoke.test.ts new file mode 100644 index 0000000..9c20b4f --- /dev/null +++ b/e2e/tests/smoke.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' +// Cross-package resolution smoke test: prove the harness can bare-import the REAL exports of +// every relay package (each resolves via its own node_modules/relay-contracts symlink transitively). +import { issueCapabilityToken, onUpgrade, needsStepUp } from 'relay-auth' +import { createClientHandshake, createHostHandshake, createE2ESession, deriveContentKey } from 'relay-e2e' +import { createReplaySealer } from 'agent' +import { CapabilityTokenSchema } from 'relay-contracts' + +describe('cross-package import smoke', () => { + it('resolves the real exports of relay-auth / relay-e2e / agent / relay-contracts', () => { + expect(typeof issueCapabilityToken).toBe('function') + expect(typeof onUpgrade).toBe('function') + expect(typeof needsStepUp).toBe('function') + expect(typeof createClientHandshake).toBe('function') + expect(typeof createHostHandshake).toBe('function') + expect(typeof createE2ESession).toBe('function') + expect(typeof deriveContentKey).toBe('function') + expect(typeof createReplaySealer).toBe('function') + expect(CapabilityTokenSchema).toBeDefined() + }) +}) diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..48130c7 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": ["harness/**/*.ts", "tests/**/*.ts"] +} diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts new file mode 100644 index 0000000..b31aa2a --- /dev/null +++ b/e2e/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + environment: 'node', + }, +})