Files
web-terminal/relay-e2e/test/integration.test.ts
Yaojia Wang 2af57e6686 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.
2026-07-02 06:10:16 +02:00

100 lines
4.3 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { readFileSync, readdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { createClientHandshake, createHostHandshake } from '../src/handshake.js'
import { nobleEd25519Signer, nobleEd25519Verifier } from '../src/ed25519.js'
import { createE2ESession } from '../src/session.js'
import { MemoryDevicePinStore } from '../src/keystore.js'
import { MAX_FRAME_BYTES } from '../src/envelope.js'
import { boundProofProvider, fromUtf8, makeHostIdentity, utf8, verifyBoundProof } from './helpers.js'
const HERE = dirname(fileURLToPath(import.meta.url))
const SRC = join(HERE, '..', 'src')
/** A passthrough "relay" that records every byte payload it forwards (the INV2 spy). */
class RelaySpy {
readonly captured: Uint8Array[] = []
forward(payload: Uint8Array): Uint8Array {
expect(payload.length).toBeLessThanOrEqual(MAX_FRAME_BYTES) // §4.1 payloadLen guard
this.captured.push(payload.slice())
return payload
}
snapshot(): string {
return this.captured.map((b) => Buffer.from(b).toString('latin1')).join('')
}
}
async function establish() {
const id = makeHostIdentity()
const client = createClientHandshake({
aeadOffer: ['xchacha20-poly1305', 'aes-256-gcm'],
deviceAuthProofProvider: boundProofProvider(),
verifier: nobleEd25519Verifier(),
pinStore: new MemoryDevicePinStore(),
hostId: 'h1',
})
const host = createHostHandshake({
signer: nobleEd25519Signer(id.privateKey),
agentPubkey: id.agentPubkey,
supported: ['xchacha20-poly1305', 'aes-256-gcm'],
verifyDeviceProof: async (p, b) => verifyBoundProof(p, b),
})
const spy = new RelaySpy()
const ch = await client.start()
const hh = await host.onClientHello(ch) // relay forwards these as opaque DATA
const rc = await client.onHostHello(hh, id.agentPubkey)
return {
spy,
clientSession: createE2ESession('client', rc),
hostSession: createE2ESession('host', host.result!),
}
}
describe('T12 integration + INV2 tripwire', () => {
it('full loop through the relay spy: bidirectional plaintext round-trips', async () => {
const { spy, clientSession, hostSession } = await establish()
const up = spy.forward(clientSession.seal(utf8('echo test')))
expect(fromUtf8(hostSession.open(up))).toBe('echo test')
const down = spy.forward(hostSession.seal(utf8('echo reply')))
expect(fromUtf8(clientSession.open(down))).toBe('echo reply')
})
it('INV2 tripwire (merge-blocking): the plaintext canary appears NOWHERE in the relay spy', async () => {
const { spy, clientSession, hostSession } = await establish()
const canary = `E2E_PLAINTEXT_CANARY_${crypto.randomUUID()}`
const wire = spy.forward(clientSession.seal(utf8(canary)))
expect(fromUtf8(hostSession.open(wire))).toBe(canary)
// The marker must not transit in cleartext anywhere the relay can see.
expect(spy.snapshot()).not.toContain(canary)
for (const buf of spy.captured) {
expect(Buffer.from(buf).toString('latin1')).not.toContain(canary)
expect(Buffer.from(buf).toString('utf8')).not.toContain(canary)
}
})
it('INV11-adjacent isolation: src imports no ws/pg/xterm/DOM-runtime/node builtins', () => {
const forbidden = /from\s+['"](ws|pg|xterm|jsdom|node:[a-z]+|@xterm)/
for (const file of readdirSync(SRC).filter((f) => f.endsWith('.ts'))) {
const text = readFileSync(join(SRC, file), 'utf8')
expect(text, `${file} must stay a pure isomorphic crypto core`).not.toMatch(forbidden)
}
})
it('no console.* in src (coding-style)', () => {
for (const file of readdirSync(SRC).filter((f) => f.endsWith('.ts'))) {
expect(readFileSync(join(SRC, file), 'utf8')).not.toMatch(/console\.\w+/)
}
})
it('vector freeze: all vector files parse to their expected shape (agent↔browser parity anchor)', () => {
const dir = join(HERE, 'vectors')
const files = readdirSync(dir).filter((f) => f.endsWith('.json'))
expect(files.sort()).toEqual(['aead.json', 'envelope.json', 'fingerprint.json', 'hkdf.json'])
const aead = JSON.parse(readFileSync(join(dir, 'aead.json'), 'utf8')) as unknown[]
expect(aead.length).toBe(2)
const fpr = JSON.parse(readFileSync(join(dir, 'fingerprint.json'), 'utf8')) as { enrollFpr: string }
expect(fpr.enrollFpr.startsWith('sha256:')).toBe(true)
})
})