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:
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