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.
187 lines
6.1 KiB
TypeScript
187 lines
6.1 KiB
TypeScript
/**
|
|
* 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,
|
|
)
|
|
}
|