RELAY-PHASE1 Wave A2/B/C/D/E (12-agent workflow, all tsc-clean, 314/314 tests pass): - A2: control-plane server.ts entry + boot/redis.ts revocation-bus wiring + start script. - B1: relay-run shared-store EnforceDeps (relay-auth ports over the SAME Postgres+Redis as P3). - B2: registry-backed MtlsVerifier (verifyAgentCert, fail-closed, INV14). - B3: store-backed RouteResolver (subdomain->hostId). - B4: Redis relay:revocations subscriber -> tunnel teardown (INV12). - B5: main-phase1.ts production entry (public bind, real TLS, async-mTLS prefetch bridge) + staging /auth/mint. - B6 (PARTIAL): relay-web operator login + browser DPoP; proof offered via term.dpop.<b64u> subprotocol. - C: agent dist/cli.js build (esbuild) + runTunnel run-loop + CliDeps. - D1: same-origin static serve of relay-web/public from the browser WSS. - E: systemd units + gen-ca/gen-capability-key/issue-tls-cert scripts + deploy/RUNBOOK.md. Adversarial review: all hard invariants PASS. Follow-ups (B7): close DPoP-subprotocol read on browser-server (blocks browser connect); rate-limit /auth/mint (F1); wire activeSessionCount (F2); scrub error logs (F5). Excludes unrelated public/style.css (concurrent iOS job).
137 lines
6.6 KiB
TypeScript
137 lines
6.6 KiB
TypeScript
/**
|
|
* 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.<b64url(proofJws)>`) 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<string>
|
|
}
|
|
|
|
/** 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<string> {
|
|
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<DpopKey> {
|
|
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<string> => {
|
|
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))
|
|
}
|