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:
Yaojia Wang
2026-07-02 16:41:19 +02:00
parent 3020184054
commit ad5cf06207
11 changed files with 1135 additions and 0 deletions

141
e2e/harness/dpop.ts Normal file
View 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' } }
}