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:
146
agent/test/hostEndpoint.test.ts
Normal file
146
agent/test/hostEndpoint.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
AeadAlg,
|
||||
ClientHello,
|
||||
E2ESession,
|
||||
HandshakeResult,
|
||||
HostHello,
|
||||
} from 'relay-contracts'
|
||||
import { generateIdentity } from '../src/keys/identity.js'
|
||||
import {
|
||||
MitmAbortError,
|
||||
createE2ETransform,
|
||||
makeHostHello,
|
||||
type CreateHostHandshake,
|
||||
type VerifyDeviceProof,
|
||||
} from '../src/e2e/hostEndpoint.js'
|
||||
import type { ReplaySealer } from '../src/e2e/replaySeal.js'
|
||||
|
||||
const ALG: AeadAlg = 'aes-256-gcm'
|
||||
|
||||
function clientHello(proof: string): ClientHello {
|
||||
return {
|
||||
clientEphPub: new Uint8Array([1, 2, 3]),
|
||||
clientNonce: new Uint8Array([4, 5, 6]),
|
||||
aeadOffer: [ALG],
|
||||
deviceAuthProof: proof,
|
||||
}
|
||||
}
|
||||
|
||||
function fakeHandshake(keysByte: number): CreateHostHandshake {
|
||||
return () => ({
|
||||
async respond(): Promise<{ hello: HostHello; result: HandshakeResult }> {
|
||||
const result: HandshakeResult = {
|
||||
keys: {
|
||||
c2h: new Uint8Array([keysByte]) as never,
|
||||
h2c: new Uint8Array([keysByte + 1]) as never,
|
||||
},
|
||||
aead: ALG,
|
||||
transcript: new Uint8Array([0xff]),
|
||||
}
|
||||
const hello: HostHello = {
|
||||
hostEphPub: new Uint8Array([7]),
|
||||
hostNonce: new Uint8Array([8]),
|
||||
aeadChoice: ALG,
|
||||
enrollFpr: 'fpr',
|
||||
sig: new Uint8Array([9]),
|
||||
}
|
||||
return { hello, result }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The load-bearing MITM verifier: only a specific proof passes.
|
||||
const realVerifier: VerifyDeviceProof = async (proof) => proof === 'VALID-PROOF'
|
||||
|
||||
describe('makeHostHello (T15, anti-MITM)', () => {
|
||||
it('derives DirectionalKeys{c2h,h2c} on a valid proof (FIX 2 — no single sessionKey)', async () => {
|
||||
const { result } = await makeHostHello(clientHello('VALID-PROOF'), generateIdentity(), realVerifier, {
|
||||
createHostHandshake: fakeHandshake(0x10),
|
||||
})
|
||||
expect(result.keys).toHaveProperty('c2h')
|
||||
expect(result.keys).toHaveProperty('h2c')
|
||||
expect(result).not.toHaveProperty('sessionKey')
|
||||
})
|
||||
|
||||
it('ABORTS on a forged proof: no keys, no HostHello (MitmAbortError)', async () => {
|
||||
const respond = vi.fn()
|
||||
const spyHandshake: CreateHostHandshake = () => ({ respond: respond as never })
|
||||
await expect(
|
||||
makeHostHello(clientHello('FORGED'), generateIdentity(), realVerifier, {
|
||||
createHostHandshake: spyHandshake,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(MitmAbortError)
|
||||
expect(respond).not.toHaveBeenCalled() // no key derivation reached
|
||||
})
|
||||
|
||||
it('no-stub guard: swapping the verifier for always-true makes the MITM test FAIL', async () => {
|
||||
const stub: VerifyDeviceProof = async () => true
|
||||
// With the stubbed verifier, the forged proof WRONGLY completes — proving the verifier is
|
||||
// load-bearing (a real build forbids this stub via the import guard below).
|
||||
const out = await makeHostHello(clientHello('FORGED'), generateIdentity(), stub, {
|
||||
createHostHandshake: fakeHandshake(0x20),
|
||||
})
|
||||
expect(out.result.keys).toBeDefined()
|
||||
})
|
||||
|
||||
it('no-stub CI guard: hostEndpoint.ts imports NO verifier from relay-e2e (FIX 6b)', () => {
|
||||
const raw = readFileSync(join(import.meta.dirname, '..', 'src', 'e2e', 'hostEndpoint.ts'), 'utf8')
|
||||
const src = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '') // strip comments
|
||||
expect(src).not.toMatch(/import[^\n]*verifyDeviceAuthProof/)
|
||||
expect(src).not.toMatch(/from ['"]relay-e2e['"]/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createE2ETransform (T15)', () => {
|
||||
function fakeSession(): E2ESession {
|
||||
// Reversible transform that HIDES the plaintext (XOR) so INV2 assertions are meaningful.
|
||||
return {
|
||||
role: 'host',
|
||||
seal: (pt) => Uint8Array.from([0xe0, ...pt.map((b) => b ^ 0x55)]),
|
||||
open: (ct) => Uint8Array.from(ct.slice(1)).map((b) => b ^ 0x55),
|
||||
rederive: () => {},
|
||||
}
|
||||
}
|
||||
function fakeReplay(): ReplaySealer & { calls: number } {
|
||||
const r = {
|
||||
calls: 0,
|
||||
seal(_pt: Uint8Array) {
|
||||
r.calls += 1
|
||||
return { seq: 0n, nonce: new Uint8Array(), ciphertext: new Uint8Array([0xde]), tag: new Uint8Array() }
|
||||
},
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
it('outbound seals under BOTH the live session and the replay key (FIX 3, INV2)', () => {
|
||||
const replay = fakeReplay()
|
||||
const t = createE2ETransform(generateIdentity(), realVerifier, replay)
|
||||
t.openStream(1)
|
||||
t.seedSession(1, fakeSession(), new Uint8Array([0x48])) // HostHello control frame
|
||||
const marker = new TextEncoder().encode('PLAIN')
|
||||
const sealed = t.outbound(1, marker)
|
||||
expect(replay.calls).toBe(1) // replay-bound seal happened
|
||||
expect(Buffer.from(sealed).includes(Buffer.from(marker))).toBe(false) // ciphertext, not plaintext
|
||||
expect(t.takeControlFrames!(1)).toEqual([new Uint8Array([0x48])]) // HostHello flushed once
|
||||
expect(t.takeControlFrames!(1)).toEqual([])
|
||||
})
|
||||
|
||||
it('inbound before seeding returns null (handshake pending); after, opens opaque', () => {
|
||||
const t = createE2ETransform(generateIdentity(), realVerifier, fakeReplay())
|
||||
const session = fakeSession()
|
||||
t.openStream(1)
|
||||
expect(t.inbound(1, new Uint8Array([1, 2]))).toBeNull()
|
||||
t.seedSession(1, session, new Uint8Array([0x48]))
|
||||
const ct = session.seal(new Uint8Array([9, 9])) // c2h ciphertext
|
||||
expect([...t.inbound(1, ct)!]).toEqual([9, 9])
|
||||
})
|
||||
|
||||
it('outbound before the session is established aborts (no silent plaintext leak)', () => {
|
||||
const t = createE2ETransform(generateIdentity(), realVerifier, fakeReplay())
|
||||
t.openStream(1)
|
||||
expect(() => t.outbound(1, new Uint8Array([1]))).toThrow(MitmAbortError)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user