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.
333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
/**
|
|
* 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)
|
|
},
|
|
}
|
|
}
|