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.
179 lines
5.3 KiB
TypeScript
179 lines
5.3 KiB
TypeScript
/**
|
|
* Shared test fixtures: Ed25519 key setup + in-memory port fakes (P3/P1 supply real impls).
|
|
*/
|
|
import { randomUUID } from 'node:crypto'
|
|
import type { HostRecord } from 'relay-contracts'
|
|
import {
|
|
configureVerifyKey,
|
|
resetVerifyKeyForTest,
|
|
} from '../src/config/keys.js'
|
|
import { generateEd25519KeyPair, exportEd25519PublicRaw } from '../src/crypto/ed25519.js'
|
|
import type {
|
|
AuthenticatedPrincipal,
|
|
AuditEvent,
|
|
AuditSink,
|
|
HostRegistryPort,
|
|
SessionRegistryPort,
|
|
RevocationStore,
|
|
TokenBucketStore,
|
|
} from '../src/types.js'
|
|
|
|
export async function setupP5SigningKey(): Promise<{ signingKey: CryptoKey; publicRaw: Uint8Array }> {
|
|
resetVerifyKeyForTest()
|
|
const pair = await generateEd25519KeyPair()
|
|
await configureVerifyKey(pair.publicKey)
|
|
const publicRaw = await exportEd25519PublicRaw(pair.publicKey)
|
|
return { signingKey: pair.privateKey, publicRaw }
|
|
}
|
|
|
|
export async function makeEphemeral(): Promise<{ privateKey: CryptoKey; publicRaw: Uint8Array }> {
|
|
const pair = await generateEd25519KeyPair()
|
|
return { privateKey: pair.privateKey, publicRaw: await exportEd25519PublicRaw(pair.publicKey) }
|
|
}
|
|
|
|
export function principal(accountId: string, overrides: Partial<AuthenticatedPrincipal> = {}): AuthenticatedPrincipal {
|
|
return {
|
|
kind: 'human',
|
|
accountId,
|
|
principalId: 'cred-' + accountId,
|
|
amr: ['passkey'],
|
|
authAt: 1000,
|
|
stepUpAt: null,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeHost(accountId: string, hostId: string, status: HostRecord['status'] = 'online'): HostRecord {
|
|
return {
|
|
hostId,
|
|
accountId,
|
|
subdomain: 'sub-' + hostId,
|
|
agentPubkey: new Uint8Array(32),
|
|
enrollFpr: 'fpr-' + hostId,
|
|
status,
|
|
lastSeen: '2026-01-01T00:00:00.000Z',
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
revokedAt: null,
|
|
}
|
|
}
|
|
|
|
export const uuid = (): string => randomUUID()
|
|
|
|
export function fakeHostRegistry(hosts: readonly HostRecord[]): HostRegistryPort {
|
|
const map = new Map(hosts.map((h) => [h.hostId, h]))
|
|
return { getById: async (id) => map.get(id) ?? null }
|
|
}
|
|
|
|
export function fakeSessionRegistry(
|
|
sessions: readonly { sessionId: string; hostId: string; accountId: string }[],
|
|
): SessionRegistryPort {
|
|
const map = new Map(sessions.map((s) => [s.sessionId, { hostId: s.hostId, accountId: s.accountId }]))
|
|
return { getById: async (id) => map.get(id) ?? null }
|
|
}
|
|
|
|
export function fakeRevocationStore(): RevocationStore & { revoked: Set<string>; consumed: Set<string> } {
|
|
const revoked = new Set<string>()
|
|
const consumed = new Set<string>()
|
|
return {
|
|
revoked,
|
|
consumed,
|
|
isRevoked: async (jti) => revoked.has(jti),
|
|
revokeJti: async (jti) => {
|
|
revoked.add(jti)
|
|
},
|
|
consumeOnce: async (jti) => {
|
|
if (consumed.has(jti)) return false
|
|
consumed.add(jti)
|
|
return true
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Token bucket that always allows unless a key is added to `blocked`. */
|
|
export function fakeTokenBucket(): TokenBucketStore & { blocked: Set<string>; calls: string[] } {
|
|
const blocked = new Set<string>()
|
|
const calls: string[] = []
|
|
return {
|
|
blocked,
|
|
calls,
|
|
take: async (key) => {
|
|
calls.push(key)
|
|
return !blocked.has(key)
|
|
},
|
|
}
|
|
}
|
|
|
|
export function fakeAuditSink(): AuditSink & { events: AuditEvent[] } {
|
|
const events: AuditEvent[] = []
|
|
return { events, append: async (e) => void events.push(e) }
|
|
}
|
|
|
|
/** True token bucket: per-key state initialized on first take, refilled by elapsed wall-time. */
|
|
export function fakeCountingBucket(): TokenBucketStore {
|
|
const state = new Map<string, { tokens: number; last: number; burst: number; refill: number }>()
|
|
return {
|
|
take: async (key, refillPerSec, burst, now) => {
|
|
let s = state.get(key)
|
|
if (s === undefined) {
|
|
s = { tokens: burst, last: now, burst, refill: refillPerSec }
|
|
state.set(key, s)
|
|
}
|
|
const elapsed = Math.max(0, now - s.last)
|
|
s.tokens = Math.min(s.burst, s.tokens + elapsed * s.refill)
|
|
s.last = now
|
|
if (s.tokens < 1) return false
|
|
s.tokens -= 1
|
|
return true
|
|
},
|
|
}
|
|
}
|
|
|
|
// ── Token + matching DPoP proof (for authz/enforce/tripwire tests) ──────────────────────────────
|
|
import { issueCapabilityToken } from '../src/capability/issue.js'
|
|
import { buildDpopProof, type DpopContext } from '../src/capability/verify.js'
|
|
import { jwkThumbprint } from '../src/crypto/thumbprint.js'
|
|
import type { CapabilityRight } from 'relay-contracts'
|
|
|
|
export interface IssuedBundle {
|
|
readonly raw: string
|
|
readonly dpop: DpopContext
|
|
}
|
|
|
|
export async function issueWithDpop(
|
|
signingKey: CryptoKey,
|
|
opts: {
|
|
accountId: string
|
|
host: string
|
|
aud: string
|
|
rights?: readonly CapabilityRight[]
|
|
ttl?: number
|
|
now: number
|
|
htu?: string
|
|
htm?: string
|
|
},
|
|
): Promise<IssuedBundle> {
|
|
const eph = await makeEphemeral()
|
|
const cnfJkt = await jwkThumbprint(eph.publicRaw)
|
|
const raw = await issueCapabilityToken(
|
|
{
|
|
principal: principal(opts.accountId),
|
|
aud: opts.aud,
|
|
host: opts.host,
|
|
rights: opts.rights ?? ['attach'],
|
|
ttlSeconds: opts.ttl ?? 45,
|
|
cnfJkt,
|
|
},
|
|
signingKey,
|
|
opts.now,
|
|
)
|
|
const htu = opts.htu ?? `https://${opts.aud}/ws`
|
|
const htm = opts.htm ?? 'GET'
|
|
const proofJws = await buildDpopProof(eph.privateKey, eph.publicRaw, {
|
|
htu,
|
|
htm,
|
|
jti: uuid(),
|
|
iat: opts.now,
|
|
})
|
|
return { raw, dpop: { proofJws, htu, htm } }
|
|
}
|