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' } }
|
||||
}
|
||||
Reference in New Issue
Block a user