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,41 @@
/**
* T12 · Enforcement entry point `onReattach` — deny-by-default on REATTACH too (INV6). Same pipeline
* as `onUpgrade` but the authz step additionally re-validates that the session exists AND belongs to
* the same account (INV6 re-validate). P3 calls this on every reattach; a stale-step-up principal
* never regains a live stream.
*/
import { authorizeReattach, type AuthzOutcome } from '../authz/decide.js'
import { runEnforcement, type EnforceDeps, type UpgradeContext } from './onUpgrade.js'
export type ReattachContext = UpgradeContext & { readonly sessionId: string }
export function onReattach(
ctx: ReattachContext,
deps: EnforceDeps,
allowedOrigins: readonly string[],
now: number,
): Promise<AuthzOutcome> {
return runEnforcement(
ctx,
deps,
allowedOrigins,
now,
() =>
authorizeReattach(
{
capabilityRaw: ctx.capabilityRaw,
expectedAud: ctx.expectedAud,
requestedHostId: ctx.requestedHostId,
requiredRight: ctx.requiredRight,
sessionId: ctx.sessionId,
},
ctx.dpop,
deps.hosts,
deps.sessions,
deps.revocation,
now,
),
'reattach',
ctx.sessionId,
)
}

View File

@@ -0,0 +1,186 @@
/**
* T12 · Enforcement entry point `onUpgrade` — the SOLE owner of the WS-upgrade authorization
* DECISION (INDEX §6a). P1's `authorizeUpgrade` is a thin adapter that DELEGATES here. The full
* decision composes, in order: Origin/CSWSH (retained, INV15) → pre-auth throttle (Finding-5) →
* deny-by-default authz incl. DPoP proof-of-possession + cross-tenant gate (T3/INV1) → per-tenant
* rate → step-up gate (T8/Finding-3, v0.10) → single-use jti burn (Finding-4) → audit (T4/INV10).
*
* Deny-by-default: every branch that is not an explicit allow returns 401/403 and emits exactly one
* `deny` AuditEvent. No allow path writes payload (INV10).
*/
import type { CapabilityRight, HostRecord } from 'relay-contracts'
import type {
AuditAction,
AuthenticatedPrincipal,
AuditSink,
HostRegistryPort,
RevocationStore,
SessionRegistryPort,
StepUpPolicy,
TokenBucketStore,
} from '../types.js'
import { authorizeConnect, authorizeReattach, type AuthzOutcome } from '../authz/decide.js'
import type { DpopContext } from '../capability/verify.js'
import { peekExp } from '../capability/verify.js'
import {
checkPreAuthRate,
checkConnectRate,
checkConcurrentSessions,
policyForPlan,
} from '../ratelimit/quota.js'
import { needsStepUp } from '../human/stepup/stepup.js'
import { buildAuditEvent, audit } from '../audit/log.js'
export interface UpgradeContext {
readonly capabilityRaw: string
readonly originHeader: string
readonly expectedAud: string
readonly requestedHostId: string
readonly requiredRight: CapabilityRight
readonly remoteAddrHash: string
readonly activeSessionCount: number
readonly dpop: DpopContext
readonly principal: AuthenticatedPrincipal | null // session principal for step-up (T8)
}
export interface EnforceDeps {
readonly hosts: HostRegistryPort
readonly sessions: SessionRegistryPort
readonly revocation: RevocationStore
readonly buckets: TokenBucketStore
readonly audit: AuditSink
/** v0.10; v0.9 injects a "never required" policy (only consulted when a session principal exists). */
readonly stepUpPolicyFor: (host: HostRecord) => StepUpPolicy
}
/** v0.9 default: no per-host tier lookup port here; the real tier comes from P3's account registry
* (INTEGRATION POINT). A conservative fixed policy bounds per-tenant abuse until then. */
const DEFAULT_TENANT_POLICY = policyForPlan('personal')
function deny(status: 401 | 403, reason: string): AuthzOutcome {
return { ok: false, status, reason }
}
function auditActionFor(base: AuditAction, reason: string): AuditAction {
return reason === 'cross_tenant' || reason === 'cross_tenant_session' ? 'cross-tenant-attempt' : base
}
async function emitDeny(
deps: EnforceDeps,
ctx: UpgradeContext,
base: AuditAction,
reason: string,
now: number,
): Promise<void> {
await audit(
deps.audit,
buildAuditEvent({
action: auditActionFor(base, reason),
principal: ctx.principal,
hostId: ctx.requestedHostId,
sessionId: null,
jti: null,
outcome: 'deny',
reason,
remoteAddrHash: ctx.remoteAddrHash,
now,
}),
)
}
/** Shared enforcement pipeline for connect (`base='attach'`) and reattach (`base='reattach'`). */
export async function runEnforcement(
ctx: UpgradeContext,
deps: EnforceDeps,
allowedOrigins: readonly string[],
now: number,
authorize: () => Promise<AuthzOutcome>,
base: AuditAction,
sessionId: string | null,
): Promise<AuthzOutcome> {
// 1. Origin / CSWSH (retained base-app check, INV15).
if (!allowedOrigins.includes(ctx.originHeader)) {
await emitDeny(deps, ctx, base, 'bad_origin', now)
return deny(401, 'bad_origin')
}
// 2. Pre-auth throttle BEFORE any token work (Finding-5) — keyed on the IP hash, no principal yet.
if (!(await checkPreAuthRate(ctx.remoteAddrHash, deps.buckets, now))) {
await emitDeny(deps, ctx, base, 'pre_auth_throttled', now)
return deny(403, 'pre_auth_throttled')
}
// 3. Deny-by-default authz (token verify + DPoP PoP + INV1 cross-tenant gate).
const outcome = await authorize()
if (!outcome.ok) {
await emitDeny(deps, ctx, base, outcome.reason, now)
return outcome
}
const accountId = outcome.principal.accountId
// 4. Per-tenant rate (after the principal is known).
if (!(await checkConnectRate(accountId, DEFAULT_TENANT_POLICY, deps.buckets, now))) {
await emitDeny(deps, ctx, base, 'rate_limited', now)
return deny(403, 'rate_limited')
}
if (!checkConcurrentSessions(accountId, ctx.activeSessionCount, DEFAULT_TENANT_POLICY)) {
await emitDeny(deps, ctx, base, 'too_many_sessions', now)
return deny(403, 'too_many_sessions')
}
// 5. Step-up gate (Finding-3, v0.10) — only when a session principal is present.
if (ctx.principal !== null) {
const host = await deps.hosts.getById(outcome.hostId)
if (host !== null && needsStepUp(ctx.principal, deps.stepUpPolicyFor(host), now)) {
await emitDeny(deps, ctx, 'stepup', 'step_up_required', now)
return deny(403, 'step_up_required')
}
}
// 6. Single-use consume (Finding-4) — burn the jti on the first fully-allowed upgrade.
const exp = peekExp(ctx.capabilityRaw)
if (!(await deps.revocation.consumeOnce(outcome.jti, exp))) {
await emitDeny(deps, ctx, base, 'token_replayed', now)
return deny(403, 'token_replayed')
}
// 7. Audit allow + return.
await audit(
deps.audit,
buildAuditEvent({
action: base,
principal: outcome.principal,
hostId: outcome.hostId,
sessionId,
jti: outcome.jti,
outcome: 'allow',
reason: 'ok',
remoteAddrHash: ctx.remoteAddrHash,
now,
}),
)
return outcome
}
export function onUpgrade(
ctx: UpgradeContext,
deps: EnforceDeps,
allowedOrigins: readonly string[],
now: number,
): Promise<AuthzOutcome> {
return runEnforcement(
ctx,
deps,
allowedOrigins,
now,
() =>
authorizeConnect(
{
capabilityRaw: ctx.capabilityRaw,
expectedAud: ctx.expectedAud,
requestedHostId: ctx.requestedHostId,
requiredRight: ctx.requiredRight,
},
ctx.dpop,
deps.hosts,
deps.revocation,
now,
),
'attach',
null,
)
}