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:
234
relay-auth/src/types.ts
Normal file
234
relay-auth/src/types.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* T1 · P5-local types + Zod schemas (frozen after T1, changed only via T1).
|
||||
*
|
||||
* Re-exports the frozen relay-contracts §4 shapes for P5 consumers (import, NEVER redefine)
|
||||
* and declares the P5-local records that §4 explicitly delegates to "P5-owned"
|
||||
* (webauthn_credentials / oidc_identities / audit / revocation policy / SPIFFE-ID).
|
||||
*
|
||||
* SECURITY (INV3): `accountId` originates ONLY inside `AuthenticatedPrincipal`, derived from a
|
||||
* verified credential/cert — never from a client field. `CapabilityToken.sub` carries THAT SAME
|
||||
* `accountId` (CAP_TOKEN_SUB_IS_ACCOUNT_ID). No schema here accepts a client-supplied
|
||||
* `accountId`/`tenant_id` as authoritative.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
// ── Re-export frozen §4 types (import, never redefine) ────────────────────────────────────────
|
||||
export type { CapabilityToken, CapabilityRight } from 'relay-contracts' // §4.3
|
||||
export type { HostRecord, HostStatus, PlanTier, SessionRecord } from 'relay-contracts' // §4.2
|
||||
// Revocation kill-signal + bus are FROZEN in relay-contracts §4.2 (FIX 4) — re-export, never redeclare:
|
||||
export type { RevocationScope, KillSignal, RevocationBus } from 'relay-contracts' // §4.2, FIX 4
|
||||
export { RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS } from 'relay-contracts' // 'relay:revocations' · 2000ms
|
||||
|
||||
// ── Auth method / principal ───────────────────────────────────────────────────────────────────
|
||||
export type PrincipalKind = 'human' | 'agent' | 'share-grant'
|
||||
export type AuthMethod = 'passkey' | 'totp' | 'oidc' | 'mtls' | 'stepup'
|
||||
|
||||
/**
|
||||
* The ONLY source of `accountId` in an authz decision (INV3). All fields are derived from
|
||||
* authenticated material; `accountId` is NEVER read from client input.
|
||||
*/
|
||||
export interface AuthenticatedPrincipal {
|
||||
readonly kind: PrincipalKind
|
||||
readonly accountId: string
|
||||
readonly principalId: string // device/credential id (WebAuthn credId, SPIFFE-ID, or grant jti)
|
||||
readonly amr: readonly AuthMethod[] // methods satisfied this session (for step-up, T8)
|
||||
readonly authAt: number // epoch seconds of last successful auth (login freshness)
|
||||
readonly stepUpAt: number | null // epoch seconds of last step-up (T8 freshness); null = never
|
||||
}
|
||||
|
||||
/**
|
||||
* FROZEN P5 CONVENTION (resolves open-Q #3): §4.3 `CapabilityToken.sub` carries
|
||||
* `principal.accountId`. The single reader is T3's `accountIdFromToken`. This chooses the VALUE
|
||||
* P5 writes into an existing frozen field — it does NOT redefine §4.3.
|
||||
*/
|
||||
export const CAP_TOKEN_SUB_IS_ACCOUNT_ID = true as const
|
||||
|
||||
export interface StepUpPolicy {
|
||||
readonly maxAgeSeconds: number
|
||||
readonly requiredMethod: AuthMethod
|
||||
}
|
||||
|
||||
// ── P5-owned auth records (§4.2 delegated to P5) ────────────────────────────────────────────────
|
||||
export interface WebAuthnCredential {
|
||||
readonly credentialId: string
|
||||
readonly accountId: string
|
||||
readonly publicKey: Uint8Array
|
||||
readonly signCount: number
|
||||
readonly transports: readonly string[]
|
||||
readonly createdAt: string
|
||||
}
|
||||
export interface OidcIdentity {
|
||||
readonly issuer: string
|
||||
readonly subject: string
|
||||
readonly accountId: string
|
||||
}
|
||||
export interface TotpSecretRecord {
|
||||
readonly accountId: string
|
||||
readonly encSecret: Uint8Array // encrypted at rest (INV5); never SMS
|
||||
readonly confirmedAt: string | null
|
||||
}
|
||||
|
||||
export type AuditAction =
|
||||
| 'attach'
|
||||
| 'reattach'
|
||||
| 'manage'
|
||||
| 'kill'
|
||||
| 'enroll'
|
||||
| 'revoke'
|
||||
| 'login'
|
||||
| 'stepup'
|
||||
| 'token-issue'
|
||||
| 'cross-tenant-attempt'
|
||||
export type AuditOutcome = 'allow' | 'deny'
|
||||
|
||||
/** INV10 — metadata only, ZERO payload. No field may carry keystrokes/output (redact.ts + Zod guard). */
|
||||
export interface AuditEvent {
|
||||
readonly ts: string
|
||||
readonly action: AuditAction
|
||||
readonly principalId: string
|
||||
readonly accountId: string
|
||||
readonly hostId: string | null
|
||||
readonly sessionId: string | null
|
||||
readonly jti: string | null
|
||||
readonly outcome: AuditOutcome
|
||||
readonly reason: string
|
||||
readonly remoteAddrHash: string
|
||||
}
|
||||
|
||||
export type SpiffeId = string // 'spiffe://relay.<domain>/account/<accountId>/host/<hostId>'
|
||||
|
||||
export interface RateLimitPolicy {
|
||||
readonly connectPerMin: number
|
||||
readonly enrollPerHour: number
|
||||
readonly maxConcurrentSessions: number
|
||||
readonly maxPairedHosts: number
|
||||
readonly preAuthPerMinPerIp: number // pre-principal throttle keyed on remoteAddrHash (T11)
|
||||
readonly totpMaxFailsPerWindow: number
|
||||
readonly totpLockoutWindowSec: number
|
||||
}
|
||||
|
||||
// ── Read-only ports P3/P1 implement (P5 stays storage-free & pure) ──────────────────────────────
|
||||
import type { HostRecord as _HostRecord } from 'relay-contracts'
|
||||
export interface HostRegistryPort {
|
||||
getById(hostId: string): Promise<_HostRecord | null>
|
||||
}
|
||||
export interface SessionRegistryPort {
|
||||
getById(sessionId: string): Promise<{ hostId: string; accountId: string } | null>
|
||||
}
|
||||
export interface RevocationStore {
|
||||
isRevoked(jti: string): Promise<boolean>
|
||||
revokeJti(jti: string, exp: number): Promise<void>
|
||||
/** single-use consumption: true = first use (proceed); false = replay (deny). */
|
||||
consumeOnce(jti: string, exp: number): Promise<boolean>
|
||||
}
|
||||
export interface AuditSink {
|
||||
append(e: AuditEvent): Promise<void>
|
||||
}
|
||||
export interface TokenBucketStore {
|
||||
/** false = throttled. Keys are OPAQUE to P5. */
|
||||
take(key: string, refillPerSec: number, burst: number, now: number): Promise<boolean>
|
||||
}
|
||||
|
||||
// ── Zod schemas (boundary validation, coding-style "Input Validation") ──────────────────────────
|
||||
export const PrincipalKindSchema = z.enum(['human', 'agent', 'share-grant'])
|
||||
export const AuthMethodSchema = z.enum(['passkey', 'totp', 'oidc', 'mtls', 'stepup'])
|
||||
|
||||
export const AuthenticatedPrincipalSchema = z
|
||||
.object({
|
||||
kind: PrincipalKindSchema,
|
||||
accountId: z.string().min(1),
|
||||
principalId: z.string().min(1),
|
||||
amr: z.array(AuthMethodSchema).readonly(),
|
||||
authAt: z.number().int().nonnegative(),
|
||||
stepUpAt: z.number().int().nonnegative().nullable(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const StepUpPolicySchema = z
|
||||
.object({
|
||||
maxAgeSeconds: z.number().int().nonnegative(),
|
||||
requiredMethod: AuthMethodSchema,
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const WebAuthnCredentialSchema = z
|
||||
.object({
|
||||
credentialId: z.string().min(1),
|
||||
accountId: z.string().min(1),
|
||||
publicKey: z.instanceof(Uint8Array),
|
||||
signCount: z.number().int().nonnegative(),
|
||||
transports: z.array(z.string()).readonly(),
|
||||
createdAt: z.string().min(1),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const OidcIdentitySchema = z
|
||||
.object({
|
||||
issuer: z.string().min(1),
|
||||
subject: z.string().min(1),
|
||||
accountId: z.string().min(1),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const TotpSecretRecordSchema = z
|
||||
.object({
|
||||
accountId: z.string().min(1),
|
||||
encSecret: z.instanceof(Uint8Array),
|
||||
confirmedAt: z.string().nullable(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const AuditActionSchema = z.enum([
|
||||
'attach',
|
||||
'reattach',
|
||||
'manage',
|
||||
'kill',
|
||||
'enroll',
|
||||
'revoke',
|
||||
'login',
|
||||
'stepup',
|
||||
'token-issue',
|
||||
'cross-tenant-attempt',
|
||||
])
|
||||
|
||||
/**
|
||||
* INV10 zero-payload guard at the type layer: `.strict()` REJECTS any unknown key (e.g. a
|
||||
* `data`/`bytes` field carrying terminal output). Length/control-char checks are in redact.ts (T4).
|
||||
*/
|
||||
export const AuditEventSchema = z
|
||||
.object({
|
||||
ts: z.string().min(1),
|
||||
action: AuditActionSchema,
|
||||
principalId: z.string(),
|
||||
accountId: z.string(),
|
||||
hostId: z.string().nullable(),
|
||||
sessionId: z.string().nullable(),
|
||||
jti: z.string().nullable(),
|
||||
outcome: z.enum(['allow', 'deny']),
|
||||
reason: z.string(),
|
||||
remoteAddrHash: z.string(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const RateLimitPolicySchema = z
|
||||
.object({
|
||||
connectPerMin: z.number().int().positive(),
|
||||
enrollPerHour: z.number().int().positive(),
|
||||
maxConcurrentSessions: z.number().int().positive(),
|
||||
maxPairedHosts: z.number().int().positive(),
|
||||
preAuthPerMinPerIp: z.number().int().positive(),
|
||||
totpMaxFailsPerWindow: z.number().int().positive(),
|
||||
totpLockoutWindowSec: z.number().int().positive(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
/**
|
||||
* SPIFFE-ID validator: accepts the `spiffe://relay.<domain>/account/<a>/host/<h>` form and
|
||||
* REJECTS path-traversal (`..`) or wildcard (`*`). Segment chars are restricted (no `/`).
|
||||
*/
|
||||
const SPIFFE_ID_RE =
|
||||
/^spiffe:\/\/relay\.[a-z0-9.-]+\/account\/[A-Za-z0-9_-]+\/host\/[A-Za-z0-9_-]+$/
|
||||
export const SpiffeIdSchema = z
|
||||
.string()
|
||||
.regex(SPIFFE_ID_RE, 'invalid SPIFFE-ID form')
|
||||
.refine((s) => !s.includes('..') && !s.includes('*'), 'path-traversal/wildcard rejected')
|
||||
Reference in New Issue
Block a user