feat(relay): Phase1 waves A2-E — server entry, shared-store data plane, agent runtime, deploy artifacts
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).
This commit is contained in:
136
relay-web/src/dpop.ts
Normal file
136
relay-web/src/dpop.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
@@ -1,11 +1,37 @@
|
||||
/**
|
||||
* index.html entry — v0.8 password gate → terminal view over the passthrough transport.
|
||||
* No inline script (strict-CSP friendly): this bundle is loaded via <script type="module">.
|
||||
* index.html entry — Phase-1 STAGING operator login → terminal view.
|
||||
*
|
||||
* The deployed Phase-1 relay serves this bundle and enables `POST /auth/mint` (B5). Flow:
|
||||
* password → mint a §4.3 capability token bound to a fresh browser DPoP key → open the WS carrying
|
||||
* the token (+ a DPoP proof signed by the SAME key) via the subprotocol → drive the terminal view.
|
||||
* No inline script (strict-CSP friendly): loaded via <script type="module">.
|
||||
*/
|
||||
import { readConfig } from '../config'
|
||||
import { mountPasswordLogin } from '../login-password'
|
||||
import { readConfig, type RelayWebConfig } from '../config'
|
||||
import { mountStagingLogin, type StagingSession } from '../login-staging'
|
||||
import { createPassthroughTransport } from '../ws-transport'
|
||||
import { mountTerminalView } from '../terminal-view'
|
||||
import { DPOP_HTM, htuFor } from '../dpop'
|
||||
|
||||
async function connect(
|
||||
cfg: RelayWebConfig,
|
||||
session: StagingSession,
|
||||
loginRoot: HTMLElement,
|
||||
termRoot: HTMLElement,
|
||||
): Promise<void> {
|
||||
// Sign a fresh DPoP proof for THIS upgrade with the key the token binds (`cnf.jkt`).
|
||||
const nowSec = Math.floor(Date.now() / 1000)
|
||||
const dpopProof = await session.dpop.proof(htuFor(session.aud), DPOP_HTM, nowSec)
|
||||
|
||||
const transport = createPassthroughTransport(cfg, {
|
||||
capabilityToken: session.token,
|
||||
dpopProof,
|
||||
})
|
||||
// Reveal the terminal only after the socket is open; on failure the login screen stays visible.
|
||||
await transport.open()
|
||||
loginRoot.hidden = true
|
||||
termRoot.hidden = false
|
||||
mountTerminalView(termRoot, transport)
|
||||
}
|
||||
|
||||
function boot(): void {
|
||||
const cfg = readConfig(window.location)
|
||||
@@ -13,12 +39,13 @@ function boot(): void {
|
||||
const termRoot = document.getElementById('terminal')
|
||||
if (!loginRoot || !termRoot) return
|
||||
|
||||
mountPasswordLogin(loginRoot, cfg, {
|
||||
onSuccess: () => {
|
||||
loginRoot.hidden = true
|
||||
termRoot.hidden = false
|
||||
const transport = createPassthroughTransport(cfg)
|
||||
void transport.open().then(() => mountTerminalView(termRoot, transport))
|
||||
mountStagingLogin(loginRoot, cfg, {
|
||||
onSuccess: (session) => {
|
||||
void connect(cfg, session, loginRoot, termRoot).catch(() => {
|
||||
// Connection denied/dropped — keep the operator on the login screen (no bearer in logs).
|
||||
loginRoot.hidden = false
|
||||
termRoot.hidden = true
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,24 @@ export * from './api-schemas'
|
||||
// v0.8 password gate (T3)
|
||||
export { mountPasswordLogin, type PasswordLogin } from './login-password'
|
||||
|
||||
// Phase-1 STAGING operator login (mint → §4.3 token) + browser DPoP PoP (B6)
|
||||
export {
|
||||
mountStagingLogin,
|
||||
type StagingLogin,
|
||||
type StagingLoginDeps,
|
||||
type StagingSession,
|
||||
} from './login-staging'
|
||||
export {
|
||||
createDpopKey,
|
||||
encodeDpopSubprotocol,
|
||||
extractDpopFromSubprotocols,
|
||||
htuFor,
|
||||
DPOP_HTM,
|
||||
DPOP_SUBPROTOCOL_PREFIX,
|
||||
type DpopKey,
|
||||
type DpopDeps,
|
||||
} from './dpop'
|
||||
|
||||
// Terminal view + transport seam (T4)
|
||||
export {
|
||||
createPassthroughTransport,
|
||||
|
||||
142
relay-web/src/login-staging.ts
Normal file
142
relay-web/src/login-staging.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* B6 (Phase-1 STAGING) — operator password gate → `POST /auth/mint` → §4.3 capability token.
|
||||
*
|
||||
* This is the STAGING login the deployed Phase-1 relay actually serves (`main-phase1.ts` enables
|
||||
* `POST /auth/mint`, B5/auth-mint.ts). It is a deliberate shortcut: a single shared operator
|
||||
* password (constant-time compared server-side) stands in for the full WebAuthn stack (Phase 2 →
|
||||
* T7 `login-passkey.ts`). The flow, per mint:
|
||||
*
|
||||
* 1. mint an EPHEMERAL browser DPoP key (WebCrypto Ed25519) and take its `jkt` thumbprint;
|
||||
* 2. `POST /auth/mint {password, jkt, subdomain}` (subdomain comes from the same-origin `location`,
|
||||
* never a caller-supplied accountId — INV3); on 200 receive `{token}` bound to that `jkt`;
|
||||
* 3. hand the caller `{token, aud, dpop}` so it can open the WS carrying the token (+ a DPoP proof
|
||||
* signed by the SAME key) — see entry/index-page.ts.
|
||||
*
|
||||
* Security (mirrors T3 login-password.ts):
|
||||
* - the password is NEVER stored in localStorage and NEVER logged; all error text is set via
|
||||
* `textContent` (never `innerHTML`); empty input disables submit (fail-fast at the boundary);
|
||||
* - the request body carries only `{password, jkt, subdomain}` — a NAME + the client's own DPoP
|
||||
* thumbprint, never an accountId/hostId (INV3, deny-by-default; the CP resolves identity);
|
||||
* - the `{token}` response is Zod-validated before use (never a torn object).
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import type { RelayWebConfig } from './config'
|
||||
import { createDpopKey, type DpopKey } from './dpop'
|
||||
|
||||
/** Same-origin staging mint endpoint (B5 auth-mint.ts owns `POST /auth/mint`). */
|
||||
const MINT_PATH = '/auth/mint'
|
||||
|
||||
/** The `{token}` response shape (lenient: ignore any additive fields, require a non-empty token). */
|
||||
const MintResponseSchema = z.object({ token: z.string().min(1) })
|
||||
|
||||
/** What a successful staging login yields — everything the connect step needs, nothing more. */
|
||||
export interface StagingSession {
|
||||
/** §4.3 capability token (a bearer secret — pass to the transport, NEVER log or persist it). */
|
||||
readonly token: string
|
||||
/** Tenant subdomain the token's `aud` is bound to; used to derive the DPoP `htu` (`htuFor`). */
|
||||
readonly aud: string
|
||||
/** The SAME ephemeral key whose `jkt` the token binds — signs the connect DPoP proof. */
|
||||
readonly dpop: DpopKey
|
||||
}
|
||||
|
||||
export interface StagingLogin {
|
||||
/** Attempt a mint with `password`. 'ok' fires `onSuccess`; 'rejected' surfaces an error in the UI. */
|
||||
submit(password: string): Promise<'ok' | 'rejected'>
|
||||
}
|
||||
|
||||
export interface StagingLoginDeps {
|
||||
readonly fetchImpl?: typeof fetch
|
||||
/** Invoked once per successful mint so the shell wrapper can open the terminal connection. */
|
||||
readonly onSuccess?: (session: StagingSession) => void
|
||||
/** DI seam: the DPoP key factory (defaults to WebCrypto); tests inject a deterministic fake. */
|
||||
readonly createKey?: () => Promise<DpopKey>
|
||||
}
|
||||
|
||||
export function mountStagingLogin(
|
||||
root: HTMLElement,
|
||||
cfg: RelayWebConfig,
|
||||
deps: StagingLoginDeps = {},
|
||||
): StagingLogin {
|
||||
const fetchImpl = deps.fetchImpl ?? fetch
|
||||
const createKey = deps.createKey ?? (() => createDpopKey())
|
||||
|
||||
const form = document.createElement('form')
|
||||
form.className = 'login-form'
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.type = 'password'
|
||||
input.autocomplete = 'current-password'
|
||||
input.placeholder = 'Operator password'
|
||||
input.className = 'login-input'
|
||||
|
||||
const button = document.createElement('button')
|
||||
button.type = 'submit'
|
||||
button.textContent = 'Connect'
|
||||
button.disabled = true // empty input → disabled (fail-fast)
|
||||
|
||||
const error = document.createElement('p')
|
||||
error.className = 'login-error'
|
||||
error.setAttribute('role', 'alert')
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
button.disabled = input.value.length === 0
|
||||
error.textContent = '' // clear stale error on edit
|
||||
})
|
||||
|
||||
async function submit(password: string): Promise<'ok' | 'rejected'> {
|
||||
if (password.length === 0) return 'rejected'
|
||||
|
||||
let dpop: DpopKey
|
||||
try {
|
||||
dpop = await createKey()
|
||||
} catch {
|
||||
error.textContent = 'Secure crypto is unavailable in this browser.'
|
||||
return 'rejected'
|
||||
}
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetchImpl(`${cfg.apiBase}${MINT_PATH}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
// INV3: only a NAME (subdomain) + the client's own DPoP thumbprint — never an accountId.
|
||||
body: JSON.stringify({ password, jkt: dpop.jkt, subdomain: cfg.subdomain }),
|
||||
})
|
||||
} catch {
|
||||
error.textContent = 'Network error — please retry.'
|
||||
return 'rejected'
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
error.textContent = res.status === 401 ? 'Invalid operator password.' : 'Sign-in failed.'
|
||||
return 'rejected'
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await res.json()
|
||||
} catch {
|
||||
error.textContent = 'Malformed response from the relay.'
|
||||
return 'rejected'
|
||||
}
|
||||
const parsed = MintResponseSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
error.textContent = 'Malformed response from the relay.'
|
||||
return 'rejected'
|
||||
}
|
||||
|
||||
error.textContent = ''
|
||||
deps.onSuccess?.({ token: parsed.data.token, aud: cfg.subdomain, dpop })
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (ev) => {
|
||||
ev.preventDefault()
|
||||
void submit(input.value)
|
||||
})
|
||||
|
||||
form.append(input, button, error)
|
||||
root.append(form)
|
||||
|
||||
return { submit }
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
|
||||
import type { RelayWebConfig } from './config'
|
||||
import { encodeDpopSubprotocol } from './dpop'
|
||||
|
||||
/** The seam every transport implements — plaintext in the browser, encoded by the impl. */
|
||||
export interface TerminalTransport {
|
||||
@@ -41,6 +42,14 @@ export type WebSocketCtor = new (url: string, protocols?: string | readonly stri
|
||||
export interface PassthroughOpts {
|
||||
/** v0.9+ populates this; ABSENT in v0.8 (cookie-only auth). */
|
||||
readonly capabilityToken?: string
|
||||
/**
|
||||
* Phase-1 STAGING (B6): a DPoP proof-of-possession JWS bound to the token's `cnf.jkt`. Browsers
|
||||
* can't set the `dpop` request header on a native WS upgrade, so — mirroring the §4.3 token — it
|
||||
* rides an extra `term.dpop.<b64u>` subprotocol entry (see dpop.ts `encodeDpopSubprotocol`). The
|
||||
* current relay ignores it (its `handleProtocols` echoes only `APP_SUBPROTOCOL`), so the handshake
|
||||
* is unaffected; true DPoP enforcement needs the relay to read this entry (server-lane, VPS-gated).
|
||||
*/
|
||||
readonly dpopProof?: string
|
||||
/** DI seam: inject a mock WebSocket constructor in tests; defaults to global `WebSocket`. */
|
||||
readonly wsCtor?: WebSocketCtor
|
||||
}
|
||||
@@ -71,15 +80,21 @@ export function createPassthroughTransport(
|
||||
): TerminalTransport {
|
||||
const Ctor: WebSocketCtor = opts.wsCtor ?? (globalThis.WebSocket as unknown as WebSocketCtor)
|
||||
const token = opts.capabilityToken
|
||||
const dpopProof = opts.dpopProof
|
||||
const hasBearer = token !== undefined || dpopProof !== undefined
|
||||
const url = cfg.wsUrl('/term') // SAME-ORIGIN, scheme-following; NEVER carries a token (T4 §)
|
||||
|
||||
let ws: WebSocketLike | null = null
|
||||
let messageCb: ((bytes: Uint8Array) => void) | null = null
|
||||
let closeCb: ((reason: string) => void) | null = null
|
||||
|
||||
// App subprotocol FIRST; token entry (v0.9+) second — the frozen §4.3 order.
|
||||
const protocols: readonly string[] =
|
||||
token === undefined ? [APP_SUBPROTOCOL] : [APP_SUBPROTOCOL, encodeTokenSubprotocol(token)]
|
||||
// App subprotocol FIRST; token entry (v0.9+) second; DPoP proof entry (Phase-1 B6) last — a
|
||||
// bearer NEVER touches the URL/query (would leak it to proxy/access logs, history, and Referer).
|
||||
const protocols: readonly string[] = [
|
||||
APP_SUBPROTOCOL,
|
||||
...(token === undefined ? [] : [encodeTokenSubprotocol(token)]),
|
||||
...(dpopProof === undefined ? [] : [encodeDpopSubprotocol(dpopProof)]),
|
||||
]
|
||||
|
||||
function open(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -88,9 +103,10 @@ export function createPassthroughTransport(
|
||||
ws = socket
|
||||
|
||||
socket.onopen = () => {
|
||||
// Echo rule (v0.9+): with a token attached, accept ONLY the app subprotocol back — a relay
|
||||
// echoing the token entry, an empty value, or a foreign one is rejected (bearer-leak guard).
|
||||
if (token !== undefined && socket.protocol !== APP_SUBPROTOCOL) {
|
||||
// Echo rule (v0.9+): with a bearer attached (token and/or DPoP), accept ONLY the app
|
||||
// subprotocol back — a relay echoing a bearer entry, an empty value, or a foreign one is
|
||||
// rejected (bearer-leak guard).
|
||||
if (hasBearer && socket.protocol !== APP_SUBPROTOCOL) {
|
||||
socket.close(4400, 'bad-subprotocol')
|
||||
reject(new Error(`unexpected echoed subprotocol: '${socket.protocol}'`))
|
||||
return
|
||||
|
||||
106
relay-web/test/dpop.test.ts
Normal file
106
relay-web/test/dpop.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import { decodeBase64UrlBytes, decodeBase64UrlString } from 'relay-contracts'
|
||||
import {
|
||||
createDpopKey,
|
||||
encodeDpopSubprotocol,
|
||||
extractDpopFromSubprotocols,
|
||||
htuFor,
|
||||
DPOP_HTM,
|
||||
DPOP_SUBPROTOCOL_PREFIX,
|
||||
} from '../src/dpop'
|
||||
|
||||
// jsdom's global crypto lacks a full SubtleCrypto/Ed25519; inject Node's webcrypto (DI seam).
|
||||
const subtle = webcrypto.subtle as unknown as SubtleCrypto
|
||||
/** Copy into a fresh ArrayBuffer-backed view (WebCrypto's BufferSource wants Uint8Array<ArrayBuffer>). */
|
||||
const ab = (u: Uint8Array): Uint8Array<ArrayBuffer> => {
|
||||
const out = new Uint8Array(u.byteLength)
|
||||
out.set(u)
|
||||
return out
|
||||
}
|
||||
const utf8 = (s: string): Uint8Array<ArrayBuffer> => ab(new TextEncoder().encode(s))
|
||||
const bytes = (b64u: string): Uint8Array<ArrayBuffer> => ab(decodeBase64UrlBytes(b64u))
|
||||
const decodeJson = (b64u: string): unknown =>
|
||||
JSON.parse(new TextDecoder().decode(decodeBase64UrlBytes(b64u)))
|
||||
|
||||
describe('createDpopKey (B6) — browser DPoP proof-of-possession', () => {
|
||||
it('produces a 43-char base64url jkt (matches the server JKT_RE)', async () => {
|
||||
const key = await createDpopKey({ subtle })
|
||||
expect(key.jkt).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
})
|
||||
|
||||
it("jkt equals base64url(SHA-256(canonical JWK)) with member order crv,kty,x", async () => {
|
||||
let jti = 0
|
||||
const key = await createDpopKey({ subtle, randomJti: () => `jti-${jti++}` })
|
||||
const proof = await key.proof('https://alice/ws', DPOP_HTM, 1000)
|
||||
const [h] = proof.split('.') as [string, string, string]
|
||||
const header = decodeJson(h) as { jwk: { crv: string; kty: string; x: string } }
|
||||
// Recompute the thumbprint from the proof's embedded JWK exactly as relay-auth does.
|
||||
const canonical = JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x: header.jwk.x })
|
||||
const digest = new Uint8Array(await subtle.digest('SHA-256', utf8(canonical)))
|
||||
const recomputed = Buffer.from(digest).toString('base64url')
|
||||
expect(recomputed).toBe(key.jkt)
|
||||
})
|
||||
|
||||
it('builds a 3-part DPoP JWS whose header/payload match relay-auth buildDpopProof', async () => {
|
||||
const key = await createDpopKey({ subtle, randomJti: () => 'fixed-jti' })
|
||||
const proof = await key.proof('https://alice/ws', 'GET', 1234)
|
||||
const parts = proof.split('.')
|
||||
expect(parts).toHaveLength(3)
|
||||
const [h, p] = parts as [string, string, string]
|
||||
const header = decodeJson(h) as { typ: string; jwk: { crv: string; kty: string; x: string } }
|
||||
const payload = decodeJson(p) as Record<string, unknown>
|
||||
expect(header.typ).toBe('dpop+ed25519')
|
||||
expect(header.jwk.kty).toBe('OKP')
|
||||
expect(header.jwk.crv).toBe('Ed25519')
|
||||
expect(payload).toEqual({ htu: 'https://alice/ws', htm: 'GET', jti: 'fixed-jti', iat: 1234 })
|
||||
})
|
||||
|
||||
it('the DPoP signature verifies under the embedded public JWK (self-consistent proof)', async () => {
|
||||
const key = await createDpopKey({ subtle })
|
||||
const proof = await key.proof('https://alice/ws', 'GET', 2000)
|
||||
const [h, p, s] = proof.split('.') as [string, string, string]
|
||||
const header = decodeJson(h) as { jwk: { x: string } }
|
||||
const pub = await subtle.importKey('raw', bytes(header.jwk.x), { name: 'Ed25519' }, true, ['verify'])
|
||||
const ok = await subtle.verify({ name: 'Ed25519' }, pub, bytes(s), utf8(`${h}.${p}`))
|
||||
expect(ok).toBe(true)
|
||||
})
|
||||
|
||||
it('mints a FRESH jti per proof (no DPoP replay across upgrades)', async () => {
|
||||
const key = await createDpopKey({ subtle })
|
||||
const a = await key.proof('https://alice/ws', 'GET', 3000)
|
||||
const b = await key.proof('https://alice/ws', 'GET', 3000)
|
||||
const jtiOf = (proof: string): unknown =>
|
||||
(decodeJson(proof.split('.')[1] as string) as { jti: unknown }).jti
|
||||
expect(jtiOf(a)).not.toEqual(jtiOf(b))
|
||||
})
|
||||
|
||||
it('fails fast (typed RelayWebError, no key material leaked) when Ed25519 keygen is unsupported', async () => {
|
||||
const brokenSubtle = {
|
||||
generateKey: async () => {
|
||||
throw new Error('Ed25519 not supported')
|
||||
},
|
||||
} as unknown as SubtleCrypto
|
||||
await expect(createDpopKey({ subtle: brokenSubtle })).rejects.toThrow(
|
||||
/failed to generate an Ed25519 DPoP key/,
|
||||
)
|
||||
})
|
||||
|
||||
it('htuFor mirrors relay-run: https://<aud>/ws', () => {
|
||||
expect(htuFor('alice')).toBe('https://alice/ws')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DPoP subprotocol carrier (encode/extract round-trip)', () => {
|
||||
it('encodes term.dpop.<base64url(proofJws)> and extracts it back verbatim', () => {
|
||||
const proof = 'aGVhZGVy.cGF5bG9hZA.c2ln'
|
||||
const entry = encodeDpopSubprotocol(proof)
|
||||
expect(entry.startsWith(DPOP_SUBPROTOCOL_PREFIX)).toBe(true)
|
||||
expect(decodeBase64UrlString(entry.slice(DPOP_SUBPROTOCOL_PREFIX.length))).toBe(proof)
|
||||
expect(extractDpopFromSubprotocols(['term.relay.v1', entry])).toBe(proof)
|
||||
})
|
||||
|
||||
it('extract returns null when no DPoP entry is present', () => {
|
||||
expect(extractDpopFromSubprotocols(['term.relay.v1', 'term.token.QUJD'])).toBeNull()
|
||||
})
|
||||
})
|
||||
126
relay-web/test/login-staging.test.ts
Normal file
126
relay-web/test/login-staging.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readConfig } from '../src/config'
|
||||
import { mountStagingLogin } from '../src/login-staging'
|
||||
import type { DpopKey } from '../src/dpop'
|
||||
|
||||
const cfg = readConfig({
|
||||
protocol: 'https:',
|
||||
host: 'alice.term.example.com',
|
||||
hostname: 'alice.term.example.com',
|
||||
})
|
||||
|
||||
/** Deterministic DPoP key so the login test never touches WebCrypto. */
|
||||
const fakeKey = (jkt: string): DpopKey => ({ jkt, proof: async () => 'h.p.s' })
|
||||
const FAKE_JKT = 'A'.repeat(43)
|
||||
|
||||
function mintFetch(body: unknown, ok = true, status = 200): typeof fetch {
|
||||
return vi.fn(async () => ({ ok, status, json: async () => body }) as Response) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
describe('mountStagingLogin (B6) — operator password → POST /auth/mint', () => {
|
||||
let root: HTMLElement
|
||||
beforeEach(() => {
|
||||
root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
})
|
||||
|
||||
it('correct password → "ok" and POSTs {password, jkt, subdomain} (INV3: a NAME, not an accountId)', async () => {
|
||||
const fetchImpl = mintFetch({ token: 'CAPTOKEN' })
|
||||
const onSuccess = vi.fn()
|
||||
const login = mountStagingLogin(root, cfg, {
|
||||
fetchImpl,
|
||||
onSuccess,
|
||||
createKey: async () => fakeKey(FAKE_JKT),
|
||||
})
|
||||
|
||||
await expect(login.submit('s3cret')).resolves.toBe('ok')
|
||||
|
||||
const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]!
|
||||
expect(call[0]).toBe('/auth/mint')
|
||||
const init = call[1] as RequestInit
|
||||
expect(init.method).toBe('POST')
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
password: 's3cret',
|
||||
jkt: FAKE_JKT,
|
||||
subdomain: 'alice',
|
||||
})
|
||||
// onSuccess receives the token + aud + the SAME dpop key whose jkt was minted.
|
||||
const session = onSuccess.mock.calls[0]![0]
|
||||
expect(session.token).toBe('CAPTOKEN')
|
||||
expect(session.aud).toBe('alice')
|
||||
expect(session.dpop.jkt).toBe(FAKE_JKT)
|
||||
})
|
||||
|
||||
it('wrong password (401) → "rejected", no onSuccess, error via textContent (no innerHTML)', async () => {
|
||||
const onSuccess = vi.fn()
|
||||
const login = mountStagingLogin(root, cfg, {
|
||||
fetchImpl: mintFetch({}, false, 401),
|
||||
onSuccess,
|
||||
createKey: async () => fakeKey(FAKE_JKT),
|
||||
})
|
||||
await expect(login.submit('nope')).resolves.toBe('rejected')
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
const err = root.querySelector('.login-error') as HTMLElement
|
||||
expect(err.textContent).toBe('Invalid operator password.')
|
||||
expect(err.innerHTML).toBe('Invalid operator password.') // textContent-set, no markup
|
||||
})
|
||||
|
||||
it('empty input keeps submit disabled and submit("") rejects without a fetch or a key', async () => {
|
||||
const fetchImpl = mintFetch({ token: 'X' })
|
||||
const createKey = vi.fn(async () => fakeKey(FAKE_JKT))
|
||||
const login = mountStagingLogin(root, cfg, { fetchImpl, createKey })
|
||||
expect((root.querySelector('button') as HTMLButtonElement).disabled).toBe(true)
|
||||
await expect(login.submit('')).resolves.toBe('rejected')
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
expect(createKey).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('network error → "rejected" with a retry message', async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error('offline')
|
||||
}) as unknown as typeof fetch
|
||||
const login = mountStagingLogin(root, cfg, { fetchImpl, createKey: async () => fakeKey(FAKE_JKT) })
|
||||
await expect(login.submit('s3cret')).resolves.toBe('rejected')
|
||||
expect((root.querySelector('.login-error') as HTMLElement).textContent).toBe(
|
||||
'Network error — please retry.',
|
||||
)
|
||||
})
|
||||
|
||||
it('malformed mint response (no token) → "rejected", no onSuccess', async () => {
|
||||
const onSuccess = vi.fn()
|
||||
const login = mountStagingLogin(root, cfg, {
|
||||
fetchImpl: mintFetch({ nope: true }),
|
||||
onSuccess,
|
||||
createKey: async () => fakeKey(FAKE_JKT),
|
||||
})
|
||||
await expect(login.submit('s3cret')).resolves.toBe('rejected')
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
expect((root.querySelector('.login-error') as HTMLElement).textContent).toBe(
|
||||
'Malformed response from the relay.',
|
||||
)
|
||||
})
|
||||
|
||||
it('crypto failure (key gen throws) → "rejected", no fetch issued', async () => {
|
||||
const fetchImpl = mintFetch({ token: 'X' })
|
||||
const login = mountStagingLogin(root, cfg, {
|
||||
fetchImpl,
|
||||
createKey: async () => {
|
||||
throw new Error('no webcrypto')
|
||||
},
|
||||
})
|
||||
await expect(login.submit('s3cret')).resolves.toBe('rejected')
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
expect((root.querySelector('.login-error') as HTMLElement).textContent).toBe(
|
||||
'Secure crypto is unavailable in this browser.',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not persist the password to localStorage', async () => {
|
||||
const login = mountStagingLogin(root, cfg, {
|
||||
fetchImpl: mintFetch({ token: 'X' }),
|
||||
createKey: async () => fakeKey(FAKE_JKT),
|
||||
})
|
||||
await login.submit('s3cret')
|
||||
expect(JSON.stringify(localStorage)).not.toContain('s3cret')
|
||||
})
|
||||
})
|
||||
81
relay-web/test/ws-transport-dpop.test.ts
Normal file
81
relay-web/test/ws-transport-dpop.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
|
||||
import { readConfig } from '../src/config'
|
||||
import { createPassthroughTransport, type WebSocketLike } from '../src/ws-transport'
|
||||
import { encodeDpopSubprotocol } from '../src/dpop'
|
||||
|
||||
const cfg = readConfig({
|
||||
protocol: 'https:',
|
||||
host: 'alice.term.example.com',
|
||||
hostname: 'alice.term.example.com',
|
||||
})
|
||||
|
||||
class MockWs implements WebSocketLike {
|
||||
static last: MockWs | null = null
|
||||
binaryType = ''
|
||||
protocol = ''
|
||||
readonly url: string
|
||||
readonly protocols: readonly string[]
|
||||
closed: { code?: number; reason?: string } | null = null
|
||||
onopen: ((ev: unknown) => void) | null = null
|
||||
onmessage: ((ev: { data: unknown }) => void) | null = null
|
||||
onclose: ((ev: { code?: number; reason?: string }) => void) | null = null
|
||||
onerror: ((ev: unknown) => void) | null = null
|
||||
constructor(url: string, protocols?: string | readonly string[]) {
|
||||
this.url = url
|
||||
this.protocols = protocols === undefined ? [] : typeof protocols === 'string' ? [protocols] : protocols
|
||||
MockWs.last = this
|
||||
}
|
||||
send(): void {}
|
||||
close(code?: number, reason?: string): void {
|
||||
const ev: { code?: number; reason?: string } = {}
|
||||
if (code !== undefined) ev.code = code
|
||||
if (reason !== undefined) ev.reason = reason
|
||||
this.closed = ev
|
||||
this.onclose?.(ev)
|
||||
}
|
||||
fireOpen(echoedProtocol: string): void {
|
||||
this.protocol = echoedProtocol
|
||||
this.onopen?.({})
|
||||
}
|
||||
}
|
||||
|
||||
describe('createPassthroughTransport — B6 DPoP proof attachment', () => {
|
||||
const PROOF = 'aGVhZGVy.cGF5bG9hZA.c2ln'
|
||||
|
||||
it('token + DPoP both ride Sec-WebSocket-Protocol in the frozen order, NEVER the URL', async () => {
|
||||
const t = createPassthroughTransport(cfg, {
|
||||
wsCtor: MockWs,
|
||||
capabilityToken: 'RAWTOK',
|
||||
dpopProof: PROOF,
|
||||
})
|
||||
const opened = t.open()
|
||||
MockWs.last!.fireOpen(APP_SUBPROTOCOL)
|
||||
await opened
|
||||
expect(MockWs.last!.protocols).toEqual([
|
||||
APP_SUBPROTOCOL,
|
||||
encodeTokenSubprotocol('RAWTOK'),
|
||||
encodeDpopSubprotocol(PROOF),
|
||||
])
|
||||
expect(MockWs.last!.url).not.toContain('RAWTOK')
|
||||
expect(MockWs.last!.url).not.toContain(PROOF)
|
||||
expect(MockWs.last!.url).not.toContain(encodeDpopSubprotocol(PROOF))
|
||||
expect(MockWs.last!.url).not.toMatch(/\?/)
|
||||
})
|
||||
|
||||
it('echo rule holds with a DPoP bearer: only APP_SUBPROTOCOL back is accepted', async () => {
|
||||
const t = createPassthroughTransport(cfg, { wsCtor: MockWs, dpopProof: PROOF })
|
||||
const opened = t.open()
|
||||
MockWs.last!.fireOpen(encodeDpopSubprotocol(PROOF)) // relay wrongly echoes the DPoP entry
|
||||
await expect(opened).rejects.toThrow()
|
||||
expect(MockWs.last!.closed).not.toBeNull()
|
||||
})
|
||||
|
||||
it('no dpopProof → protocols unchanged from the v0.9 token-only path (backward compatible)', async () => {
|
||||
const t = createPassthroughTransport(cfg, { wsCtor: MockWs, capabilityToken: 'RAWTOK' })
|
||||
const opened = t.open()
|
||||
MockWs.last!.fireOpen(APP_SUBPROTOCOL)
|
||||
await opened
|
||||
expect(MockWs.last!.protocols).toEqual([APP_SUBPROTOCOL, encodeTokenSubprotocol('RAWTOK')])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user