From a09c13153974498f9ceea4738519794165f894ce Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 2 Jul 2026 16:41:18 +0200 Subject: [PATCH 1/4] =?UTF-8?q?fix(relay-auth):=20close=205=20pre-producti?= =?UTF-8?q?on=20security=20findings=20(F1=E2=80=93F5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 (HIGH): bind step-up freshness to the required method (stepUpMethod) so a login-time or weaker (TOTP) factor can no longer satisfy a passkey requirement. F2 (HIGH): make the step-up gate host-driven and fail-closed on connect+reattach (deny when policy.required and the principal is missing/stale/wrong-method). F3 (MED): bind the WebAuthn assertion to the stored credential (credentialId cross-check + forward publicKey/credentialId to the verifier). F4 (LOW): make verifyDpopProof totally fail-safe (no unhandled throw), wrap the authz boundary into a clean audited 401, and validate cnfJkt format at issue. F5 (LOW): return newSignCount from finishAuthentication so the caller can persist the advanced counter (WebAuthn clone detection). All fixes ship regression tests. relay-auth: 122 tests green, tsc clean. --- relay-auth/src/authz/decide.ts | 8 ++- relay-auth/src/capability/errors.ts | 1 + relay-auth/src/capability/issue.ts | 2 + relay-auth/src/capability/verify.ts | 63 ++++++++++--------- relay-auth/src/enforce/onUpgrade.ts | 12 ++-- relay-auth/src/human/stepup/stepup.ts | 25 +++++--- relay-auth/src/human/webauthn/authenticate.ts | 8 ++- relay-auth/src/human/webauthn/verifier.ts | 3 + relay-auth/src/types.ts | 4 ++ relay-auth/test/capability.test.ts | 27 ++++++++ relay-auth/test/enforce.test.ts | 24 +++++-- relay-auth/test/stepup.test.ts | 45 ++++++++++--- relay-auth/test/tripwire/cross-tenant.test.ts | 2 +- relay-auth/test/types.test.ts | 60 ++++++++++++++++++ relay-auth/test/webauthn.test.ts | 24 +++++-- 15 files changed, 248 insertions(+), 60 deletions(-) create mode 100644 relay-auth/test/types.test.ts diff --git a/relay-auth/src/authz/decide.ts b/relay-auth/src/authz/decide.ts index 4a92fd5..a1374b6 100644 --- a/relay-auth/src/authz/decide.ts +++ b/relay-auth/src/authz/decide.ts @@ -54,7 +54,13 @@ async function coreAuthorize( } catch { return { outcome: deny(401, 'invalid_capability_token') } } - if (!(await verifyDpopProof(token, dpop, now))) { + // verifyDpopProof is fail-safe, but guard defensively so an unexpected throw becomes a single + // audited deny (emitted once by onUpgrade) rather than an unhandled exception (Finding-4). + try { + if (!(await verifyDpopProof(token, dpop, now))) { + return { outcome: deny(401, 'dpop_proof_failed') } + } + } catch { return { outcome: deny(401, 'dpop_proof_failed') } } if (await revocation.isRevoked(token.jti)) return { outcome: deny(403, 'token_revoked') } diff --git a/relay-auth/src/capability/errors.ts b/relay-auth/src/capability/errors.ts index 208b1a4..5ea3fab 100644 --- a/relay-auth/src/capability/errors.ts +++ b/relay-auth/src/capability/errors.ts @@ -11,6 +11,7 @@ export type CapabilityErrorReason = | 'bad_signature' | 'replayed' | 'no_cnf' + | 'bad_cnf' export class CapabilityError extends Error { readonly reason: CapabilityErrorReason diff --git a/relay-auth/src/capability/issue.ts b/relay-auth/src/capability/issue.ts index ff7cfa9..c5d91e8 100644 --- a/relay-auth/src/capability/issue.ts +++ b/relay-auth/src/capability/issue.ts @@ -49,6 +49,8 @@ export async function issueCapabilityToken( for (const r of a.rights) CapabilityRightSchema.parse(r) if (a.rights.length === 0) throw new CapabilityError('malformed', 'rights must be non-empty') if (a.cnfJkt.length === 0) throw new CapabilityError('no_cnf') + // A base64url SHA-256 JWK thumbprint is exactly 43 chars of [A-Za-z0-9_-] (hardening, Finding-4). + if (!/^[A-Za-z0-9_-]{43}$/.test(a.cnfJkt)) throw new CapabilityError('bad_cnf') const ttl = Math.max(a.ttlSeconds, CONNECT_TOKEN_MIN_TTL_SEC) const body: TokenBody = { diff --git a/relay-auth/src/capability/verify.ts b/relay-auth/src/capability/verify.ts index d05a907..1b21df1 100644 --- a/relay-auth/src/capability/verify.ts +++ b/relay-auth/src/capability/verify.ts @@ -116,38 +116,45 @@ export async function verifyDpopProof( dpop: DpopContext, now: number, ): Promise { - const expectedJkt = readCnfJkt(token) - const parts = dpop.proofJws.split('.') - if (parts.length !== 3) return false - const [h, p, s] = parts as [string, string, string] - let header: DpopHeader - let payload: DpopPayload + // TOTALLY fail-safe (Finding-4): any thrown error — from readCnfJkt, jwkThumbprint, + // importEd25519PublicRaw, or any decode — becomes `false`, never a rejected promise, so the + // INV15 gate always denies through an audited path instead of an unhandled exception. try { - header = JSON.parse(new TextDecoder().decode(decodeBase64UrlBytes(h))) as DpopHeader - payload = JSON.parse(new TextDecoder().decode(decodeBase64UrlBytes(p))) as DpopPayload + const expectedJkt = readCnfJkt(token) + const parts = dpop.proofJws.split('.') + if (parts.length !== 3) return false + const [h, p, s] = parts as [string, string, string] + let header: DpopHeader + let payload: DpopPayload + try { + header = JSON.parse(new TextDecoder().decode(decodeBase64UrlBytes(h))) as DpopHeader + payload = JSON.parse(new TextDecoder().decode(decodeBase64UrlBytes(p))) as DpopPayload + } catch { + return false + } + if (header.jwk?.kty !== 'OKP' || header.jwk?.crv !== 'Ed25519') return false + let rawPub: Uint8Array + try { + rawPub = decodeBase64UrlBytes(header.jwk.x) + } catch { + return false + } + if ((await jwkThumbprint(rawPub)) !== expectedJkt) return false + if (payload.htu !== dpop.htu || payload.htm !== dpop.htm) return false + if (typeof payload.iat !== 'number' || Math.abs(now - payload.iat) > DPOP_MAX_AGE_SEC) return false + const signed = new TextEncoder().encode(`${h}.${p}`) + const pubKey = await importEd25519PublicRaw(rawPub) + let sig: Uint8Array + try { + sig = decodeBase64UrlBytes(s) + } catch { + return false + } + if (!(await verifyEd25519(pubKey, sig, signed))) return false + return rememberDpop(payload.jti, payload.iat + DPOP_MAX_AGE_SEC, now) } catch { return false } - if (header.jwk?.kty !== 'OKP' || header.jwk?.crv !== 'Ed25519') return false - let rawPub: Uint8Array - try { - rawPub = decodeBase64UrlBytes(header.jwk.x) - } catch { - return false - } - if ((await jwkThumbprint(rawPub)) !== expectedJkt) return false - if (payload.htu !== dpop.htu || payload.htm !== dpop.htm) return false - if (typeof payload.iat !== 'number' || Math.abs(now - payload.iat) > DPOP_MAX_AGE_SEC) return false - const signed = new TextEncoder().encode(`${h}.${p}`) - const pubKey = await importEd25519PublicRaw(rawPub) - let sig: Uint8Array - try { - sig = decodeBase64UrlBytes(s) - } catch { - return false - } - if (!(await verifyEd25519(pubKey, sig, signed))) return false - return rememberDpop(payload.jti, payload.iat + DPOP_MAX_AGE_SEC, now) } /** Build a DPoP proof (client-side helper; also used by tests). Signs with the ephemeral key. */ diff --git a/relay-auth/src/enforce/onUpgrade.ts b/relay-auth/src/enforce/onUpgrade.ts index 51885a1..843fef7 100644 --- a/relay-auth/src/enforce/onUpgrade.ts +++ b/relay-auth/src/enforce/onUpgrade.ts @@ -124,10 +124,14 @@ export async function runEnforcement( await emitDeny(deps, ctx, base, 'too_many_sessions', now) return deny(403, 'too_many_sessions') } - // 5. Step-up gate (Finding-3, v0.10) — only when a session principal is present. - if (ctx.principal !== null) { - const host = await deps.hosts.getById(outcome.hostId) - if (host !== null && needsStepUp(ctx.principal, deps.stepUpPolicyFor(host), now)) { + // 5. Step-up gate (Finding-3/F1/F2) — HOST-DRIVEN and FAIL-CLOSED on connect AND reattach. + // When a host REQUIRES step-up, DENY unless an authenticated principal proves a FRESH step-up + // of the required method. A missing host, a null principal, or a stale/wrong-method step-up all + // fail closed. When the policy does not require step-up, this gate is a no-op. + const host = await deps.hosts.getById(outcome.hostId) + const policy = deps.stepUpPolicyFor(host as HostRecord) + if (policy.required) { + if (host === null || ctx.principal === null || needsStepUp(ctx.principal, policy, now)) { await emitDeny(deps, ctx, 'stepup', 'step_up_required', now) return deny(403, 'step_up_required') } diff --git a/relay-auth/src/human/stepup/stepup.ts b/relay-auth/src/human/stepup/stepup.ts index 2d4c750..1f85dd2 100644 --- a/relay-auth/src/human/stepup/stepup.ts +++ b/relay-auth/src/human/stepup/stepup.ts @@ -14,14 +14,22 @@ import type { AuthMethod, AuthenticatedPrincipal, StepUpPolicy } from '../../typ export const DEFAULT_STEPUP_MAX_AGE_SECONDS = 300 as const -/** Per-host step-up freshness requirement (default: recent passkey step-up). */ +/** Per-host step-up freshness requirement (default: recent passkey step-up REQUIRED). */ export function policyForHost(_host: HostRecord): StepUpPolicy { - return { maxAgeSeconds: DEFAULT_STEPUP_MAX_AGE_SECONDS, requiredMethod: 'passkey' } + return { required: true, maxAgeSeconds: DEFAULT_STEPUP_MAX_AGE_SECONDS, requiredMethod: 'passkey' } +} + +/** v0.9 "never required" default: step-up gate is off (used until per-host tiers exist). */ +export const NO_STEPUP_POLICY: StepUpPolicy = { + required: false, + maxAgeSeconds: DEFAULT_STEPUP_MAX_AGE_SECONDS, + requiredMethod: 'passkey', } /** - * true when step-up is required: never stepped up, OR the last step-up is stale, OR the required - * method is not present in `amr` — i.e. a fresh login alone does NOT satisfy it. + * true when step-up is required: never stepped up, OR the last step-up is stale, OR the LAST step-up + * was not performed with the required method (method-binding, F1) — i.e. neither a fresh login nor a + * weaker (e.g. TOTP) step-up satisfies a passkey requirement. */ export function needsStepUp( principal: AuthenticatedPrincipal, @@ -30,16 +38,19 @@ export function needsStepUp( ): boolean { if (principal.stepUpAt === null) return true if (now - principal.stepUpAt > policy.maxAgeSeconds) return true - if (!principal.amr.includes(policy.requiredMethod)) return true + if ((principal.stepUpMethod ?? null) !== policy.requiredMethod) return true return false } -/** Returns a NEW principal with `stepUpAt = now` and `method` added to `amr` (immutable). */ +/** + * Returns a NEW principal recording a fresh step-up: `stepUpAt = now`, `stepUpMethod = method`, and + * `method` appended to `amr` (audit trail). Immutable — the original is untouched. + */ export function recordStepUp( principal: AuthenticatedPrincipal, method: AuthMethod, now: number, ): AuthenticatedPrincipal { const amr = principal.amr.includes(method) ? principal.amr : [...principal.amr, method] - return { ...principal, amr, stepUpAt: now } + return { ...principal, amr, stepUpAt: now, stepUpMethod: method } } diff --git a/relay-auth/src/human/webauthn/authenticate.ts b/relay-auth/src/human/webauthn/authenticate.ts index 3b4816f..69c2b8e 100644 --- a/relay-auth/src/human/webauthn/authenticate.ts +++ b/relay-auth/src/human/webauthn/authenticate.ts @@ -32,16 +32,19 @@ export async function finishAuthentication( origin: string, verifier: WebAuthnVerifier, now: number, -): Promise { +): Promise<{ principal: AuthenticatedPrincipal; newSignCount: number }> { if (resp.clientChallenge !== expectedChallenge) throw new WebAuthnError('challenge mismatch') if (resp.origin !== origin) throw new WebAuthnError('origin mismatch') if (resp.rpId !== rpId) throw new WebAuthnError('rpId mismatch') + if (resp.credentialId !== cred.credentialId) throw new WebAuthnError('credential id mismatch') const result = await verifier.verifyAuthentication( resp, expectedChallenge, rpId, origin, + cred.publicKey, + cred.credentialId, cred.signCount, ) if (!result.verified) throw new WebAuthnError('assertion not verified') @@ -49,7 +52,7 @@ export async function finishAuthentication( throw new WebAuthnError('signCount regression (cloned authenticator)') } - return { + const principal: AuthenticatedPrincipal = { kind: 'human', accountId: cred.accountId, principalId: cred.credentialId, @@ -57,4 +60,5 @@ export async function finishAuthentication( authAt: now, stepUpAt: null, } + return { principal, newSignCount: result.newSignCount } } diff --git a/relay-auth/src/human/webauthn/verifier.ts b/relay-auth/src/human/webauthn/verifier.ts index bd702f5..7ed3254 100644 --- a/relay-auth/src/human/webauthn/verifier.ts +++ b/relay-auth/src/human/webauthn/verifier.ts @@ -28,6 +28,7 @@ export interface AuthenticationResponse { readonly clientChallenge: string readonly origin: string readonly rpId: string + readonly credentialId: string } export interface RegistrationVerification { @@ -54,6 +55,8 @@ export interface WebAuthnVerifier { expectedChallenge: string, rpId: string, origin: string, + credentialPublicKey: Uint8Array, + expectedCredentialId: string, storedSignCount: number, ): Promise } diff --git a/relay-auth/src/types.ts b/relay-auth/src/types.ts index 4b1a8ba..71e21ad 100644 --- a/relay-auth/src/types.ts +++ b/relay-auth/src/types.ts @@ -34,6 +34,7 @@ export interface AuthenticatedPrincipal { readonly amr: readonly AuthMethod[] // methods satisfied this session (for step-up, T8) readonly authAt: number // epoch seconds of last successful auth (login freshness) readonly stepUpAt: number | null // epoch seconds of last step-up (T8 freshness); null = never + readonly stepUpMethod?: AuthMethod | null // method used for the LAST step-up (F1 method-binding); absent/null ⇒ none } /** @@ -44,6 +45,7 @@ export interface AuthenticatedPrincipal { export const CAP_TOKEN_SUB_IS_ACCOUNT_ID = true as const export interface StepUpPolicy { + readonly required: boolean readonly maxAgeSeconds: number readonly requiredMethod: AuthMethod } @@ -141,11 +143,13 @@ export const AuthenticatedPrincipalSchema = z amr: z.array(AuthMethodSchema).readonly(), authAt: z.number().int().nonnegative(), stepUpAt: z.number().int().nonnegative().nullable(), + stepUpMethod: AuthMethodSchema.nullable().optional(), }) .strict() export const StepUpPolicySchema = z .object({ + required: z.boolean(), maxAgeSeconds: z.number().int().nonnegative(), requiredMethod: AuthMethodSchema, }) diff --git a/relay-auth/test/capability.test.ts b/relay-auth/test/capability.test.ts index 2faeae1..d9ae232 100644 --- a/relay-auth/test/capability.test.ts +++ b/relay-auth/test/capability.test.ts @@ -11,6 +11,7 @@ import { } from '../src/capability/verify.js' import { jwkThumbprint } from '../src/crypto/thumbprint.js' import { CapabilityError } from '../src/capability/errors.js' +import { encodeBase64UrlBytes } from 'relay-contracts' import { setupP5SigningKey, makeEphemeral, principal, uuid } from './_helpers.js' const NOW = 1_700_000_000 @@ -150,5 +151,31 @@ describe('capability token (§4.3)', () => { expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(true) expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(false) }) + + // F4: a proof whose jwk.x decodes to a NON-32-byte blob makes importEd25519PublicRaw throw. + // verifyDpopProof must be TOTALLY fail-safe and RESOLVE to false, never reject. + it('resolves false (never throws) when the proof key is not a valid 32-byte Ed25519 key', async () => { + // A 16-byte "key" — importEd25519PublicRaw will reject this raw length. + const shortBlob = new Uint8Array(16).fill(7) + // cnf.jkt is computed over the SAME 16-byte blob so the thumbprint check passes and + // execution reaches the risky importEd25519PublicRaw call. + const cnfJkt = await jwkThumbprint(shortBlob) + const raw = await issueFor(signingKey, { cnfJkt }) + const tok = await verifyCapabilityToken(raw, AUD, NOW) + + const htu = 'https://alice.term.example.com/ws' + const enc = (o: unknown) => encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(o))) + const h = enc({ typ: 'dpop+ed25519', jwk: { crv: 'Ed25519', kty: 'OKP', x: encodeBase64UrlBytes(shortBlob) } }) + const p = enc({ htu, htm: 'GET', jti: uuid(), iat: NOW }) + const s = encodeBase64UrlBytes(new Uint8Array(64)) // any signature bytes + const proofJws = `${h}.${p}.${s}` + + const verify = verifyDpopProof(tok, { proofJws, htu, htm: 'GET' }, NOW) + await expect(verify).resolves.toBe(false) + }) + }) + + it('rejects a malformed cnf.jkt at issue (not a 43-char base64url thumbprint)', async () => { + await expect(issueFor(signingKey, { cnfJkt: 'short' })).rejects.toMatchObject({ reason: 'bad_cnf' }) }) }) diff --git a/relay-auth/test/enforce.test.ts b/relay-auth/test/enforce.test.ts index ba36bc3..b75f473 100644 --- a/relay-auth/test/enforce.test.ts +++ b/relay-auth/test/enforce.test.ts @@ -20,7 +20,7 @@ const NOW = 1_700_000_000 const AUD_A = 'alice.term.example.com' const ORIGIN_A = `https://${AUD_A}` const ALLOWED = [ORIGIN_A] -const NEVER_REQUIRED: StepUpPolicy = { maxAgeSeconds: Number.MAX_SAFE_INTEGER, requiredMethod: 'passkey' } +const NEVER_REQUIRED: StepUpPolicy = { required: false, maxAgeSeconds: Number.MAX_SAFE_INTEGER, requiredMethod: 'passkey' } describe('enforcement onUpgrade/onReattach (T12)', () => { let signingKey: CryptoKey @@ -126,8 +126,8 @@ describe('enforcement onUpgrade/onReattach (T12)', () => { expect(out).toMatchObject({ ok: false, status: 403, reason: 'cross_tenant_session' }) }) - describe('v0.10 step-up augmentation (Finding-3)', () => { - const STRICT: StepUpPolicy = { maxAgeSeconds: 300, requiredMethod: 'passkey' } + describe('v0.10 step-up augmentation (Finding-3, F1/F2)', () => { + const STRICT: StepUpPolicy = { required: true, maxAgeSeconds: 300, requiredMethod: 'passkey' } it('fresh login but stale step-up → 403 step_up_required at onUpgrade', async () => { deps = { ...deps, stepUpPolicyFor: () => STRICT } @@ -141,10 +141,26 @@ describe('enforcement onUpgrade/onReattach (T12)', () => { it('after a fresh step-up the same request → ok:true', async () => { deps = { ...deps, stepUpPolicyFor: () => STRICT } const b = await issueWithDpop(signingKey, { accountId: 'acct-A', host: hostA, aud: AUD_A, now: NOW }) - const steppedUp = principal('acct-A', { authAt: NOW, stepUpAt: NOW, amr: ['passkey', 'stepup'] }) + const steppedUp = principal('acct-A', { authAt: NOW, stepUpAt: NOW, amr: ['passkey', 'stepup'], stepUpMethod: 'passkey' }) expect(needsStepUp(steppedUp, STRICT, NOW)).toBe(false) const out = await onUpgrade(ctxFor(b, { principal: steppedUp }), deps, ALLOWED, NOW) expect(out.ok).toBe(true) }) + + it('STRICT (required) policy + principal:null → 403 step_up_required (F2 fail-closed)', async () => { + deps = { ...deps, stepUpPolicyFor: () => STRICT } + const b = await issueWithDpop(signingKey, { accountId: 'acct-A', host: hostA, aud: AUD_A, now: NOW }) + const out = await onUpgrade(ctxFor(b, { principal: null }), deps, ALLOWED, NOW) + expect(out).toMatchObject({ ok: false, status: 403, reason: 'step_up_required' }) + expect(audit.events.some((e) => e.action === 'stepup' && e.outcome === 'deny')).toBe(true) + }) + + it('required:false policy + principal:null → allow (non-step-up host preserved)', async () => { + deps = { ...deps, stepUpPolicyFor: () => NEVER_REQUIRED } + const b = await issueWithDpop(signingKey, { accountId: 'acct-A', host: hostA, aud: AUD_A, now: NOW }) + const out = await onUpgrade(ctxFor(b, { principal: null }), deps, ALLOWED, NOW) + expect(out.ok).toBe(true) + expect(audit.events.some((e) => e.outcome === 'allow')).toBe(true) + }) }) }) diff --git a/relay-auth/test/stepup.test.ts b/relay-auth/test/stepup.test.ts index a7d41b6..3860dae 100644 --- a/relay-auth/test/stepup.test.ts +++ b/relay-auth/test/stepup.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from 'vitest' -import { needsStepUp, recordStepUp, policyForHost } from '../src/human/stepup/stepup.js' +import { needsStepUp, recordStepUp, policyForHost, NO_STEPUP_POLICY } from '../src/human/stepup/stepup.js' import type { StepUpPolicy } from '../src/types.js' import { principal, makeHost, uuid } from './_helpers.js' const NOW = 1_700_000_000 -const POLICY: StepUpPolicy = { maxAgeSeconds: 300, requiredMethod: 'passkey' } +const POLICY: StepUpPolicy = { required: true, maxAgeSeconds: 300, requiredMethod: 'passkey' } describe('step-up before session open (T8, Finding-3)', () => { it('fresh login but stepUpAt=null → needsStepUp true (login ≠ step-up)', () => { @@ -13,34 +13,61 @@ describe('step-up before session open (T8, Finding-3)', () => { }) it('stale step-up (older than maxAgeSeconds) → true', () => { - const p = principal('acct-A', { stepUpAt: NOW - 301, amr: ['passkey', 'stepup'] }) + const p = principal('acct-A', { stepUpAt: NOW - 301, amr: ['passkey', 'stepup'], stepUpMethod: 'passkey' }) expect(needsStepUp(p, POLICY, NOW)).toBe(true) }) - it('required method absent from amr → true', () => { - const p = principal('acct-A', { stepUpAt: NOW, amr: ['totp'] }) + it('required method not the LAST step-up method → true', () => { + const p = principal('acct-A', { stepUpAt: NOW, amr: ['totp'], stepUpMethod: 'totp' }) expect(needsStepUp(p, POLICY, NOW)).toBe(true) }) - it('fresh passkey step-up → false', () => { - const p = principal('acct-A', { stepUpAt: NOW, amr: ['passkey'] }) + it('fresh passkey step-up satisfies passkey policy → false (F1)', () => { + const p = principal('acct-A', { stepUpAt: NOW, amr: ['passkey'], stepUpMethod: 'passkey' }) expect(needsStepUp(p, POLICY, NOW)).toBe(false) }) + it('fresh TOTP step-up does NOT satisfy passkey policy → true (F1 factor-downgrade regression)', () => { + // A weaker (TOTP) step-up performed just now must not satisfy a passkey requirement, even though + // it is fresh — the required method itself must be the LAST step-up. + const p = principal('acct-A', { stepUpAt: NOW, amr: ['passkey', 'totp'], stepUpMethod: 'totp' }) + expect(needsStepUp(p, POLICY, NOW)).toBe(true) + }) + + it('login-time passkey in amr but no step-up (stepUpMethod null) → true (login ≠ step-up)', () => { + const p = principal('acct-A', { stepUpAt: NOW, amr: ['passkey'], stepUpMethod: null }) + expect(needsStepUp(p, POLICY, NOW)).toBe(true) + }) + it('recordStepUp returns a NEW principal (immutable), original untouched', () => { - const p = principal('acct-A', { stepUpAt: null, amr: ['totp'] }) + const p = principal('acct-A', { stepUpAt: null, amr: ['totp'], stepUpMethod: 'totp' }) const stepped = recordStepUp(p, 'passkey', NOW) expect(stepped).not.toBe(p) expect(p.stepUpAt).toBeNull() expect(p.amr).toEqual(['totp']) + expect(p.stepUpMethod).toBe('totp') expect(stepped.stepUpAt).toBe(NOW) expect(stepped.amr).toContain('passkey') expect(needsStepUp(stepped, POLICY, NOW)).toBe(false) }) - it('policyForHost yields a passkey freshness requirement', () => { + it('recordStepUp stamps stepUpMethod with the method used (F1)', () => { + const p = principal('acct-A', { stepUpAt: null, amr: ['passkey'], stepUpMethod: null }) + const stepped = recordStepUp(p, 'passkey', NOW) + expect(stepped.stepUpMethod).toBe('passkey') + expect(stepped.stepUpAt).toBe(NOW) + }) + + it('policyForHost yields a REQUIRED passkey freshness requirement', () => { const policy = policyForHost(makeHost('acct-A', uuid())) + expect(policy.required).toBe(true) expect(policy.requiredMethod).toBe('passkey') expect(policy.maxAgeSeconds).toBeGreaterThan(0) }) + + it('NO_STEPUP_POLICY is the v0.9 never-required default', () => { + expect(NO_STEPUP_POLICY.required).toBe(false) + expect(NO_STEPUP_POLICY.requiredMethod).toBe('passkey') + expect(NO_STEPUP_POLICY.maxAgeSeconds).toBeGreaterThan(0) + }) }) diff --git a/relay-auth/test/tripwire/cross-tenant.test.ts b/relay-auth/test/tripwire/cross-tenant.test.ts index fcb9418..dc1beef 100644 --- a/relay-auth/test/tripwire/cross-tenant.test.ts +++ b/relay-auth/test/tripwire/cross-tenant.test.ts @@ -29,7 +29,7 @@ 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 = { maxAgeSeconds: Number.MAX_SAFE_INTEGER, requiredMethod: 'passkey' } +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 diff --git a/relay-auth/test/types.test.ts b/relay-auth/test/types.test.ts new file mode 100644 index 0000000..045c5cb --- /dev/null +++ b/relay-auth/test/types.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import { + AuthenticatedPrincipalSchema, + StepUpPolicySchema, + type AuthenticatedPrincipal, + type StepUpPolicy, +} from '../src/types.js' + +const basePrincipal = { + kind: 'human' as const, + accountId: 'acct-A', + principalId: 'cred-1', + amr: ['passkey' as const], + authAt: 1_700_000_000, + stepUpAt: null, +} + +describe('StepUpPolicy.required (host-driven, fail-closed)', () => { + it('parses a policy with required=true', () => { + const policy: StepUpPolicy = { required: true, maxAgeSeconds: 300, requiredMethod: 'passkey' } + expect(StepUpPolicySchema.parse(policy)).toEqual(policy) + }) + + it('parses a policy with required=false', () => { + const policy: StepUpPolicy = { required: false, maxAgeSeconds: 300, requiredMethod: 'passkey' } + expect(StepUpPolicySchema.parse(policy)).toEqual(policy) + }) + + it('rejects a policy missing required', () => { + const bad = { maxAgeSeconds: 300, requiredMethod: 'passkey' } + expect(StepUpPolicySchema.safeParse(bad).success).toBe(false) + }) + + it('rejects a non-boolean required', () => { + const bad = { required: 'yes', maxAgeSeconds: 300, requiredMethod: 'passkey' } + expect(StepUpPolicySchema.safeParse(bad).success).toBe(false) + }) +}) + +describe('AuthenticatedPrincipal.stepUpMethod (F1 method-binding, optional)', () => { + it('parses a principal that omits stepUpMethod (backward compatible)', () => { + const parsed = AuthenticatedPrincipalSchema.parse(basePrincipal) + expect(parsed.stepUpMethod).toBeUndefined() + }) + + it('parses a principal with an explicit stepUpMethod', () => { + const p: AuthenticatedPrincipal = { ...basePrincipal, stepUpAt: 1_700_000_100, stepUpMethod: 'passkey' } + expect(AuthenticatedPrincipalSchema.parse(p).stepUpMethod).toBe('passkey') + }) + + it('parses a principal with stepUpMethod=null (no step-up)', () => { + const parsed = AuthenticatedPrincipalSchema.parse({ ...basePrincipal, stepUpMethod: null }) + expect(parsed.stepUpMethod).toBeNull() + }) + + it('rejects an invalid stepUpMethod value', () => { + const bad = { ...basePrincipal, stepUpMethod: 'sms' } + expect(AuthenticatedPrincipalSchema.safeParse(bad).success).toBe(false) + }) +}) diff --git a/relay-auth/test/webauthn.test.ts b/relay-auth/test/webauthn.test.ts index 1696031..af919fc 100644 --- a/relay-auth/test/webauthn.test.ts +++ b/relay-auth/test/webauthn.test.ts @@ -21,14 +21,14 @@ const verifier: WebAuthnVerifier = { signCount: 0, transports: ['internal'], }), - verifyAuthentication: async (_r, _c, _rp, _o, stored) => ({ verified: true, newSignCount: stored + 1 }), + verifyAuthentication: async (_r, _c, _rp, _o, _pk, _cid, stored) => ({ verified: true, newSignCount: stored + 1 }), } function regResp(over: Partial = {}): RegistrationResponse { return { clientChallenge: 'chal', origin: ORIGIN, rpId: RP_ID, ...over } } function authResp(over: Partial = {}): AuthenticationResponse { - return { clientChallenge: 'chal', origin: ORIGIN, rpId: RP_ID, ...over } + return { clientChallenge: 'chal', origin: ORIGIN, rpId: RP_ID, credentialId: 'cred-1', ...over } } const cred: WebAuthnCredential = { credentialId: 'cred-1', @@ -68,16 +68,32 @@ describe('WebAuthn / Passkey (T5, primary)', () => { it('happy path auth yields a principal with amr:[passkey]', async () => { const startOpts = await startAuthentication('acct-A', RP_ID) expect(startOpts.rpId).toBe(RP_ID) - const p = await finishAuthentication(cred, authResp(), 'chal', RP_ID, ORIGIN, verifier, NOW) + const { principal: p } = await finishAuthentication(cred, authResp(), 'chal', RP_ID, ORIGIN, verifier, NOW) expect(p.accountId).toBe('acct-A') expect(p.amr).toEqual(['passkey']) expect(p.authAt).toBe(NOW) }) + it('F3: rejects an assertion whose credentialId does not match the stored credential', async () => { + const forgiving: WebAuthnVerifier = { + ...verifier, + verifyAuthentication: async (_r, _c, _rp, _o, _pk, _cid, stored) => ({ verified: true, newSignCount: stored + 1 }), + } + await expect( + finishAuthentication(cred, authResp({ credentialId: 'other-cred' }), 'chal', RP_ID, ORIGIN, forgiving, NOW), + ).rejects.toThrow('credential id mismatch') + }) + + it('F5: returns the verifier-advanced newSignCount so the caller can persist it', async () => { + const { principal: p, newSignCount } = await finishAuthentication(cred, authResp(), 'chal', RP_ID, ORIGIN, verifier, NOW) + expect(p.accountId).toBe('acct-A') + expect(newSignCount).toBe(cred.signCount + 1) + }) + it('rejects a signCount regression (cloned authenticator signal)', async () => { const regressing: WebAuthnVerifier = { ...verifier, - verifyAuthentication: async () => ({ verified: true, newSignCount: 3 }), // < stored 5 + verifyAuthentication: async () => ({ verified: true, newSignCount: 3 }), // < stored 5 (adapts to {principal,newSignCount} return) } await expect( finishAuthentication(cred, authResp(), 'chal', RP_ID, ORIGIN, regressing, NOW), From 3020184054b798cb1e760ad0b3b6fceb809a4990 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 2 Jul 2026 16:41:19 +0200 Subject: [PATCH 2/4] fix(relay): close F6 replay K_content nonce reuse via per-generation epoch-in-key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recoverable replay key was stable per (hostContentSecret, sessionId) while the agent-side sealer resets its deterministic-nonce seq to 0 on every restart/re-attach → two sealer generations sealed distinct plaintext under the SAME (key, nonce). Add a required 'epoch' to ReplayKeyParams, fold it into the K_content HKDF salt (sessionId U+001F epoch), mint a fresh epoch per createReplaySealer generation and expose it, and thread it through ReplaySource so the browser re-derives the matching key. Fresh epoch per generation ⇒ fresh key ⇒ seq=0 can never collide; recoverability within a generation is preserved. Touches relay-contracts/relay-e2e/agent/relay-web. Green: contracts 81, e2e 78, agent 133, web 99; tsc clean. Regression proves same seq-0 nonce, different key. --- agent/src/e2e/replaySeal.ts | 27 +++++++++-- agent/test/hostEndpoint.test.ts | 1 + agent/test/replaySeal.test.ts | 61 +++++++++++++++++++++---- relay-contracts/src/e2e/types.ts | 15 +++++- relay-e2e/src/replay-key.ts | 28 +++++++++++- relay-e2e/test/keystore.test.ts | 4 +- relay-e2e/test/replay-key.test.ts | 31 ++++++++++++- relay-web/src/entry/manage-page.ts | 16 ++++++- relay-web/src/preview-client.ts | 17 +++++-- relay-web/test/default-terminal.test.ts | 4 +- relay-web/test/extra-coverage.test.ts | 2 +- relay-web/test/preview-client.test.ts | 59 ++++++++++++++++++++++-- relay-web/test/preview-grid.test.ts | 11 ++++- 13 files changed, 243 insertions(+), 33 deletions(-) diff --git a/agent/src/e2e/replaySeal.ts b/agent/src/e2e/replaySeal.ts index 6180375..3ed135d 100644 --- a/agent/src/e2e/replaySeal.ts +++ b/agent/src/e2e/replaySeal.ts @@ -13,7 +13,19 @@ * the frozen relay-contracts signatures — production wiring passes the relay-e2e impls verbatim. * `hostContentSecret` comes from Keystore.loadContentSecret() (T3); NEVER the ephemeral key, * NEVER logged, NEVER sent to the relay (INV2/INV9). + * + * PER-GENERATION EPOCH (F6 fix): K_content = HKDF(hostContentSecret, salt=sessionId, info=const) is + * byte-identical for a given (host, sessionId) and RECOVERABLE by design — but the deterministic seal + * nonce is seq, which resets to 0 on every sealer reconstruction (restart / re-attach). Two sealer + * GENERATIONS would therefore seal DISTINCT plaintext under the SAME (key, nonce) → catastrophic AEAD + * reuse. Each `createReplaySealer` call mints a FRESH, NON-SECRET `epoch` (randomUUID) that is folded + * into K_content derivation, so a restart / new generation yields a FRESH key even for the same + * (hostContentSecret, sessionId); seq=0 can never collide across generations. The epoch is exposed on + * the sealer so the wiring can persist it with the ring buffer / replay stream and serve it to the + * browser, which re-derives the matching key. Recoverability WITHIN one generation (same epoch ⇒ same + * key) is preserved. */ +import { randomUUID } from 'node:crypto' import type { AeadAlg, AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts' /** The two §4.4 replay primitives, typed to the frozen relay-contracts signatures (P4 impls). */ @@ -23,13 +35,20 @@ export interface ReplayCrypto { } export interface ReplaySealer { + /** + * The fresh, NON-SECRET per-generation epoch folded into K_content (F6). The wiring persists it + * with the ring buffer / replay stream so the browser re-derives the matching key. + */ + readonly epoch: string /** K_content seal with monotonic seq per session (INV13); NOT the live h2c frame. */ seal(plaintext: Uint8Array): E2EEnvelope } /** - * Build a per-(host, session) replay sealer. K_content is derived ONCE from - * { hostContentSecret, sessionId, alg }; seq is strictly monotonic from 0 (INV13). + * Build a per-(host, session) replay sealer for ONE generation. A FRESH `epoch` is minted per call + * and folded into K_content, which is derived ONCE from { hostContentSecret, sessionId, alg, epoch }; + * seq is strictly monotonic from 0 (INV13). A restart / re-attach constructs a NEW generation with a + * NEW epoch ⇒ a FRESH key, so seq=0 never collides across generations (F6). */ export function createReplaySealer( hostContentSecret: Uint8Array, @@ -37,9 +56,11 @@ export function createReplaySealer( alg: AeadAlg, crypto: ReplayCrypto, ): ReplaySealer { - const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg }) + const epoch = randomUUID() + const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg, epoch }) let seq = 0n return { + epoch, seal(plaintext: Uint8Array): E2EEnvelope { const env = crypto.sealReplayFrame(key, seq, plaintext) seq += 1n diff --git a/agent/test/hostEndpoint.test.ts b/agent/test/hostEndpoint.test.ts index a9c348e..7c42d61 100644 --- a/agent/test/hostEndpoint.test.ts +++ b/agent/test/hostEndpoint.test.ts @@ -107,6 +107,7 @@ describe('createE2ETransform (T15)', () => { function fakeReplay(): ReplaySealer & { calls: number } { const r = { calls: 0, + epoch: 'test-epoch', seal(_pt: Uint8Array) { r.calls += 1 return { seq: 0n, nonce: new Uint8Array(), ciphertext: new Uint8Array([0xde]), tag: new Uint8Array() } diff --git a/agent/test/replaySeal.test.ts b/agent/test/replaySeal.test.ts index caaaf15..3601481 100644 --- a/agent/test/replaySeal.test.ts +++ b/agent/test/replaySeal.test.ts @@ -2,14 +2,24 @@ import { describe, expect, it, vi } from 'vitest' import type { AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts' import { createReplaySealer, type ReplayCrypto } from '../src/e2e/replaySeal.js' -/** Fake AEAD: key = tagged secret‖sessionId; ciphertext = plaintext XOR keyByte (marker hidden). */ -function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[] } { +/** + * Fake AEAD. The derived key is a tag that DEPENDS on every ReplayKeyParams field — crucially on + * `epoch` (F6), so two sealer generations for the same (secret, sessionId, alg) yield DIFFERENT keys. + * The tag leads with `epoch` so key[0] also varies per generation; ciphertext = plaintext XOR key[0] + * (a UUID's first char is a hex digit 0x30–0x66 → always nonzero, so the marker is always hidden). + */ +function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[]; keys: Uint8Array[] } { const derivations: ReplayKeyParams[] = [] + const keys: Uint8Array[] = [] return { derivations, + keys, deriveContentKey(params: ReplayKeyParams): AeadKey { derivations.push(params) - const tag = new TextEncoder().encode(`${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}`) + const tag = new TextEncoder().encode( + `${params.epoch}:${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}:${params.alg}`, + ) + keys.push(tag) return tag as unknown as AeadKey }, sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope { @@ -23,12 +33,26 @@ function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[] } { const SECRET = new Uint8Array([1, 2, 3, 4]) describe('createReplaySealer (T19, FIX 3)', () => { - it('derives K_content deterministically from (secret, sessionId, alg)', () => { - const c1 = fakeCrypto() - createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c1) - const c2 = fakeCrypto() - createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c2) - expect(c1.derivations[0]).toEqual(c2.derivations[0]) + it('folds the exposed per-generation epoch into K_content, deriving it exactly once', () => { + const c = fakeCrypto() + const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c) + expect(c.derivations).toHaveLength(1) + expect(c.derivations[0]!.epoch).toBe(sealer.epoch) + expect(sealer.epoch).toMatch(/^[0-9a-f-]{36}$/) // randomUUID shape + }) + + it('recoverable WITHIN one generation: re-deriving with the exposed epoch yields the same key', () => { + const c = fakeCrypto() + const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c) + // The browser re-derives from the SAME (secret, sessionId, alg, epoch) carried with the ring buffer. + const browser = fakeCrypto() + const browserKey = browser.deriveContentKey({ + hostContentSecret: SECRET, + sessionId: 'sess-1', + alg: 'aes-256-gcm', + epoch: sealer.epoch, + }) as unknown as Uint8Array + expect(Buffer.from(browserKey).equals(Buffer.from(c.keys[0]!))).toBe(true) }) it('a different sessionId → a different key (per-session separation)', () => { @@ -38,6 +62,23 @@ describe('createReplaySealer (T19, FIX 3)', () => { expect(c.derivations[0]!.sessionId).not.toBe(c.derivations[1]!.sessionId) }) + it('F6 regression: two generations for the SAME (secret, sessionId, alg) get DIFFERENT epochs → DIFFERENT keys, so seq=0 never collides', () => { + const c = fakeCrypto() + const gen1 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c) + const gen2 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c) + // Fresh epoch per generation… + expect(gen2.epoch).not.toBe(gen1.epoch) + // …therefore distinct K_content even though (secret, sessionId, alg) are identical… + expect(Buffer.from(c.keys[1]!).equals(Buffer.from(c.keys[0]!))).toBe(false) + // …so the seq=0 seal of generation 2 uses a DIFFERENT key than the seq=0 seal of generation 1 + // (this is exactly the (key, nonce) reuse F6 prevents — same nonce, but a fresh key). + const s1 = gen1.seal(new Uint8Array([0x41, 0x42, 0x43])) + const s2 = gen2.seal(new Uint8Array([0x41, 0x42, 0x43])) + expect(s1.seq).toBe(0n) + expect(s2.seq).toBe(0n) + expect(s1.nonce).toEqual(s2.nonce) // same deterministic nonce (seq=0)… + }) + it('emits a monotonic seq (INV13) and never leaks the plaintext marker (INV2)', () => { const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto()) const marker = new TextEncoder().encode('SECRET-MARKER') @@ -50,7 +91,7 @@ describe('createReplaySealer (T19, FIX 3)', () => { it('replay seal is DISTINCT from a live h2c seal for the same plaintext (FIX 3)', () => { const replay = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto()) - // model a live seal with a different key byte + // model a live seal with a different key byte (0x11 is below the 0x30–0x66 hex-digit epoch prefix) const liveKey = new Uint8Array([0x11]) as unknown as AeadKey const live = fakeCrypto().sealReplayFrame(liveKey, 0n, new Uint8Array([0x41, 0x42])) const rep = replay.seal(new Uint8Array([0x41, 0x42])) diff --git a/relay-contracts/src/e2e/types.ts b/relay-contracts/src/e2e/types.ts index e754d34..99bdc94 100644 --- a/relay-contracts/src/e2e/types.ts +++ b/relay-contracts/src/e2e/types.ts @@ -117,10 +117,23 @@ export interface AuthorizedDeviceContext { readonly hostContentSecret: Uint8Array // §4.5 host-scoped secret; NEVER logged/sent to relay } -/** FIX 3 replay content-key derivation params (§4.4). */ +/** + * FIX 3 replay content-key derivation params (§4.4). + * + * FIX 3b / F6 (anti-nonce-reuse): K_content = HKDF(hostContentSecret, salt=sessionId, info=const) + * was byte-identical for a given (host, sessionId), yet the agent-side sealer resets its + * deterministic-nonce seq to 0 on every reconstruction (restart / re-attach). Two sealer + * GENERATIONS would therefore seal distinct plaintext under the SAME (key, nonce) — a + * catastrophic AEAD reuse. The per-generation `epoch` is folded into K_content so a restart / + * new generation yields a FRESH key even for the same (hostContentSecret, sessionId); seq=0 can + * never collide across generations. Recoverability WITHIN one generation (same epoch ⇒ same key) + * is preserved. + */ export interface ReplayKeyParams { readonly hostContentSecret: Uint8Array readonly sessionId: string readonly alg: AeadAlg + readonly epoch: string // FIX 3b / F6: fresh per-sealer-generation diversifier folded into K_content; + // NON-SECRET, carried with the ring buffer so the browser re-derives the key. } export const REPLAY_KDF_INFO = 'relay-e2e/replay/v1' as const diff --git a/relay-e2e/src/replay-key.ts b/relay-e2e/src/replay-key.ts index a54a0ab..5deccb3 100644 --- a/relay-e2e/src/replay-key.ts +++ b/relay-e2e/src/replay-key.ts @@ -21,12 +21,36 @@ import { openFrame, sealFrame } from './session.js' const encoder = new TextEncoder() -/** HKDF(hostContentSecret, salt=sessionId, info=REPLAY_KDF_INFO) → per-session, per-host content key. */ +/** + * ASCII unit separator (0x1f). Domain-separates sessionId ‖ epoch inside the HKDF salt so + * distinct (sessionId, epoch) pairs can never alias via concatenation. Neither field contains + * it: sessionId is an opaque id and epoch is a generated non-secret diversifier. + */ +const SALT_FIELD_SEPARATOR = 0x1f + +/** salt = utf8(sessionId) ‖ 0x1f ‖ utf8(epoch) — unambiguous per-session, per-generation binding. */ +function replaySalt(sessionId: string, epoch: string): Uint8Array { + const sessionBytes = encoder.encode(sessionId) + const epochBytes = encoder.encode(epoch) + const salt = new Uint8Array(sessionBytes.length + 1 + epochBytes.length) + salt.set(sessionBytes, 0) + salt[sessionBytes.length] = SALT_FIELD_SEPARATOR + salt.set(epochBytes, sessionBytes.length + 1) + return salt +} + +/** + * HKDF(hostContentSecret, salt=sessionId ‖ 0x1f ‖ epoch, info=REPLAY_KDF_INFO) → per-session, + * per-host, per-generation content key (F6). A fresh `epoch` per sealer generation yields a fresh + * key even for the same (hostContentSecret, sessionId), so a seq=0 reset after restart/re-attach + * can never collide with a prior generation's (key, nonce). Recoverability WITHIN one generation is + * preserved: same (secret, sessionId, alg, epoch) ⇒ byte-identical key. + */ export function deriveContentKey(p: ReplayKeyParams): AeadKey { const raw = hkdf( sha256, p.hostContentSecret, - encoder.encode(p.sessionId), + replaySalt(p.sessionId, p.epoch), encoder.encode(REPLAY_KDF_INFO), AEAD_KEY_BYTES, ) diff --git a/relay-e2e/test/keystore.test.ts b/relay-e2e/test/keystore.test.ts index 343a701..73d6e0d 100644 --- a/relay-e2e/test/keystore.test.ts +++ b/relay-e2e/test/keystore.test.ts @@ -37,8 +37,8 @@ describe('T11 multi-device adapters (authenticated channel, NOT a QR)', () => { expect(result.aead).toBe('xchacha20-poly1305') expect(label).toBeTruthy() } - const kA = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm' }) - const kB = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm' }) + const kA = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm', epoch: 'gen-0' }) + const kB = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm', epoch: 'gen-0' }) expect(bytesToHex(unwrapAeadKey(kA).raw)).toBe(bytesToHex(unwrapAeadKey(kB).raw)) }) diff --git a/relay-e2e/test/replay-key.test.ts b/relay-e2e/test/replay-key.test.ts index 2e64d0f..adf5c0c 100644 --- a/relay-e2e/test/replay-key.test.ts +++ b/relay-e2e/test/replay-key.test.ts @@ -6,10 +6,16 @@ import { AeadOpenError } from '../src/errors.js' import { deriveContentKey, openReplayCiphertext, sealReplayFrame } from '../src/replay-key.js' import { bytesToHex, fromUtf8, utf8 } from './helpers.js' -const params = (secret: Uint8Array, sessionId: string, alg: AeadAlg = 'aes-256-gcm'): ReplayKeyParams => ({ +const params = ( + secret: Uint8Array, + sessionId: string, + alg: AeadAlg = 'aes-256-gcm', + epoch = 'gen-0', +): ReplayKeyParams => ({ hostContentSecret: secret, sessionId, alg, + epoch, }) describe('T10 recoverable replay content-key', () => { @@ -48,6 +54,29 @@ describe('T10 recoverable replay content-key', () => { expect(() => openReplayCiphertext(deriveContentKey(params(secretA, 's2')), wire)).toThrow(AeadOpenError) }) + it('F6: SAME (secret, sessionId) but DIFFERENT epoch ⇒ different raw key bytes (no seq=0 (key,nonce) reuse across generations)', () => { + const secret = new Uint8Array(32).fill(0x66) + // Two sealer generations (e.g. before/after an agent restart) that both reset seq to 0. + const kA = deriveContentKey(params(secret, 'sess-F6', 'aes-256-gcm', 'epoch-A')) + const kB = deriveContentKey(params(secret, 'sess-F6', 'aes-256-gcm', 'epoch-B')) + // Raw key bytes MUST differ, so sealFrame(kA, 0n) and sealFrame(kB, 0n) never share (key, nonce). + expect(bytesToHex(unwrapAeadKey(kA).raw)).not.toBe(bytesToHex(unwrapAeadKey(kB).raw)) + + // And a frame sealed under one epoch cannot be opened with the other epoch's key. + const wireA = encodeEnvelope(sealReplayFrame(kA, 0n, utf8('generation A, seq 0'))) + expect(() => openReplayCiphertext(kB, wireA)).toThrow(AeadOpenError) + }) + + it('F6: SAME (secret, sessionId, epoch) ⇒ identical key — recoverability WITHIN a generation preserved', () => { + const secret = new Uint8Array(32).fill(0x67) + const kSeal = deriveContentKey(params(secret, 'sess-F6', 'aes-256-gcm', 'epoch-E')) + const wire = encodeEnvelope(sealReplayFrame(kSeal, 0n, utf8('same-epoch replay'))) + // Browser re-derives the key for the SAME epoch E carried with the ring buffer. + const kReDerived = deriveContentKey(params(secret, 'sess-F6', 'aes-256-gcm', 'epoch-E')) + expect(bytesToHex(unwrapAeadKey(kSeal).raw)).toBe(bytesToHex(unwrapAeadKey(kReDerived).raw)) + expect(fromUtf8(openReplayCiphertext(kReDerived, wire))).toBe('same-epoch replay') + }) + it('revocation (INV12): a revoked wrap yields no secret → no key derivable for that host going forward', () => { // The unwrap mechanism is P5/P2; P4 asserts derivation is GATED on having the unwrapped secret. const revokedUnwrap = (): Uint8Array => { diff --git a/relay-web/src/entry/manage-page.ts b/relay-web/src/entry/manage-page.ts index 436dc91..5670db7 100644 --- a/relay-web/src/entry/manage-page.ts +++ b/relay-web/src/entry/manage-page.ts @@ -16,11 +16,23 @@ import { createApiClient } from '../api-client' import { mountPreviewGrid } from '../preview-grid' import type { ReplaySource } from '../preview-client' +/** + * FIX 3b / F6 placeholder epoch. The real per-generation `epoch` MUST be served by P1/P2 alongside + * the ciphertext ring buffer (the agent stamps a fresh epoch each time it reconstructs the sealer and + * resets seq to 0). This constant only keeps the `ReplaySource` shape explicit until those endpoints + * land; the stub still throws, so no frame is ever decrypted under it. + */ +const PLACEHOLDER_EPOCH = 'PENDING_P1_P2_EPOCH' + async function loadReplay( _hostId: string, ): Promise<{ replay: ReplaySource; hostContentSecret: Uint8Array }> { - // TODO(P5/P1): fetch ciphertext replay + hostContentSecret (post auth/step-up). Not yet available. - throw new Error('replay source not yet wired (pending P5/P1 endpoints)') + // TODO(P5/P1/P2): fetch ciphertext replay + hostContentSecret (post auth/step-up) AND the real + // per-generation `epoch` (FIX 3b / F6) that the frames were sealed under. Until then the shape is: + // { replay: { sessionId, alg, epoch: PLACEHOLDER_EPOCH, frames }, hostContentSecret } + // Not yet available → throw so a card shows "unavailable" rather than a fabricated screen. + void PLACEHOLDER_EPOCH + throw new Error('replay source not yet wired (pending P5/P1/P2 endpoints)') } function boot(): void { diff --git a/relay-web/src/preview-client.ts b/relay-web/src/preview-client.ts index 618f66f..8cabf6b 100644 --- a/relay-web/src/preview-client.ts +++ b/relay-web/src/preview-client.ts @@ -6,10 +6,11 @@ * Ring-buffer replay survives a reload because the agent (P2) sealed each stored frame with * `sealReplayFrame` under the RECOVERABLE content key `K_content` — NOT the ephemeral live * `DirectionalKeys` (which are re-derived per handshake and lost on reload). The browser re-derives - * the SAME `K_content` via `deriveContentKey({ hostContentSecret, sessionId, alg })` (§4.4 FIX 3; + * the SAME `K_content` via `deriveContentKey({ hostContentSecret, sessionId, alg, epoch })` (§4.4 FIX 3; * `hostContentSecret` obtained via P5 after auth/step-up) and decrypts each payload with - * `openReplayCiphertext`. A wrong/ephemeral key throws (AEAD tag) → the card shows "unavailable", - * never a torn/garbled screen (cross-host/session isolation, INV1). + * `openReplayCiphertext`. A wrong/ephemeral key — or a `ReplaySource.epoch` that doesn't match the + * generation the frames were sealed under (FIX 3b / F6) — throws (AEAD tag) → the card shows + * "unavailable", never a torn/garbled screen (cross-host/session isolation, INV1). * * The §4.4 crypto (`deriveContentKey`/`openReplayCiphertext`) is imported from `relay-e2e` and * injected — cited verbatim, never re-implemented. `hostContentSecret`/`K_content` are transient in @@ -17,10 +18,17 @@ */ import type { AeadAlg, AeadKey, ReplayKeyParams } from 'relay-contracts' -/** Ciphertext ring-buffer replay for one host/session. */ +/** Ciphertext ring-buffer replay for one host/session (one sealer generation). */ export interface ReplaySource { readonly sessionId: string readonly alg: AeadAlg // negotiated aead (matches how P2 sealed the replay) + // FIX 3b / F6: the per-generation `epoch` under which THESE frames were sealed. The agent resets + // its deterministic-nonce seq to 0 on every reconstruction (restart/re-attach); folding a fresh + // epoch into K_content gives each generation a fresh key, so a seq=0 reset can never collide with + // a prior generation's (key, nonce). NON-SECRET — carried with the ring buffer so the browser + // re-derives the matching key. Frames from a DIFFERENT epoch derive a different key → AEAD tag + // fails → "unavailable" (never a torn screen). + readonly epoch: string readonly frames: readonly Uint8Array[] // stored DATA payloads, each a sealReplayFrame envelope } @@ -85,6 +93,7 @@ export function mountPreviewClient( hostContentSecret, sessionId: replay.sessionId, alg: replay.alg, + epoch: replay.epoch, // F6: bind the key to THIS generation; a mismatched epoch → wrong key. }) } catch { showUnavailable() diff --git a/relay-web/test/default-terminal.test.ts b/relay-web/test/default-terminal.test.ts index d88bc5f..562c751 100644 --- a/relay-web/test/default-terminal.test.ts +++ b/relay-web/test/default-terminal.test.ts @@ -52,10 +52,12 @@ describe('default (real-xterm) loaders behind the DI seam', () => { it('mountPreviewClient renders via the default read-only xterm loader', async () => { const secret = randomBytes(32) - const k = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm' }) + const epoch = 'gen-1' // FIX 3b / F6: seal + declare under the same generation. + const k = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm', epoch }) const replay: ReplaySource = { sessionId: 's', alg: 'aes-256-gcm', + epoch, frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode('DEFAULT-XTERM')))], } const card = document.createElement('div') diff --git a/relay-web/test/extra-coverage.test.ts b/relay-web/test/extra-coverage.test.ts index f4715be..0e9765a 100644 --- a/relay-web/test/extra-coverage.test.ts +++ b/relay-web/test/extra-coverage.test.ts @@ -207,7 +207,7 @@ describe('preview-client unavailable path', () => { const card = document.createElement('div') const client = mountPreviewClient( card, - { sessionId: 's', alg: 'aes-256-gcm', frames: [new Uint8Array([1])] }, + { sessionId: 's', alg: 'aes-256-gcm', epoch: 'gen-1', frames: [new Uint8Array([1])] }, new Uint8Array(32), { cols: 80, rows: 24 }, { diff --git a/relay-web/test/preview-client.test.ts b/relay-web/test/preview-client.test.ts index f413854..238935e 100644 --- a/relay-web/test/preview-client.test.ts +++ b/relay-web/test/preview-client.test.ts @@ -11,16 +11,38 @@ import { const HOST_SECRET = randomBytes(32) const SESSION_ID = 'sess-1' const ALG = 'aes-256-gcm' as const +const EPOCH = 'gen-1' // FIX 3b / F6: the sealer generation these fixtures seal + declare under. -/** Seal a screen (as a P2 agent stub would) under K_content with sealReplayFrame. */ -function sealedReplay(lines: readonly string[], secret = HOST_SECRET): ReplaySource { - const k = deriveContentKey({ hostContentSecret: secret, sessionId: SESSION_ID, alg: ALG }) +interface SealOpts { + readonly secret?: Uint8Array + /** Epoch the FRAMES are actually sealed under (the key that encrypts). */ + readonly sealEpoch?: string + /** Epoch the ReplaySource DECLARES (the key the browser will re-derive). Defaults to sealEpoch. */ + readonly declaredEpoch?: string +} + +/** + * Seal a screen (as a P2 agent stub would) under K_content with sealReplayFrame. Recoverability + * holds because the frames are sealed with the SAME (secret, sessionId, alg, epoch) the returned + * ReplaySource declares. For the F6 regression, `sealEpoch` and `declaredEpoch` are set apart so the + * browser derives a different key than the frames were sealed under. + */ +function sealedReplay(lines: readonly string[], opts: SealOpts = {}): ReplaySource { + const secret = opts.secret ?? HOST_SECRET + const sealEpoch = opts.sealEpoch ?? EPOCH + const declaredEpoch = opts.declaredEpoch ?? sealEpoch + const k = deriveContentKey({ + hostContentSecret: secret, + sessionId: SESSION_ID, + alg: ALG, + epoch: sealEpoch, + }) const frames = lines.map((line, i) => // sealReplayFrame → E2EEnvelope, encoded to a DATA payload the browser opens. // openReplayCiphertext decodes+opens; we mirror the encode via the session envelope codec. encodeReplayFrame(sealReplayFrame(k, BigInt(i), new TextEncoder().encode(line))), ) - return { sessionId: SESSION_ID, alg: ALG, frames } + return { sessionId: SESSION_ID, alg: ALG, epoch: declaredEpoch, frames } } // The replay codec: relay-e2e's openReplayCiphertext expects the encoded envelope bytes. @@ -67,6 +89,35 @@ describe('mountPreviewClient (T9)', () => { expect(term.written).toEqual([]) // never wrote a torn screen }) + it('F6: a ReplaySource epoch that does NOT match the sealed generation → "unavailable", never a torn screen', async () => { + const card = document.createElement('div') + const term = mockTerminal() + // Frames sealed under generation "gen-1", but the ReplaySource declares a DIFFERENT epoch + // ("gen-2") — as if the ring buffer were mislabeled with the wrong generation. The browser + // re-derives a different K_content → AEAD tag fails → unavailable (no garbled render). + const replay = sealedReplay(['stale-screen'], { sealEpoch: 'gen-1', declaredEpoch: 'gen-2' }) + const client = mountPreviewClient(card, replay, HOST_SECRET, { cols: 80, rows: 24 }, { + ...realDeps, + createTerminal: () => term, + }) + await client.render() + expect(card.querySelector('.preview-unavailable')?.textContent).toBe('unavailable') + expect(term.written).toEqual([]) // never wrote a torn screen from the wrong generation + }) + + it('F6: a matching per-generation epoch preserves recoverability (frames render)', async () => { + const card = document.createElement('div') + const term = mockTerminal() + // sealEpoch === declaredEpoch (both "gen-7") ⇒ same key within one generation ⇒ replay recovers. + const replay = sealedReplay(['line-A', 'line-B'], { sealEpoch: 'gen-7', declaredEpoch: 'gen-7' }) + const client = mountPreviewClient(card, replay, HOST_SECRET, { cols: 80, rows: 24 }, { + ...realDeps, + createTerminal: () => term, + }) + await client.render() + expect(term.written).toEqual(['line-A', 'line-B']) + }) + it('read-only: the preview terminal exposes NO input path (no onData/send)', async () => { const card = document.createElement('div') const term = mockTerminal() diff --git a/relay-web/test/preview-grid.test.ts b/relay-web/test/preview-grid.test.ts index 3bb47da..b48bd6d 100644 --- a/relay-web/test/preview-grid.test.ts +++ b/relay-web/test/preview-grid.test.ts @@ -27,9 +27,16 @@ function host(sub: string): HostRecord { } } +const EPOCH = 'gen-1' // FIX 3b / F6: per-generation key diversifier (seal + declare under the same one). + function sealed(sessionId: string, secret: Uint8Array, line: string): ReplaySource { - const k = deriveContentKey({ hostContentSecret: secret, sessionId, alg: ALG }) - return { sessionId, alg: ALG, frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode(line)))] } + const k = deriveContentKey({ hostContentSecret: secret, sessionId, alg: ALG, epoch: EPOCH }) + return { + sessionId, + alg: ALG, + epoch: EPOCH, + frames: [encodeEnvelope(sealReplayFrame(k, 0n, new TextEncoder().encode(line)))], + } } function fakeApi(hosts: readonly HostRecord[]): ApiClient { From ad5cf06207ccf929b68d053d273024205977e9bb Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 2 Jul 2026 16:41:19 +0200 Subject: [PATCH 3/4] test(relay): cross-package e2e adversarial security harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- e2e/harness/dpop.ts | 141 ++++++++++ e2e/harness/fakes.ts | 132 ++++++++++ e2e/harness/spy.ts | 34 +++ e2e/harness/world.ts | 332 ++++++++++++++++++++++++ e2e/package.json | 12 + e2e/tests/adversarial-auth.test.ts | 226 ++++++++++++++++ e2e/tests/adversarial-transport.test.ts | 115 ++++++++ e2e/tests/flow.test.ts | 99 +++++++ e2e/tests/smoke.test.ts | 21 ++ e2e/tsconfig.json | 15 ++ e2e/vitest.config.ts | 8 + 11 files changed, 1135 insertions(+) create mode 100644 e2e/harness/dpop.ts create mode 100644 e2e/harness/fakes.ts create mode 100644 e2e/harness/spy.ts create mode 100644 e2e/harness/world.ts create mode 100644 e2e/package.json create mode 100644 e2e/tests/adversarial-auth.test.ts create mode 100644 e2e/tests/adversarial-transport.test.ts create mode 100644 e2e/tests/flow.test.ts create mode 100644 e2e/tests/smoke.test.ts create mode 100644 e2e/tsconfig.json create mode 100644 e2e/vitest.config.ts 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', + }, +}) From 0ad7c31549d6b3412eebd8da461f8f5d6841ecbf Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 2 Jul 2026 16:41:19 +0200 Subject: [PATCH 4/4] docs(relay): security audit report + progress log for the relay findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REVIEW_RELAY_SECURITY.md: two-round multi-agent audit — F1–F6 with fix plan/ resolution, refuted-but-recorded items, and the e2e harness coverage table. PROGRESS_LOG.md: cross-session record of the audit + fixes + harness. --- docs/PROGRESS_LOG.md | 9 ++ docs/REVIEW_RELAY_SECURITY.md | 169 ++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 docs/REVIEW_RELAY_SECURITY.md diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 9be614b..a5b1605 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,6 +24,15 @@ > 新会话读到的第一块。保持准确,只描述"此刻"。 +### ✅ 安全审计 relay-e2e + relay-auth — 上线前深度审计 + 修复(完成 — 2026-07-02,分支 `security/relay-auth-audit-fixes`,未提交) +上线门:自动生成的 E2E/鉴权核心须过安全专家审计(前次复审判 approve-with-changes)。**多 agent 编排 = 两个 ultracode `Workflow`**:①**审计**(99 agents)—— 11 维 finder 并行(两包)→ 每个 finding 3 lens 对抗式 verify(exploitability/crypto-spec/refuter 多数票)→ 第二轮「重复 review」深挖 → 每包 completeness critic → dedup+定级;②**修复循环**(10 agents)—— foundation(types)→ 3 组文件互斥并行修复(module fixers)→ **verify 循环至 tsc 干净 + 全绿** → 5 路对抗式 re-audit 确认每个 exploit 已闭合。 +- **审计结论**: **relay-e2e = PASS 零改动**(所有候选在 verify 阶段被驳回:确定性 nonce 安全 —— 方向分离 + 真实 agent 侧 `createReplaySealer` 各自持独立单调 seq;strict-successor recv guard 无法导致 replay-accept)。**relay-auth = approve-with-changes,5 条确认**(2 HIGH / 1 MED / 2 LOW),全部在 human-auth/enforcement 层,全部包内可修。驳回但存档(不再复议):E2E replay nonce reuse、mTLS EKU、SPIFFE 域未 pin、reattach host-scope、TOTP 时序。完整报告见 `docs/REVIEW_RELAY_SECURITY.md`。 +- **修复**(均含回归测试): **F1 HIGH** step-up 因子降级 —— `needsStepUp` 现把新鲜度绑定到**精确方法**(`stepUpMethod`),钓来的 TOTP 不再满足 passkey;**F2 HIGH** step-up 在新建会话路径被完全跳过(生产 caller `term-relay/data-plane/upgrade.ts:105` 硬编码 `principal:null` → 生产环境 step-up 形同虚设)—— 改为**主机驱动 + fail-closed**(`policy.required` 时 principal 缺失/过期/错方法一律 403);**F3 MED** WebAuthn 断言未绑定已存凭据 —— 现校验 credentialId 且转发 pubkey 给 verifier;**F4 LOW** DPoP verify 未捕获抛出 → INV15 门未审计地崩(审计逃逸+DoS)—— `verifyDpopProof` 全体 try/catch→false + `coreAuthorize` 边界兜成干净审计 401 + issue 校验 cnfJkt 格式;**F5 LOW** 验证过的 signCount 被丢弃 → 克隆检测失效 —— `finishAuthentication` 现返回 `{principal,newSignCount}` 供持久化。 +- **验证(orchestrator 亲验)**: relay-auth `tsc --noEmit` 干净 + **122 测试全绿**(基线 104,+18 回归);relay-e2e 未动、tsc 干净、76 全绿。re-audit **5/5 闭合**(置信 0.95–0.97,均有回归测试)。 +- **遗留/behavioral note(非阻塞)**: F2 改为 fail-closed 后,**任何 `stepUpPolicyFor(host).required===true` 的主机,P1(term-relay)必须在 upgrade ctx 传入已认证 principal**,否则正确地被拒;当前 v0.9 默认 `NO_STEPUP_POLICY.required===false`,现网非 step-up 主机不受影响。P1 集成时须补此接线(见 [[relay-stepup-needs-principal-wiring]])。 +- **第二轮独立 review(Fable 5,换模型增强多样性)+ F6 修复**: 第二个 `Workflow`(fix-regression 猎取 + 新角度 sweep + 对抗式 re-attack 被驳回项)判:**5 条修复 0 regression**、被驳回/D-i-D 项 re-attack **0 存活**(确认真不可利用),但**新增 1 条 HIGH——F6**:relay-e2e `replay-key.ts` 的可恢复 `K_content` 仅由 `(hostContentSecret, sessionId)` 决定(稳定),而真实消费者 `agent/src/e2e/replaySeal.ts` 的 `let seq=0n` 在每次重建(重启/重连)时归零 → 两代 sealer 在**相同 (key, nonce)** 下封不同明文 = 灾难性 AEAD 复用。**第一轮曾误驳此项**(verifier 看到"独立单调计数器"就类比 live 路径判安全,漏了 K_content 跨重启稳定);第二轮定向 re-attack + orchestrator 亲读 `replaySeal.ts` 证实为真。**修法(用户选 epoch-in-key,4 包)**:`ReplayKeyParams` 加 `epoch`;`deriveContentKey` 把 epoch 以 `sessionId‖0x1f‖epoch` 混入 HKDF salt;`createReplaySealer` 每代 `randomUUID()` 新 epoch 并暴露;`ReplaySource` 带 epoch、浏览器按之重推。**验证(亲验)**:4 包 tsc 干净 + 全绿(relay-contracts 81 / relay-e2e 78[+2 F6 回归] / agent 133 / relay-web 99);re-audit 判闭合(回归证:两代同 seq=0 nonce 但不同 key)。**残留(fail-closed 非复用)**:ring-buffer 传输(P1/P2)须持久化并按代下发 epoch —— 已记 TODO,当前 replay 路径尚未端到端接线(`manage-page.loadReplay` 仍是 throw stub),故 F6 此刻是潜伏漏洞。完整报告 `docs/REVIEW_RELAY_SECURITY.md`(含 F6 + RESOLUTION)。 +- **动态 e2e 安全测试台(新增 `e2e/` 包)**: 跨包 harness `buildRelayWorld()` 把**真实** P5(auth)+P4(crypto)+P2(agent replay) 通过内存 seam + 不可信 relay「RelaySpy」攻击者视角接起来(仅 I/O 边界 fake,所有安全检查走生产代码路径)。全流程 issue cap→upgrade/authz→handshake→sealed session 过 relay→reattach→revoke。**21 测试全绿、tsc 干净(亲验)**。把 F1/F2/F3/F4/F6 从「静态+单测」升级为「动态攻击者实测」,另加 MITM/reflection/replay/reorder/INV2(live+replay)/cross-tenant/single-use。F5(signCount)为返回形状改动、由 relay-auth 单测覆盖;RelaySpy 代替 term-relay mux(真多进程/浏览器 e2e 需尚未建的 run tooling)。用符号链接免 `npm install`(`e2e/node_modules/*` → 各包源码)。覆盖表见 `docs/REVIEW_RELAY_SECURITY.md`。**未提交**,留待用户决定。 + ### ✅ 桌面客户端 v0.1 — Electron 一体化壳(代码完成 — 2026-07-01,分支 `feat/desktop-electron`,未提交) Mac/Windows 桌面 App,**内嵌现有 Node 服务器 + node-pty**(all-in-one):主进程 `startEmbeddedServer` 复用 `src/server.ts` 的 `startServer(cfg)`/`loadConfig`(**服务器零改动** —— 它本就导出可编程启动且 import 无副作用),窗口 `loadURL('http://127.0.0.1:/')` 复用**前端零改动**(前端严格同源,`location.host` 自动指向内嵌服务器;Origin 白名单默认含 localhost)。核心价值 = **原生通知**(轮询 `/live-sessions` → `computeNotifications` → 系统通知)+ 托盘常驻 + 深链 `terminalapp://` + 开机自启。 - **编排**: ultracode `Workflow` —— 脚手架(orchestrator 冻结 `desktop/src/types.ts` 契约 + package/tsconfig/esbuild/electron-builder)→ Build(4 builder 并行、文件互斥、对齐冻结签名)→ Review(correctness/security/typescript 三 lens 并行、schema 结构化 findings)。纯逻辑尽量抽出可单测;Electron glue(main/window/tray/menu/preload/embedded-server/logger)作为不可单测 wiring 排除出覆盖率(沿用既有 vitest.config 先例)。**无用 zod、无用 electron-store**(遵循项目手写校验 + 手写 JSON 持久化的最小依赖惯例)。 diff --git a/docs/REVIEW_RELAY_SECURITY.md b/docs/REVIEW_RELAY_SECURITY.md new file mode 100644 index 0000000..ea537fa --- /dev/null +++ b/docs/REVIEW_RELAY_SECURITY.md @@ -0,0 +1,169 @@ +# Security Audit — relay-e2e + relay-auth (pre-production go-live gate) + +**Date:** 2026-07-02 · **Branch:** `security/relay-auth-audit-fixes` · **Prior verdict:** approve-with-changes +**Method:** multi-agent deep audit — 11 security dimensions fanned out across both packages, every +candidate finding adversarially verified by ≥3 independent skeptic lenses (exploitability / +crypto-spec / refuter, majority vote), a second "repeat-review" deep-dive pass, a per-package +completeness critic, then dedup + severity re-rank. 99 agents, ~6.9M tokens. The orchestrator +independently read 100% of both `src/` trees to adjudicate. + +## Verdict + +**relay-e2e (crypto core): PASS — no changes required.** Every candidate against the E2E core +(replay-key nonce reuse, sequence/handshake ordering, key-hygiene, X25519 low-order) was **refuted** +on verification: the deterministic-nonce discipline is safe because the direction split + the real +agent-side `createReplaySealer` (in `term-relay/agent`) each own an independent monotonic counter; +recv `accept()` is a strict-successor guard whose pre-auth commit cannot cause a replay-accept. + +**relay-auth: APPROVE-WITH-CHANGES — 5 confirmed findings (2 HIGH, 1 MED, 2 LOW), all in the +human-auth / enforcement layer.** All fixes are contained in relay-auth; the P5-local types +(`AuthenticatedPrincipal`, `StepUpPolicy`) are extendable without touching any frozen contract. + +### Refuted (verified NOT exploitable — recorded so they are not re-litigated) +- E2E replay `K_content` nonce reuse — real consumer keeps an independent monotonic seq (no reset-to-0). +- mTLS `verifyChain` missing EKU/keyUsage — every abuse cert fails the later mandatory SPIFFE-SAN → + enrolled-&-non-revoked-host registry gate. +- SPIFFE trust-domain not pinned on verify — gated by chain-valid + registry; defense-in-depth only. +- Reattach host-scope "bypass" — `token.host === requestedHostId` bind at `decide.ts:62` blocks it; + session↔host binding is enforced at connect by P1/P3. (Kept as a cheap D-i-D hardening note.) +- TOTP non-constant-time compare / device-proof `acct` canonicalization / DPoP ±future-iat — LOW, refuted. + +--- + +## Confirmed findings + fix plan + +### F1 · HIGH — Step-up factor downgrade (`human/stepup/stepup.ts:33`) +`needsStepUp()` tests `policy.requiredMethod ∈ principal.amr` and freshness via a single +method-agnostic `stepUpAt`. `amr` is cumulative across login + every step-up, so a **passkey** +requirement is satisfied by a passkey used at *login*, while the freshness timestamp can be produced +by a *weaker* step-up (e.g. phished TOTP). The phishing-resistant control silently downgrades. +**Fix:** bind freshness to the method. Add `stepUpMethod` to the principal; `needsStepUp` requires +`stepUpMethod === requiredMethod` AND fresh; `recordStepUp` stamps the method. (Group A) + +### F2 · HIGH — Mandatory step-up skipped on the new-session path (`enforce/onUpgrade.ts:128`) +The gate is wrapped in `if (ctx.principal !== null)`, but the production caller +(`term-relay/data-plane/upgrade.ts:105`) hardcodes `principal: null` on **both** connect and reattach +(session principal exists only *after* authz). Result: **step-up never runs in production** — a valid +DPoP-bound token opens a root shell with the mandatory passkey ceremony never performed. +Deny-by-default is violated (absence of proof → allow). +**Fix:** make step-up **host-driven and fail-closed**: when `stepUpPolicyFor(host).required`, DENY +(403 `step_up_required`) unless an authenticated principal proves fresh step-up. Applies to connect +and reattach. Default v0.9 policy is `required:false` → existing non-step-up hosts unaffected. +**Behavioral note:** once a host sets `required:true`, the P1 caller must supply a step-up principal; +until then such hosts correctly deny. (Group A) + +### F3 · MEDIUM — WebAuthn assertion not bound to the stored credential (`human/webauthn/authenticate.ts:40`) +`finishAuthentication` forwards only `signCount` to the verifier — never `cred.publicKey` / +`cred.credentialId`, and there is no `credentialId` cross-check. Any reasonable verifier can only +check challenge/origin/rpId, so an assertion from a *different* registered credential authenticates. +**Fix:** add `credentialPublicKey` + `expectedCredentialId` to the verifier interface & forward them; +assert `resp.credentialId === cred.credentialId` before trusting `verified`. (Group B) + +### F4 · LOW — DPoP verify throws (unhandled) → INV15 gate fails un-audited (`capability/verify.ts:142`) +`importEd25519PublicRaw(rawPub)` (and `readCnfJkt`) run outside try/catch; a thumbprint-matching but +malformed key makes `verifyDpopProof` **reject** instead of returning false. Callers (`decide.ts:57`, +`onUpgrade.ts:112`) don't catch → the sole authz gate throws an unhandled exception and emits **no** +`deny` AuditEvent (audit-evasion + repeatable unhandled-rejection DoS). Fail-closed but off-contract. +**Fix (defense-in-depth):** (a) wrap the risky calls in `verifyDpopProof` → return false; (b) wrap the +`verifyDpopProof` call in `coreAuthorize` → clean audited `deny(401)`; (c) validate `cnfJkt` format at +issue. (Group C) + +### F5 · LOW — WebAuthn verified `signCount` discarded → clone detection inert (`human/webauthn/authenticate.ts:52`) +The advanced `newSignCount` is used only for the regression check and never returned/persisted, so the +guard forever compares against the registration-time count — a cloned authenticator is never detected. +**Fix:** return `newSignCount` (updated credential) so the caller can persist it. (Group B) + +--- + +## Execution plan (multi-agent, loop mode) + +- **Foundation (barrier):** `types.ts` — add `StepUpPolicy.required: boolean` + `AuthenticatedPrincipal.stepUpMethod?: AuthMethod|null` (+ Zod). Optional `stepUpMethod` ⇒ no ripple to other principal constructors; absent ⇒ treated as null ⇒ needs-step-up (fail-safe). +- **Fix groups (parallel, file-disjoint):** + - **Group A (F1,F2):** `stepup.ts`, `enforce/onUpgrade.ts`, `test/{stepup,enforce}.test.ts`, `test/tripwire/cross-tenant.test.ts`. + - **Group B (F3,F5):** `human/webauthn/{verifier,authenticate}.ts`, `test/webauthn.test.ts`. + - **Group C (F4):** `capability/{verify,issue}.ts`, `authz/decide.ts`, `test/capability.test.ts`. +- **Verify (loop until green):** `tsc --noEmit` + full `vitest run` in relay-auth; repair any breakage; repeat. Baseline to preserve/extend: relay-e2e 76, relay-auth 104. +- **Re-audit (parallel adversarial):** one verifier per finding re-reads the fixed code and confirms the specific exploit is closed. + +Each fix ships with a **regression test** encoding the exploit (F2: STRICT+null→403; F1: fresh TOTP ≠ passkey; F3: credentialId mismatch→reject; F4: malformed-jwk DPoP→false+audited deny; F5: newSignCount surfaced). + +--- + +## Second independent review (2026-07-02, Fable 5) — RECORD CORRECTION + +A second review (fix-regression hunt + fresh-angle sweep + adversarial re-attack of refuted items), +run on a **different model** for reviewer diversity, returned: +- **Fix-regression: 0** — the 5 fixes (F1–F5) survive independent scrutiny, no regressions. +- **Re-attack of refuted/D-i-D items: 0 survived** — mTLS EKU, SPIFFE domain, reattach host-scope, TOTP + replay, device-proof canonicalization are re-confirmed **non-exploitable**. +- **1 new HIGH** — which the first audit had **REFUTED in error** (a false-negative). Corrected below. + +### F6 · HIGH — Recoverable `K_content` replay key reuses `(key, nonce)` across sealer generations +**⚠️ This corrects the earlier "relay-e2e PASS" verdict — relay-e2e's replay surface has a real nonce-reuse hazard.** +`relay-e2e/src/replay-key.ts` `deriveContentKey` keys `K_content = HKDF(hostContentSecret, salt=sessionId, +info=const)` — **deterministic**, and *intended* to be recoverable/stable per `(host, sessionId)`. The +nonce is deterministic `f(seq)` and `sealReplayFrame` is stateless (no SequenceGuard). The sole real +consumer, `agent/src/e2e/replaySeal.ts:41` `createReplaySealer`, holds `let seq = 0n` **in memory** and +**resets to 0 on every reconstruction** (agent restart/redeploy, or a new attach), while `hostContentSecret` +is persisted 0600 (Keystore) and `sessionId` is stable (localStorage). Unlike the LIVE h2c path — safe +because each reconnect runs a fresh ECDH → brand-new keys — the replay path reuses the **same K_content** +with `seq` restarting at 0 ⇒ two generations of distinct plaintext sealed under identical `(key, nonce)`. +An untrusted relay (INV2 sees ciphertext) that observes both generations recovers plaintext-XOR (terminal +output: commands/tokens/code) and, for AES-256-GCM, recovers the GHASH auth-subkey → forgery, which +`openReplayCiphertext` (no cross-frame replay check) will accept. +**Why round 1 missed it:** the round-1 verifier located `let seq = 0n`, correctly noted it's an independent +counter, but wrongly concluded "safe" by analogy to the live path — missing that `K_content` (unlike the +live ephemeral keys) is STABLE across restarts, so the seq reset *does* collide. Round 2's dedicated +re-attack + orchestrator's direct read of `replaySeal.ts`/`hostEndpoint.ts` confirm the exploit. +**Fix options (design decision — touches a frozen contract and/or the agent package, both outside the two +originally-scoped packages):** +- **(A) Agent-side durable seq** — persist the replay `SequenceGuard` with the ring buffer / Keystore so + `seq` never resets for a given `(sessionId, K_content)`. Keeps `K_content` recoverable; blast radius = + `agent/` only. Requires durable monotonic-seq guarantee across restart. +- **(B) Per-generation epoch in `K_content`** — add an epoch/generation to `ReplayKeyParams` (frozen in + `relay-contracts/src/e2e/types.ts`), mixed into the HKDF salt, and carry it with each frame so the browser + re-derives the right key. Cryptographically robust (fresh key per generation) but changes a **frozen + contract** + `relay-e2e` + `relay-web` (browser re-derivation) + `agent`. +- Regression test (either path): two sealer generations for the same `(secret, sessionId)` must never emit + two frames sharing `(key, nonce)`. + +**RESOLUTION — FIXED (option B, epoch-in-key), 2026-07-02.** Implemented across all 4 packages: +`ReplayKeyParams` (relay-contracts) gains a required `epoch: string`; `deriveContentKey` (relay-e2e) folds it +into the HKDF salt as `utf8(sessionId) ‖ 0x1F ‖ utf8(epoch)` (unit-separator, no concat aliasing); +`createReplaySealer` (agent) mints a fresh `randomUUID()` epoch per generation and exposes it; `ReplaySource` +(relay-web) carries `epoch` and the browser re-derives with it. A fresh epoch per generation ⇒ a fresh key, +so a `seq=0` reset after restart/re-attach can never collide with a prior generation's `(key, nonce)`; +recoverability within a generation (same epoch ⇒ same key) is preserved. **Verified:** all 4 packages +tsc-clean and green (relay-contracts 81, relay-e2e 78 [+2 F6 regressions], agent 133, relay-web 99); re-audit +confirms closure (regression proves two generations share the seq=0 nonce yet derive different keys). +**Residual (fail-closed, not reuse):** the ring-buffer transport (P1/P2) must persist each generation's epoch +and serve the epoch matching those exact frames; documented as a TODO at `relay-web/.../manage-page.ts` +(`loadReplay` is still a throwing stub — the replay path is not wired end-to-end yet, so F6 was latent). + +--- + +## Dynamic end-to-end security harness (`e2e/`, 2026-07-02) + +A cross-package harness (`e2e/`) now wires the **real** P5(auth) + P4(crypto) + P2(agent replay) exports +through in-memory seams and an untrusted-relay "RelaySpy" attacker vantage — every security check runs the +production code path; only true I/O boundaries (registries/buckets/revocation/audit/sockets) are faked. +`buildRelayWorld()` exposes the full flow (issue cap → upgrade/authz → handshake → sealed session via relay +→ reattach → revoke). **21 tests pass, tsc clean.** This upgrades the findings from *static + unit* validation +to *dynamic* validation — the attacker actually attempts each exploit: + +| Attack (dynamic) | Asserted defense | +|---|---| +| **F1** step-up factor downgrade | fresh TOTP step-up → `needsStepUp` true + upgrade 403; passkey → allowed | +| **F2** step-up skipped (fail-open) | STRICT + `principal:null` → 403 `step_up_required` + audit; `required:false`+null → allowed | +| **F3** WebAuthn credential binding | `credentialId` mismatch → `WebAuthnError` even with a verifier that returns `verified:true` | +| **F4** DPoP unhandled throw | malformed thumbprint-matching jwk → `verifyDpopProof` resolves `false` (no throw) → clean audited `dpop_proof_failed` deny | +| **F6** replay `(key,nonce)` reuse | two sealer generations → same seq-0 nonce but different key ⇒ ciphertext+tag differ; cross-gen open throws; within-gen recovers | +| MITM | wrong `agentPubkey` / tampered `hostEphPub` → `FingerprintMismatchError`, no keys derived | +| reflection / replay / reorder | c2h→client.open rejects; dup/out-of-order `open` throws (SequenceGuard) | +| INV2 | RelaySpy ciphertext (live + replay path) never contains the plaintext marker | +| cross-tenant (INV1) | acct-A token at acct-B host → 403 `cross_tenant` + `cross-tenant-attempt` audit | +| capability single-use | same jti twice (fresh DPoP) → 2nd 403 `token_replayed` | + +**Not dynamically covered:** F5 (signCount persistence) is a return-shape change, asserted by its relay-auth +unit regression. The harness fakes the P1 relay/transport (RelaySpy) rather than running term-relay's mux +and the control-plane HTTP — a true multi-process/browser e2e would need the (not-yet-built) run tooling.