/** * 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' } } }