Files
web-terminal/relay-web/test/dpop.test.ts
Yaojia Wang aa1912b962 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).
2026-07-06 16:13:34 +02:00

107 lines
4.8 KiB
TypeScript

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()
})
})