test(relay): cross-package e2e adversarial security harness
New e2e/ package wires the REAL P5(auth)+P4(crypto)+P2(agent) exports through in-memory seams and an untrusted-relay attacker vantage (RelaySpy). Dynamically re-validates F1–F4/F6 plus MITM/reflection/replay/reorder/INV2/cross-tenant/ single-use — every assertion runs the production code path. 21 tests green, tsc clean. node_modules via symlinks (gitignored); no npm install needed.
This commit is contained in:
141
e2e/harness/dpop.ts
Normal file
141
e2e/harness/dpop.ts
Normal file
@@ -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<P5Key> {
|
||||||
|
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<Ephemeral> {
|
||||||
|
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<DpopContext>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function issueCapBundle(signingKey: CryptoKey, o: IssueOpts): Promise<CapBundle> {
|
||||||
|
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<DpopContext> => ({
|
||||||
|
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' } }
|
||||||
|
}
|
||||||
132
e2e/harness/fakes.ts
Normal file
132
e2e/harness/fakes.ts
Normal file
@@ -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> = {},
|
||||||
|
): 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<string>
|
||||||
|
readonly consumed: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fakeRevocationStore(): RevocationFake {
|
||||||
|
const revoked = new Set<string>()
|
||||||
|
const consumed = new Set<string>()
|
||||||
|
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<string>
|
||||||
|
readonly calls: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Token bucket that always allows unless a key is added to `blocked`. */
|
||||||
|
export function fakeTokenBucket(): TokenBucketFake {
|
||||||
|
const blocked = new Set<string>()
|
||||||
|
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) }
|
||||||
|
}
|
||||||
34
e2e/harness/spy.ts
Normal file
34
e2e/harness/spy.ts
Normal file
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
332
e2e/harness/world.ts
Normal file
332
e2e/harness/world.ts
Normal file
@@ -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<HostFixture> {
|
||||||
|
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<typeof principal>[1]): ReturnType<typeof principal>
|
||||||
|
setStepUpPolicy(policy: StepUpPolicy): void
|
||||||
|
issueCap(o: {
|
||||||
|
accountId: string
|
||||||
|
host: string
|
||||||
|
aud: string
|
||||||
|
rights?: readonly UpgradeContext['requiredRight'][]
|
||||||
|
ttl?: number
|
||||||
|
now?: number
|
||||||
|
}): Promise<CapBundle>
|
||||||
|
craftMalformedDpop(o: {
|
||||||
|
accountId: string
|
||||||
|
host: string
|
||||||
|
aud: string
|
||||||
|
now?: number
|
||||||
|
blobLen?: number
|
||||||
|
}): Promise<{ raw: string; dpop: UpgradeContext['dpop'] }>
|
||||||
|
upgrade(a: UpgradeArgs): Promise<AuthzOutcome>
|
||||||
|
reattach(a: ReattachArgs): Promise<AuthzOutcome>
|
||||||
|
addSession(e: { sessionId: string; hostId: string; accountId: string }): void
|
||||||
|
establishSession(opts?: HandshakeOpts): Promise<EstablishedSessions>
|
||||||
|
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<void>
|
||||||
|
revoke(scope: RevocationScope): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildRelayWorld(now: number = DEFAULT_NOW): Promise<RelayWorld> {
|
||||||
|
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<EstablishedSessions> {
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
12
e2e/package.json
Normal file
12
e2e/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
226
e2e/tests/adversarial-auth.test.ts
Normal file
226
e2e/tests/adversarial-auth.test.ts
Normal file
@@ -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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
115
e2e/tests/adversarial-transport.test.ts
Normal file
115
e2e/tests/adversarial-transport.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
99
e2e/tests/flow.test.ts
Normal file
99
e2e/tests/flow.test.ts
Normal file
@@ -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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
21
e2e/tests/smoke.test.ts
Normal file
21
e2e/tests/smoke.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
15
e2e/tsconfig.json
Normal file
15
e2e/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
8
e2e/vitest.config.ts
Normal file
8
e2e/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user