/** * B6 (Phase-1 STAGING) — browser-side DPoP proof-of-possession for the §4.3 capability-token WS * upgrade. * * The operator's browser mints an EPHEMERAL Ed25519 keypair (WebCrypto), computes its RFC 7638/8037 * JWK thumbprint `jkt`, and binds the minted capability token to it (`cnf.jkt`) at `POST /auth/mint`. * On connect it signs a DPoP proof over (htu, htm, jti, iat) with the SAME private key; the relay * recomputes the thumbprint from the proof's embedded JWK and requires it to equal the token's * `cnf.jkt` (relay-auth `verifyDpopProof`). The private key NEVER leaves the page and is NEVER * logged (INV9); it is generated per successful login and thrown away when the tab closes. * * Wire format is byte-for-byte identical to relay-auth's `buildDpopProof` / `jwkThumbprint` * (cross-validated against relay-auth `verifyDpopProof`): header * `{typ:'dpop+ed25519', jwk:{crv:'Ed25519', kty:'OKP', x}}`, payload `{htu, htm, jti, iat}`, * proof = `b64u(header).b64u(payload).b64u(Ed25519sig("h.p"))`; jkt = `b64u(SHA-256(canonical JWK))` * with members in the REQUIRED lexicographic order `crv,kty,x` and no whitespace. base64url comes * from relay-contracts (the shared, isomorphic helper — never hand-rolled here). * * DELIVERY (Phase-1 gap): the browser's native WebSocket API cannot set request headers, but the * relay reads the DPoP proof from the `dpop` request header (relay-run browser-server.ts). Mirroring * the §4.3 token (which rides `Sec-WebSocket-Protocol` for exactly this reason), the proof is offered * as an ADDITIONAL subprotocol entry (`term.dpop.`) via {@link encodeDpopSubprotocol}. * The current relay ignores unknown subprotocol entries (its `handleProtocols` echoes only * `APP_SUBPROTOCOL`), so this is handshake-safe today; a real browser connect is nonetheless denied * at DPoP until the relay is taught to read the proof from this entry (a relay/server-lane change, * validated on the VPS). See the returned notes in the B6 log entry. */ import { encodeBase64UrlBytes, encodeBase64UrlString, decodeBase64UrlString } from 'relay-contracts' import { RelayWebError } from './errors' /** DPoP HTTP method bound into every proof — a WS upgrade is a GET (mirrors relay-run `DPOP_HTM`). */ export const DPOP_HTM = 'GET' as const /** Subprotocol entry prefix carrying the DPoP proof on the upgrade (browsers can't set headers). */ export const DPOP_SUBPROTOCOL_PREFIX = 'term.dpop.' as const /** * Canonical DPoP `htu` for an audience — mirrors relay-run `htuFor` (the SINGLE definition both * sides use so issuance and verification never drift). `aud` is the tenant subdomain the token is * bound to; the relay re-derives the identical string from its own resolved authority. */ export function htuFor(aud: string): string { return `https://${aud}/ws` } /** A base64url SHA-256 JWK thumbprint is exactly 43 chars — matches the server's `JKT_RE`. */ const JKT_LENGTH = 43 export interface DpopKey { /** RFC 7638 JWK thumbprint (base64url SHA-256, 43 chars) — the `cnf.jkt` binding sent to /auth/mint. */ readonly jkt: string /** Sign a FRESH DPoP proof JWS for one upgrade (unique jti; `iat = nowSec`). Never logs the key. */ proof(htu: string, htm: string, nowSec: number): Promise } /** DI seams so tests run deterministically (inject Node webcrypto under jsdom + a fixed jti). */ export interface DpopDeps { /** SubtleCrypto to use; defaults to the page's `globalThis.crypto.subtle`. */ readonly subtle?: SubtleCrypto /** DPoP `jti` generator; defaults to `crypto.randomUUID()`. */ readonly randomJti?: () => string } const ED25519 = { name: 'Ed25519' } as const function defaultJti(): string { const c = globalThis.crypto if (!c || typeof c.randomUUID !== 'function') { throw new RelayWebError('crypto.randomUUID is unavailable — cannot mint a DPoP jti') } return c.randomUUID() } /** Canonical Ed25519 public JWK JSON (member order crv, kty, x) → base64url(SHA-256) thumbprint. */ async function computeJkt(subtle: SubtleCrypto, x: string): Promise { const json = JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x }) const digest = new Uint8Array(await subtle.digest('SHA-256', new TextEncoder().encode(json))) return encodeBase64UrlBytes(digest) } /** * Generate an ephemeral browser DPoP key and expose its `jkt` + a per-upgrade proof signer. * Fails fast (typed {@link RelayWebError}) when WebCrypto/Ed25519 is unavailable. */ export async function createDpopKey(deps: DpopDeps = {}): Promise { const subtle = deps.subtle ?? globalThis.crypto?.subtle if (!subtle) { throw new RelayWebError('WebCrypto SubtleCrypto is unavailable — DPoP requires Ed25519') } const randomJti = deps.randomJti ?? defaultJti let pair: CryptoKeyPair let rawPub: Uint8Array try { pair = (await subtle.generateKey(ED25519, true, ['sign', 'verify'])) as CryptoKeyPair rawPub = new Uint8Array(await subtle.exportKey('raw', pair.publicKey)) } catch { // Never surface key material in the error (INV9). throw new RelayWebError('failed to generate an Ed25519 DPoP key (unsupported by this browser?)') } const x = encodeBase64UrlBytes(rawPub) const jkt = await computeJkt(subtle, x) if (jkt.length !== JKT_LENGTH) { throw new RelayWebError(`unexpected DPoP thumbprint length ${jkt.length} (expected ${JKT_LENGTH})`) } const proof = async (htu: string, htm: string, nowSec: number): Promise => { const header = { typ: 'dpop+ed25519', jwk: { crv: 'Ed25519', kty: 'OKP', x } } const payload = { htu, htm, jti: randomJti(), iat: nowSec } const h = encodeBase64UrlString(JSON.stringify(header)) const p = encodeBase64UrlString(JSON.stringify(payload)) const sig = new Uint8Array( await subtle.sign(ED25519, pair.privateKey, new TextEncoder().encode(`${h}.${p}`)), ) return `${h}.${p}.${encodeBase64UrlBytes(sig)}` } return { jkt, proof } } /** Build the DPoP subprotocol entry: `term.dpop.` + base64url(proofJws). */ export function encodeDpopSubprotocol(proofJws: string): string { return DPOP_SUBPROTOCOL_PREFIX + encodeBase64UrlString(proofJws) } /** * Extract the DPoP proof from a subprotocol list (the future relay-side inverse of * {@link encodeDpopSubprotocol}). Returns null when absent. Exposed for round-trip tests and to * document the exact wire contract the relay must read once it consumes the proof from the handshake. */ export function extractDpopFromSubprotocols(values: readonly string[]): string | null { const entry = values.find((v) => v.startsWith(DPOP_SUBPROTOCOL_PREFIX)) if (entry === undefined) return null return decodeBase64UrlString(entry.slice(DPOP_SUBPROTOCOL_PREFIX.length)) }