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:
41
relay-auth/src/enforce/onReattach.ts
Normal file
41
relay-auth/src/enforce/onReattach.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
186
relay-auth/src/enforce/onUpgrade.ts
Normal file
186
relay-auth/src/enforce/onUpgrade.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user