feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)

Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.

Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
  capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay:   native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent:        host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e:    browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth:   Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web:    browser login + Web Crypto E2E + client-side preview rendering

Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.

NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
This commit is contained in:
Yaojia Wang
2026-07-02 06:10:16 +02:00
parent e4c327e25e
commit 2af57e6686
326 changed files with 40877 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { issueCapabilityToken } from '../src/capability/issue.js'
import {
verifyCapabilityToken,
verifyDpopProof,
buildDpopProof,
readCnfJkt,
subAccountId,
hasRight,
resetDpopCacheForTest,
} from '../src/capability/verify.js'
import { jwkThumbprint } from '../src/crypto/thumbprint.js'
import { CapabilityError } from '../src/capability/errors.js'
import { setupP5SigningKey, makeEphemeral, principal, uuid } from './_helpers.js'
const NOW = 1_700_000_000
const AUD = 'alice.term.example.com'
async function issueFor(
signingKey: CryptoKey,
opts: { accountId?: string; host?: string; rights?: readonly ('attach' | 'manage' | 'kill')[]; ttl?: number; cnfJkt?: string },
) {
const cnfJkt = opts.cnfJkt ?? (await jwkThumbprint((await makeEphemeral()).publicRaw))
return issueCapabilityToken(
{
principal: principal(opts.accountId ?? 'acct-A'),
aud: AUD,
host: opts.host ?? uuid(),
rights: opts.rights ?? ['attach'],
ttlSeconds: opts.ttl ?? 45,
cnfJkt,
},
signingKey,
NOW,
)
}
describe('capability token (§4.3)', () => {
let signingKey: CryptoKey
beforeEach(async () => {
;({ signingKey } = await setupP5SigningKey())
resetDpopCacheForTest()
})
it('round-trips issue → verify preserving host/rights/jti and sub===accountId', async () => {
const host = uuid()
const raw = await issueFor(signingKey, { accountId: 'acct-A', host, rights: ['attach', 'manage'] })
const tok = await verifyCapabilityToken(raw, AUD, NOW + 1)
expect(tok.host).toBe(host)
expect(tok.rights).toEqual(['attach', 'manage'])
expect(tok.jti.length).toBeGreaterThan(0)
expect(subAccountId(tok)).toBe('acct-A')
})
it('sets sub to principal.accountId, never a client value', async () => {
const raw = await issueFor(signingKey, { accountId: 'acct-A' })
const tok = await verifyCapabilityToken(raw, AUD, NOW)
expect(tok.sub).toBe('acct-A')
})
it('rejects an expired token', async () => {
const raw = await issueFor(signingKey, { ttl: 30 })
await expect(verifyCapabilityToken(raw, AUD, NOW + 31)).rejects.toMatchObject({ reason: 'expired' })
})
it('rejects a not-yet-valid token (iat in the future beyond skew)', async () => {
const raw = await issueFor(signingKey, {})
await expect(verifyCapabilityToken(raw, AUD, NOW - 100)).rejects.toMatchObject({
reason: 'not_yet_valid',
})
})
it('refuses an over-long TTL at issue (no long-lived reusable token)', async () => {
await expect(issueFor(signingKey, { ttl: 61 })).rejects.toMatchObject({ reason: 'ttl_too_long' })
})
it('clamps a too-short TTL up to the 30s floor', async () => {
const raw = await issueFor(signingKey, { ttl: 5 })
const tok = await verifyCapabilityToken(raw, AUD, NOW)
expect(tok.exp - tok.iat).toBe(30)
})
it('rejects a wildcard host at issue', async () => {
await expect(issueFor(signingKey, { host: '*' })).rejects.toMatchObject({ reason: 'wildcard_host' })
})
it('rejects wrong aud (Host-confusion, INV1)', async () => {
const raw = await issueFor(signingKey, {})
await expect(verifyCapabilityToken(raw, 'bob.term.example.com', NOW)).rejects.toMatchObject({
reason: 'aud_mismatch',
})
})
it('rejects a tampered payload (signature fails)', async () => {
const raw = await issueFor(signingKey, {})
const tampered = raw.slice(0, -4) + (raw.endsWith('AAAA') ? 'BBBB' : 'AAAA')
await expect(verifyCapabilityToken(tampered, AUD, NOW)).rejects.toBeInstanceOf(CapabilityError)
})
it('rejects a token signed by a different key', async () => {
const other = await setupP5SigningKey() // reconfigures verify key to a DIFFERENT pair
const raw = await issueFor(other.signingKey, {})
// reconfigure back to the original key so the verifier uses the wrong public key
await setupP5SigningKey()
await expect(verifyCapabilityToken(raw, AUD, NOW)).rejects.toMatchObject({ reason: 'bad_signature' })
})
it('enforces least-privilege rights (INV15)', async () => {
const raw = await issueFor(signingKey, { rights: ['attach'] })
const tok = await verifyCapabilityToken(raw, AUD, NOW)
expect(hasRight(tok, 'attach')).toBe(true)
expect(hasRight(tok, 'kill')).toBe(false)
})
describe('DPoP proof-of-possession', () => {
it('accepts a proof from the bound ephemeral key and rejects a different key', async () => {
const eph = await makeEphemeral()
const cnfJkt = await jwkThumbprint(eph.publicRaw)
const raw = await issueFor(signingKey, { cnfJkt })
const tok = await verifyCapabilityToken(raw, AUD, NOW)
expect(readCnfJkt(tok)).toBe(cnfJkt)
const htu = 'https://alice.term.example.com/ws'
const good = await buildDpopProof(eph.privateKey, eph.publicRaw, {
htu,
htm: 'GET',
jti: uuid(),
iat: NOW,
})
expect(await verifyDpopProof(tok, { proofJws: good, htu, htm: 'GET' }, NOW)).toBe(true)
const wrong = await makeEphemeral()
const bad = await buildDpopProof(wrong.privateKey, wrong.publicRaw, {
htu,
htm: 'GET',
jti: uuid(),
iat: NOW,
})
expect(await verifyDpopProof(tok, { proofJws: bad, htu, htm: 'GET' }, NOW)).toBe(false)
})
it('rejects a replayed DPoP proof (same jti reused)', async () => {
const eph = await makeEphemeral()
const cnfJkt = await jwkThumbprint(eph.publicRaw)
const raw = await issueFor(signingKey, { cnfJkt })
const tok = await verifyCapabilityToken(raw, AUD, NOW)
const htu = 'https://alice.term.example.com/ws'
const jti = uuid()
const proof = await buildDpopProof(eph.privateKey, eph.publicRaw, { htu, htm: 'GET', jti, iat: NOW })
expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(true)
expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(false)
})
})
})