Files
web-terminal/relay-auth/test/_helpers.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

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