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:
77
control-plane/src/api/authz.ts
Normal file
77
control-plane/src/api/authz.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* T11 — admin-API principal derivation (INV3). `accountId` comes ONLY from the VERIFIED capability
|
||||
* token (P5 owns the signing key + `verifyCapabilityToken`; we consume it as an injected verifier).
|
||||
* Any body/query field named `account_id`/`tenant_id` is IGNORED for authz decisions. Deny-by-default:
|
||||
* a missing/expired/foreign-`aud` token → 401.
|
||||
*/
|
||||
import { extractTokenFromSubprotocols, type CapabilityToken, type CapabilityRight } from 'relay-contracts'
|
||||
|
||||
export interface AdminPrincipal {
|
||||
readonly accountId: string
|
||||
readonly rights: readonly CapabilityRight[]
|
||||
}
|
||||
|
||||
export class AuthzError extends Error {
|
||||
constructor(
|
||||
public readonly status: 401 | 403,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** P5's verifier, injected. Mirrors the frozen `verifyCapabilityToken` signature (§4.3). */
|
||||
export interface CapabilityVerifier {
|
||||
verify(raw: string, expectedAud: string, now: number): CapabilityToken
|
||||
}
|
||||
|
||||
/** Minimal request shape we read for the bearer token (never a body account field). */
|
||||
export interface AuthRequest {
|
||||
readonly headers: Readonly<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
export interface AuthzDeps {
|
||||
readonly verifier: CapabilityVerifier
|
||||
readonly expectedAud: string // admin audience the token must be scoped to (Host-confusion guard)
|
||||
readonly now?: () => number
|
||||
}
|
||||
|
||||
function extractRawToken(req: AuthRequest): string | null {
|
||||
const auth = req.headers['authorization']
|
||||
if (typeof auth === 'string' && auth.startsWith('Bearer ')) return auth.slice('Bearer '.length).trim()
|
||||
// Also accept the WS-upgrade subprotocol transport (FIX 5), for parity with the relay edge.
|
||||
const proto = req.headers['sec-websocket-protocol']
|
||||
if (typeof proto === 'string') {
|
||||
const values = proto.split(',').map((v) => v.trim())
|
||||
return extractTokenFromSubprotocols(values)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export interface Authorizer {
|
||||
principalFromRequest(req: AuthRequest): AdminPrincipal
|
||||
requireRight(principal: AdminPrincipal, right: CapabilityRight): void
|
||||
}
|
||||
|
||||
export function createAuthorizer(deps: AuthzDeps): Authorizer {
|
||||
const now = deps.now ?? (() => Math.floor(Date.now() / 1000))
|
||||
return {
|
||||
principalFromRequest(req) {
|
||||
const raw = extractRawToken(req)
|
||||
if (raw === null) throw new AuthzError(401, 'missing capability token')
|
||||
let token: CapabilityToken
|
||||
try {
|
||||
token = deps.verifier.verify(raw, deps.expectedAud, now())
|
||||
} catch (err: unknown) {
|
||||
throw new AuthzError(401, `invalid capability token: ${err instanceof Error ? err.message : 'rejected'}`)
|
||||
}
|
||||
// accountId derives ONLY from the token principal — never from a client field (INV3).
|
||||
return { accountId: token.sub, rights: token.rights }
|
||||
},
|
||||
requireRight(principal, right) {
|
||||
if (!principal.rights.includes(right)) {
|
||||
throw new AuthzError(403, `token scope lacks required right: ${right}`)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
137
control-plane/src/api/provision.ts
Normal file
137
control-plane/src/api/provision.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* T11 — provisioning / deprovisioning HTTP routes. ALL admin routes are deny-by-default and scope
|
||||
* every operation to `principal.accountId` (INV3/INV6). Host-targeting routes re-check
|
||||
* `ownsHost` (INV1). No route resolves a host by a user-supplied address. `/enroll` is the one
|
||||
* route NOT gated by a capability token (it IS the auth bootstrap) but is guarded by the single-use
|
||||
* pairing code (T8). Every body is Zod-validated at the boundary; malformed → 400.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import type { PlanTier } from 'relay-contracts'
|
||||
import type { Authorizer, AdminPrincipal } from './authz.js'
|
||||
import { AuthzError } from './authz.js'
|
||||
import type { AccountRegistry } from '../registry/accounts.js'
|
||||
import type { HostRegistry } from '../registry/hosts.js'
|
||||
import type { PairingIssuer } from '../pairing/issue.js'
|
||||
import type { PairingRedeemer } from '../pairing/redeem.js'
|
||||
import { RedeemError } from '../pairing/redeem.js'
|
||||
import type { Deprovisioner } from '../deprovision/deprovision.js'
|
||||
|
||||
export interface ProvisionDeps {
|
||||
readonly authorizer: Authorizer
|
||||
readonly accounts: AccountRegistry
|
||||
readonly hosts: HostRegistry
|
||||
readonly pairingIssuer: PairingIssuer
|
||||
readonly redeemer: PairingRedeemer
|
||||
readonly deprovisioner: Deprovisioner
|
||||
}
|
||||
|
||||
const PlanSchema = z.enum(['free', 'personal', 'pro', 'team'])
|
||||
const StatusSchema = z.object({ status: z.enum(['active', 'suspended']) })
|
||||
const EnrollSchema = z.object({
|
||||
code: z.string().min(1),
|
||||
agentPubkey: z.string().min(1), // base64
|
||||
csr: z.string().min(1), // base64
|
||||
})
|
||||
|
||||
function sendError(reply: FastifyReply, err: unknown): void {
|
||||
if (err instanceof AuthzError) {
|
||||
void reply.code(err.status).send({ error: err.message })
|
||||
return
|
||||
}
|
||||
if (err instanceof RedeemError) {
|
||||
const status = err.code === 'too_many_attempts' ? 429 : 400
|
||||
void reply.code(status).send({ error: err.code })
|
||||
return
|
||||
}
|
||||
void reply.code(400).send({ error: err instanceof Error ? err.message : 'bad request' })
|
||||
}
|
||||
|
||||
/** Enforce that the path :id equals the authenticated principal's account (INV1). */
|
||||
function assertOwnAccount(principal: AdminPrincipal, pathAccountId: string): void {
|
||||
if (principal.accountId !== pathAccountId) {
|
||||
throw new AuthzError(403, 'account scope mismatch') // never operate on another tenant
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
||||
return async (app) => {
|
||||
const principal = (req: FastifyRequest): AdminPrincipal =>
|
||||
deps.authorizer.principalFromRequest({ headers: req.headers })
|
||||
|
||||
app.post('/accounts', async (req, reply) => {
|
||||
try {
|
||||
const p = principal(req)
|
||||
deps.authorizer.requireRight(p, 'manage')
|
||||
const plan: PlanTier = PlanSchema.parse((req.body as { plan?: unknown })?.plan ?? 'free')
|
||||
const account = await deps.accounts.createAccount(plan)
|
||||
await reply.code(201).send(account)
|
||||
} catch (err) {
|
||||
sendError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/accounts/:id/pairing-codes', async (req, reply) => {
|
||||
try {
|
||||
const p = principal(req)
|
||||
deps.authorizer.requireRight(p, 'manage')
|
||||
assertOwnAccount(p, (req.params as { id: string }).id)
|
||||
const issued = await deps.pairingIssuer.issuePairingCode(p.accountId)
|
||||
await reply.code(201).send(issued)
|
||||
} catch (err) {
|
||||
sendError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/accounts/:id/status', async (req, reply) => {
|
||||
try {
|
||||
const p = principal(req)
|
||||
deps.authorizer.requireRight(p, 'manage')
|
||||
assertOwnAccount(p, (req.params as { id: string }).id)
|
||||
const { status } = StatusSchema.parse(req.body)
|
||||
const account = await deps.accounts.setAccountStatus(p.accountId, status)
|
||||
await reply.send(account)
|
||||
} catch (err) {
|
||||
sendError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/accounts/:id/hosts', async (req, reply) => {
|
||||
try {
|
||||
const p = principal(req)
|
||||
assertOwnAccount(p, (req.params as { id: string }).id)
|
||||
const hosts = await deps.hosts.listHosts(p.accountId) // ownership-scoped
|
||||
await reply.send(hosts.map((h) => ({ ...h, agentPubkey: Buffer.from(h.agentPubkey).toString('base64') })))
|
||||
} catch (err) {
|
||||
sendError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/hosts/:hostId', async (req, reply) => {
|
||||
try {
|
||||
const p = principal(req)
|
||||
deps.authorizer.requireRight(p, 'manage')
|
||||
// account_id in the body is IGNORED — authz uses ONLY the token principal (INV3).
|
||||
await deps.deprovisioner.deprovisionHost(p, (req.params as { hostId: string }).hostId)
|
||||
await reply.code(204).send()
|
||||
} catch (err) {
|
||||
sendError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/enroll', async (req, reply) => {
|
||||
// NOT capability-token gated — guarded by the single-use pairing code (T8).
|
||||
try {
|
||||
const body = EnrollSchema.parse(req.body)
|
||||
const result = await deps.redeemer.redeemPairingCode({
|
||||
code: body.code,
|
||||
agentPubkey: new Uint8Array(Buffer.from(body.agentPubkey, 'base64')),
|
||||
csr: new Uint8Array(Buffer.from(body.csr, 'base64')),
|
||||
})
|
||||
await reply.code(201).send({ ...result, hostContentSecret: Buffer.from(result.hostContentSecret).toString('base64') })
|
||||
} catch (err) {
|
||||
sendError(reply, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
118
control-plane/src/audit/log.ts
Normal file
118
control-plane/src/audit/log.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* T14 — immutable, ZERO-PAYLOAD audit writer (INV10). Single funnel that P5 also imports for
|
||||
* attach/manage/kill/revoke events. Append-only: no update/delete path is exposed. A payload
|
||||
* guard rejects any `meta` value that could smuggle terminal output (length cap + control/ANSI
|
||||
* bytes), so keystrokes/output/secrets can never land in `audit_log`.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import type { AuditStore, AuditRow } from '../store/ports.js'
|
||||
|
||||
export type AuditAction =
|
||||
| 'account.create'
|
||||
| 'account.suspend'
|
||||
| 'host.bind'
|
||||
| 'host.revoke'
|
||||
| 'pairing.issue'
|
||||
| 'pairing.redeem'
|
||||
| 'subdomain.assign'
|
||||
| 'node.drain'
|
||||
| 'attach'
|
||||
| 'manage'
|
||||
| 'kill'
|
||||
| 'revoke'
|
||||
|
||||
export interface AuditEntry {
|
||||
readonly action: AuditAction
|
||||
readonly principalId: string
|
||||
readonly accountId: string
|
||||
readonly hostId: string | null
|
||||
readonly ts: string
|
||||
readonly meta: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/** Max length of a single metadata value — small enough that no terminal frame fits (INV10). */
|
||||
export const MAX_META_VALUE_LEN = 256
|
||||
/** Max number of metadata keys per entry. */
|
||||
export const MAX_META_KEYS = 16
|
||||
|
||||
const AuditActionSchema = z.enum([
|
||||
'account.create',
|
||||
'account.suspend',
|
||||
'host.bind',
|
||||
'host.revoke',
|
||||
'pairing.issue',
|
||||
'pairing.redeem',
|
||||
'subdomain.assign',
|
||||
'node.drain',
|
||||
'attach',
|
||||
'manage',
|
||||
'kill',
|
||||
'revoke',
|
||||
])
|
||||
|
||||
/** True if the string contains any C0 control byte (0x00–0x1f) — incl. ESC that begins ANSI. */
|
||||
function hasControlBytes(v: string): boolean {
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
if (v.charCodeAt(i) < 0x20) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const MetaValue = z
|
||||
.string()
|
||||
.max(MAX_META_VALUE_LEN, 'meta value exceeds zero-payload cap')
|
||||
.refine((v) => !hasControlBytes(v), 'meta value contains control/ANSI bytes (possible terminal payload)')
|
||||
|
||||
const AuditEntrySchema = z
|
||||
.object({
|
||||
action: AuditActionSchema,
|
||||
principalId: z.string().min(1),
|
||||
accountId: z.string().min(1),
|
||||
hostId: z.string().nullable(),
|
||||
ts: z.string().datetime({ offset: true }),
|
||||
meta: z.record(z.string(), MetaValue).refine((m) => Object.keys(m).length <= MAX_META_KEYS, {
|
||||
message: 'too many meta keys',
|
||||
}),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export interface AuditWriter {
|
||||
writeAuditEvent(entry: AuditEntry): Promise<void>
|
||||
queryAudit(accountId: string, from: string, to: string): Promise<readonly AuditEntry[]>
|
||||
}
|
||||
|
||||
export function createAuditLog(store: AuditStore): AuditWriter {
|
||||
return {
|
||||
async writeAuditEvent(entry) {
|
||||
const parsed = AuditEntrySchema.safeParse(entry)
|
||||
if (!parsed.success) {
|
||||
throw new Error(`audit payload guard rejected entry: ${parsed.error.issues[0]?.message ?? 'invalid'}`)
|
||||
}
|
||||
const row: AuditRow = { ...parsed.data }
|
||||
await store.append(row) // append-only, immutable
|
||||
},
|
||||
async queryAudit(accountId, from, to) {
|
||||
const rows = await store.query(accountId, from, to)
|
||||
return rows.map((r) => ({
|
||||
action: r.action as AuditAction,
|
||||
principalId: r.principalId,
|
||||
accountId: r.accountId,
|
||||
hostId: r.hostId,
|
||||
ts: r.ts,
|
||||
meta: r.meta,
|
||||
}))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** No-op writer for tasks that treat audit as an optional injected dependency in v0.9. */
|
||||
export function noopAuditWriter(): AuditWriter {
|
||||
return {
|
||||
async writeAuditEvent() {
|
||||
/* intentionally does nothing */
|
||||
},
|
||||
async queryAudit() {
|
||||
return []
|
||||
},
|
||||
}
|
||||
}
|
||||
75
control-plane/src/boot/ca-wiring.ts
Normal file
75
control-plane/src/boot/ca-wiring.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* T15 — CA-key / secret-manager bootstrap + KMS-signer factory (§3.1). The intermediate
|
||||
* leaf-signing key is a non-exportable KMS/HSM key: signing is a `sign()` CALL, the raw private
|
||||
* key is NEVER loaded into control-plane memory. `buildCaSigner` FAILS FAST at startup (INV9) if
|
||||
* the key ref is unresolvable OR its policy does not restrict `sign` to the control-plane
|
||||
* service principal. Both T8 `signHostLeaf` and T15 `renewHostLeaf` call this shared primitive —
|
||||
* neither loads a raw key.
|
||||
*/
|
||||
import { createPublicKey } from 'node:crypto'
|
||||
import type { ControlPlaneEnv } from '../env.js'
|
||||
import { ed25519Sign, generateEd25519, type KeyObject } from '../util/crypto.js'
|
||||
|
||||
/** Wraps KMS `sign()`; no raw key ever crosses this boundary. */
|
||||
export interface CaSigner {
|
||||
sign(tbsCert: Uint8Array): Promise<Uint8Array>
|
||||
/** Public verifying key of the CA (for tests / chain assembly). */
|
||||
readonly publicKeyRaw: Uint8Array
|
||||
}
|
||||
|
||||
/**
|
||||
* KMS resolver seam. Production resolves a real KMS/HSM key handle + policy; tests inject a fake.
|
||||
* A resolver returns `{ canSign, policyRestrictedToServicePrincipal }` for the given ref, or throws
|
||||
* if the ref cannot be resolved at all.
|
||||
*/
|
||||
export interface KmsResolver {
|
||||
resolve(keyRef: string): Promise<{
|
||||
readonly signer: CaSigner
|
||||
readonly policyRestrictedToServicePrincipal: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the CA signer for the configured intermediate KMS key. Fail-fast (INV9, §3.1):
|
||||
* - throws if the resolver cannot resolve the ref;
|
||||
* - throws if the KMS key policy is broader than the control-plane service principal.
|
||||
*/
|
||||
export async function buildCaSigner(env: ControlPlaneEnv, resolver: KmsResolver): Promise<CaSigner> {
|
||||
let resolved
|
||||
try {
|
||||
resolved = await resolver.resolve(env.caIntermediateKmsKeyRef)
|
||||
} catch (err: unknown) {
|
||||
// Never log the ref VALUE — only that resolution failed (INV9).
|
||||
throw new Error(
|
||||
`CA intermediate KMS key could not be resolved: ${err instanceof Error ? err.message : 'unknown'}`,
|
||||
)
|
||||
}
|
||||
if (!resolved.policyRestrictedToServicePrincipal) {
|
||||
throw new Error(
|
||||
'CA intermediate KMS key policy is broader than the control-plane service principal (§3.1) — refusing to boot',
|
||||
)
|
||||
}
|
||||
return resolved.signer
|
||||
}
|
||||
|
||||
/**
|
||||
* In-process Ed25519 CA signer — TEST/DEV ONLY (clearly NOT a KMS; the plan mandates a real
|
||||
* non-exportable KMS key in production, §3.1). Exposes the same `sign()` surface so callers are
|
||||
* KMS-shaped and never touch a raw key path themselves.
|
||||
*/
|
||||
export function inProcessCaSigner(privateKey?: KeyObject): CaSigner {
|
||||
const key = privateKey ?? generateEd25519().privateKey
|
||||
const pub = derivePublicRaw(key)
|
||||
return {
|
||||
publicKeyRaw: pub,
|
||||
async sign(tbsCert) {
|
||||
return ed25519Sign(key, tbsCert)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function derivePublicRaw(privateKey: KeyObject): Uint8Array {
|
||||
// Recover the raw 32-byte Ed25519 public key from the private KeyObject.
|
||||
const spki = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) as Buffer
|
||||
return new Uint8Array(spki.subarray(spki.length - 32))
|
||||
}
|
||||
53
control-plane/src/ca/csr.ts
Normal file
53
control-plane/src/ca/csr.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* CSR proof-of-possession (INV14). Models a PKCS#10 CSR as `embeddedPub(32) || sig(64)` where
|
||||
* `sig = Ed25519(privateKey, CSR_CHALLENGE || embeddedPub)`. Verifying the signature against the
|
||||
* embedded pubkey proves the requester holds the matching private key (the exact property a real
|
||||
* PKCS#10 self-signature provides) — using builtin crypto only.
|
||||
*
|
||||
* INTEGRATION SEAM: production parses a real PKCS#10 DER (`@peculiar/x509`) and checks the
|
||||
* self-signature. Swapping it in does not change `signHostLeaf`'s check ORDER or reject path.
|
||||
*/
|
||||
import { ed25519Sign, ed25519Verify, type KeyObject } from '../util/crypto.js'
|
||||
|
||||
const CSR_CHALLENGE = new TextEncoder().encode('relay-cp/csr-pop/v1|')
|
||||
|
||||
/** Test/agent helper: build a CSR proving possession of `privateKey` for `embeddedPub`. */
|
||||
export function buildCsr(privateKey: KeyObject, embeddedPub: Uint8Array): Uint8Array {
|
||||
const message = concat(CSR_CHALLENGE, embeddedPub)
|
||||
const sig = ed25519Sign(privateKey, message)
|
||||
return concat(embeddedPub, sig)
|
||||
}
|
||||
|
||||
export interface CsrParts {
|
||||
readonly embeddedPub: Uint8Array
|
||||
readonly sig: Uint8Array
|
||||
}
|
||||
|
||||
/** Parse the modelled CSR bytes. Throws on wrong length. */
|
||||
export function parseCsr(csr: Uint8Array): CsrParts {
|
||||
if (csr.length !== 96) throw new Error('malformed csr')
|
||||
return { embeddedPub: csr.subarray(0, 32), sig: csr.subarray(32, 96) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify CSR proof-of-possession: the embedded pubkey's private key signed the challenge.
|
||||
* Independent of any registry state (a forged signature is refused even for a registered key).
|
||||
*/
|
||||
export function verifyCsrPoP(csr: Uint8Array): { ok: boolean; embeddedPub: Uint8Array } {
|
||||
let parts: CsrParts
|
||||
try {
|
||||
parts = parseCsr(csr)
|
||||
} catch {
|
||||
return { ok: false, embeddedPub: new Uint8Array(0) }
|
||||
}
|
||||
const message = concat(CSR_CHALLENGE, parts.embeddedPub)
|
||||
const ok = ed25519Verify(parts.embeddedPub, message, parts.sig)
|
||||
return { ok, embeddedPub: parts.embeddedPub }
|
||||
}
|
||||
|
||||
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(a.length + b.length)
|
||||
out.set(a, 0)
|
||||
out.set(b, a.length)
|
||||
return out
|
||||
}
|
||||
6
control-plane/src/ca/fingerprint.ts
Normal file
6
control-plane/src/ca/fingerprint.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** Enrollment fingerprint of an agent public key: SHA-256 hex (pinned by the browser for E2E TOFU, §4.4). */
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
export function fingerprint(agentPubkey: Uint8Array): string {
|
||||
return createHash('sha256').update(agentPubkey).digest('hex')
|
||||
}
|
||||
55
control-plane/src/ca/rotate.ts
Normal file
55
control-plane/src/ca/rotate.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* T15 — CA leaf RENEWAL (INV14). Re-signs a short-TTL leaf for an already-bound, non-revoked host
|
||||
* under the current intermediate. SAME guards as T8 `signHostLeaf`: (1) CSR proof-of-possession
|
||||
* against the embedded pubkey; (2) embedded pubkey == the host's registered `agent_pubkey`;
|
||||
* (3) host active/non-revoked. Any failure → same reject path. A drained/revoked host cannot renew
|
||||
* (closes the INV12+INV14 loop). Signing = `CaSigner.sign()` (KMS, §3.1), never a raw key.
|
||||
* Distinct file from T8 (`ca/sign.ts`) — shares only the KMS-signer primitive.
|
||||
*/
|
||||
import type { HostStore } from '../store/ports.js'
|
||||
import type { CaSigner } from '../boot/ca-wiring.js'
|
||||
import { verifyCsrPoP } from './csr.js'
|
||||
import { LeafSignError } from './sign.js'
|
||||
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
|
||||
|
||||
export interface LeafRenewerDeps {
|
||||
readonly hosts: HostStore
|
||||
readonly signer: CaSigner
|
||||
readonly caChainDer: readonly Uint8Array[]
|
||||
readonly leafTtlSec?: number
|
||||
}
|
||||
|
||||
export interface LeafRenewer {
|
||||
renewHostLeaf(
|
||||
hostId: string,
|
||||
csr: Uint8Array,
|
||||
): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
|
||||
}
|
||||
|
||||
const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
|
||||
|
||||
export function createLeafRenewer(deps: LeafRenewerDeps): LeafRenewer {
|
||||
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
|
||||
return {
|
||||
async renewHostLeaf(hostId, csr) {
|
||||
const host = await deps.hosts.get(hostId)
|
||||
// A revoked/absent host cannot renew (INV12 + INV14).
|
||||
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
|
||||
|
||||
const pop = verifyCsrPoP(csr)
|
||||
if (!pop.ok) throw new LeafSignError('csr_rejected')
|
||||
// embedded pubkey must equal the host's REGISTERED pubkey (no key substitution on renewal).
|
||||
if (!timingSafeEqualBytes(pop.embeddedPub, host.agentPubkey)) throw new LeafSignError('csr_rejected')
|
||||
|
||||
const notAfter = Math.floor(Date.now() / 1000) + ttl
|
||||
const tbs = new TextEncoder().encode(
|
||||
JSON.stringify({ v: 1, hostId, subjectSpki: bytesToBase64(host.agentPubkey), notAfter, renewed: true }),
|
||||
)
|
||||
const sig = await deps.signer.sign(tbs) // KMS sign(); no raw key
|
||||
const cert = new TextEncoder().encode(
|
||||
JSON.stringify({ tbs: bytesToBase64(tbs), sig: bytesToBase64(sig) }),
|
||||
)
|
||||
return { cert, caChain: deps.caChainDer }
|
||||
},
|
||||
}
|
||||
}
|
||||
70
control-plane/src/ca/sign.ts
Normal file
70
control-plane/src/ca/sign.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* T8 — bind-time mTLS leaf signer, registry-gated (INV14 registry half). ORDER OF CHECKS (all
|
||||
* must pass, SAME reject path):
|
||||
* 1. CSR proof-of-possession — verify the CSR self-signature against its embedded pubkey.
|
||||
* 2. embedded pubkey == caller-supplied `agentPubkey` (no substitution).
|
||||
* 3. (hostId, agentPubkey) is an ACTIVE, non-revoked row in the host registry.
|
||||
* A failure at ANY step rejects identically; the KMS `sign()` is NEVER invoked when any check
|
||||
* fails. Signing itself is `CaSigner.sign()` (KMS, §3.1) — never a raw private key in memory.
|
||||
*/
|
||||
import type { HostStore } from '../store/ports.js'
|
||||
import type { CaSigner } from '../boot/ca-wiring.js'
|
||||
import { verifyCsrPoP } from './csr.js'
|
||||
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
|
||||
|
||||
export class LeafSignError extends Error {
|
||||
constructor(public readonly code: 'csr_rejected' | 'not_registered') {
|
||||
super('leaf signing refused') // uniform message — do not leak which check failed
|
||||
}
|
||||
}
|
||||
|
||||
export interface LeafSignerDeps {
|
||||
readonly hosts: HostStore
|
||||
readonly signer: CaSigner
|
||||
readonly caChainDer: readonly Uint8Array[]
|
||||
/** Leaf validity in seconds (short-lived, INV14). Default 24h. */
|
||||
readonly leafTtlSec?: number
|
||||
}
|
||||
|
||||
export interface LeafSigner {
|
||||
signHostLeaf(
|
||||
hostId: string,
|
||||
agentPubkey: Uint8Array,
|
||||
csr: Uint8Array,
|
||||
): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
|
||||
}
|
||||
|
||||
const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
|
||||
|
||||
export function createLeafSigner(deps: LeafSignerDeps): LeafSigner {
|
||||
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
|
||||
return {
|
||||
async signHostLeaf(hostId, agentPubkey, csr) {
|
||||
// 1. proof-of-possession (independent of registry state)
|
||||
const pop = verifyCsrPoP(csr)
|
||||
if (!pop.ok) throw new LeafSignError('csr_rejected')
|
||||
// 2. no substitution: CSR pubkey must equal the presented agentPubkey
|
||||
if (!timingSafeEqualBytes(pop.embeddedPub, agentPubkey)) throw new LeafSignError('csr_rejected')
|
||||
// 3. registry gate: host bound, active, non-revoked, pubkey matches (INV14)
|
||||
const host = await deps.hosts.get(hostId)
|
||||
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
|
||||
if (!timingSafeEqualBytes(host.agentPubkey, agentPubkey)) throw new LeafSignError('not_registered')
|
||||
|
||||
// Only now do we invoke KMS sign() over the to-be-signed leaf.
|
||||
const notAfter = Math.floor(Date.now() / 1000) + ttl
|
||||
const tbs = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
hostId,
|
||||
subjectSpki: bytesToBase64(agentPubkey), // subject pubkey == agentPubkey (assertable in tests)
|
||||
notAfter,
|
||||
}),
|
||||
)
|
||||
const sig = await deps.signer.sign(tbs)
|
||||
const cert = new TextEncoder().encode(
|
||||
JSON.stringify({ tbs: bytesToBase64(tbs), sig: bytesToBase64(sig) }),
|
||||
)
|
||||
return { cert, caChain: deps.caChainDer }
|
||||
},
|
||||
}
|
||||
}
|
||||
31
control-plane/src/db/pool.ts
Normal file
31
control-plane/src/db/pool.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* T2 — Postgres pool + typed `query` wrapper. PARAMETERIZED ONLY: the wrapper accepts
|
||||
* `(sql, params)` and passes params to the driver separately — there is NO string-interpolation
|
||||
* path, so SQL injection via value concatenation is structurally impossible (INV5-adjacent, §7).
|
||||
*
|
||||
* The client is injected (`Queryable`) so the wrapper is unit-testable without a live DB; the real
|
||||
* `pg.Pool` is constructed by `createPgPool`. Applying the migrations + mapping the repository ports
|
||||
* to SQL over this wrapper is the Testcontainers integration seam (PLAN §10).
|
||||
*/
|
||||
import pg from 'pg'
|
||||
|
||||
/** Minimal driver surface the wrapper needs — satisfied by `pg.Pool` and `pg.PoolClient`. */
|
||||
export interface Queryable {
|
||||
query(text: string, params: readonly unknown[]): Promise<{ rows: unknown[] }>
|
||||
}
|
||||
|
||||
export type QueryFn = <T>(sql: string, params: readonly unknown[]) => Promise<readonly T[]>
|
||||
|
||||
/** Build a parameterized-only query function over any `Queryable`. */
|
||||
export function createQuery(client: Queryable): QueryFn {
|
||||
return async <T>(sql: string, params: readonly unknown[]): Promise<readonly T[]> => {
|
||||
// Params ALWAYS travel separately from the SQL text — never interpolated into it.
|
||||
const result = await client.query(sql, params)
|
||||
return result.rows as T[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Construct a real Postgres connection pool (production). */
|
||||
export function createPgPool(connectionString: string): pg.Pool & Queryable {
|
||||
return new pg.Pool({ connectionString })
|
||||
}
|
||||
34
control-plane/src/deprovision/deprovision.ts
Normal file
34
control-plane/src/deprovision/deprovision.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* T11 — v0.9 MINIMAL deprovision (self-contained; superseded by T13 fast path in v0.10). Requires
|
||||
* `ownsHost(principal.accountId, hostId)` (INV1/INV6), then `setHostStatus('revoked')` (T4 versioned
|
||||
* snapshot) + `dropRoute` (T9). Best-effort tunnel drop only — NOT the INV12 within-seconds
|
||||
* guarantee (that is T13's `revokeHost`). Depends on T4 + T9 ONLY; no dependency on v0.10.
|
||||
*/
|
||||
import type { HostRegistry } from '../registry/hosts.js'
|
||||
import type { RoutingTable } from '../routing/table.js'
|
||||
import type { AdminPrincipal } from '../api/authz.js'
|
||||
import { AuthzError } from '../api/authz.js'
|
||||
|
||||
export interface Deprovisioner {
|
||||
deprovisionHost(principal: AdminPrincipal, hostId: string): Promise<void>
|
||||
}
|
||||
|
||||
export interface DeprovisionDeps {
|
||||
readonly hosts: HostRegistry
|
||||
readonly routing: RoutingTable
|
||||
}
|
||||
|
||||
export function createDeprovisioner(deps: DeprovisionDeps): Deprovisioner {
|
||||
return {
|
||||
async deprovisionHost(principal, hostId) {
|
||||
if (!(await deps.hosts.ownsHost(principal.accountId, hostId))) {
|
||||
throw new AuthzError(403, 'not authorized for this host') // INV1: 403, never a 404 leak
|
||||
}
|
||||
const host = await deps.hosts.getHost(hostId)
|
||||
if (host !== null && host.status !== 'revoked') {
|
||||
await deps.hosts.setHostStatus(hostId, 'revoked')
|
||||
}
|
||||
await deps.routing.systemDropRoute(hostId)
|
||||
},
|
||||
}
|
||||
}
|
||||
96
control-plane/src/env.ts
Normal file
96
control-plane/src/env.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* T1 — startup config/secret validation (INV9): secrets come from env/secret-manager,
|
||||
* are Zod-validated at startup, fail fast, and are NEVER logged (only key NAMES on failure).
|
||||
*
|
||||
* The CA intermediate signing key is referenced by a KMS/HSM ref only — never inlined,
|
||||
* never loaded raw into process memory (§3.1). The KMS key-policy check runs in
|
||||
* `boot/ca-wiring.ts` (T15 `buildCaSigner`) with an injected resolver, because it needs a
|
||||
* live KMS call; `loadEnv` validates only the presence/shape of the ref here.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { base64ToBytes } from './util/bytes.js'
|
||||
|
||||
/** Pairing-code TTL default: 10 minutes (§5 T7). */
|
||||
export const DEFAULT_PAIRING_TTL_SEC = 600
|
||||
/** Per-code redemption lockout threshold default (§5 T7/T8). */
|
||||
export const DEFAULT_PAIRING_MAX_REDEEM_ATTEMPTS = 5
|
||||
|
||||
export interface ControlPlaneEnv {
|
||||
readonly pgUrl: string
|
||||
readonly redisUrl: string
|
||||
/** P5 signing PUBLIC key, used to VERIFY admin capability tokens (INV3). */
|
||||
readonly capabilitySignPubkey: Uint8Array
|
||||
/** KMS/HSM key REF for the non-exportable intermediate leaf-signing key (§3.1). */
|
||||
readonly caIntermediateKmsKeyRef: string
|
||||
/** Intermediate cert (public) + chain up to the offline root. */
|
||||
readonly caIntermediateCertPath: string
|
||||
/** CA bundle used to VERIFY relay-node mTLS client certs (T9 node-auth). */
|
||||
readonly nodeMtlsTrustBundlePath: string
|
||||
/** 'term.<domain>' for subdomain assembly. */
|
||||
readonly baseDomain: string
|
||||
readonly heartbeatTtlSec: number
|
||||
readonly pairingTtlSec: number
|
||||
readonly pairingMaxRedeemAttempts: number
|
||||
}
|
||||
|
||||
/** A positive integer parsed from an env string, or a default when unset/empty. */
|
||||
const intWithDefault = (fallback: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === undefined || v === '' ? String(fallback) : v))
|
||||
.pipe(z.coerce.number().int().positive())
|
||||
|
||||
const requiredString = (name: string) =>
|
||||
z.string({ required_error: `${name} is required` }).trim().min(1, `${name} must not be empty`)
|
||||
|
||||
const EnvSchema = z.object({
|
||||
PG_URL: requiredString('PG_URL').url('PG_URL must be a valid connection URL'),
|
||||
REDIS_URL: requiredString('REDIS_URL').url('REDIS_URL must be a valid connection URL'),
|
||||
CAPABILITY_SIGN_PUBKEY_B64: requiredString('CAPABILITY_SIGN_PUBKEY_B64'),
|
||||
CA_INTERMEDIATE_KMS_KEY_REF: requiredString('CA_INTERMEDIATE_KMS_KEY_REF'),
|
||||
CA_INTERMEDIATE_CERT_PATH: requiredString('CA_INTERMEDIATE_CERT_PATH'),
|
||||
NODE_MTLS_TRUST_BUNDLE_PATH: requiredString('NODE_MTLS_TRUST_BUNDLE_PATH'),
|
||||
BASE_DOMAIN: requiredString('BASE_DOMAIN'),
|
||||
HEARTBEAT_TTL_SEC: intWithDefault(15),
|
||||
PAIRING_TTL_SEC: intWithDefault(DEFAULT_PAIRING_TTL_SEC),
|
||||
PAIRING_MAX_REDEEM_ATTEMPTS: intWithDefault(DEFAULT_PAIRING_MAX_REDEEM_ATTEMPTS),
|
||||
})
|
||||
|
||||
/**
|
||||
* Parse + validate control-plane config. THROWS (fail-fast) listing every missing/invalid
|
||||
* key by NAME. Never echoes secret VALUES (INV9).
|
||||
*/
|
||||
export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv {
|
||||
const parsed = EnvSchema.safeParse(source)
|
||||
if (!parsed.success) {
|
||||
const problems = parsed.error.issues
|
||||
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
|
||||
.join('; ')
|
||||
// Only key names + messages — never values (INV9).
|
||||
throw new Error(`Invalid control-plane env: ${problems}`)
|
||||
}
|
||||
const e = parsed.data
|
||||
let capabilitySignPubkey: Uint8Array
|
||||
try {
|
||||
capabilitySignPubkey = base64ToBytes(e.CAPABILITY_SIGN_PUBKEY_B64)
|
||||
} catch {
|
||||
throw new Error('Invalid control-plane env: CAPABILITY_SIGN_PUBKEY_B64 is not valid base64')
|
||||
}
|
||||
if (capabilitySignPubkey.length !== 32) {
|
||||
throw new Error('Invalid control-plane env: CAPABILITY_SIGN_PUBKEY_B64 must decode to 32 bytes (Ed25519)')
|
||||
}
|
||||
return {
|
||||
pgUrl: e.PG_URL,
|
||||
redisUrl: e.REDIS_URL,
|
||||
capabilitySignPubkey,
|
||||
caIntermediateKmsKeyRef: e.CA_INTERMEDIATE_KMS_KEY_REF,
|
||||
caIntermediateCertPath: e.CA_INTERMEDIATE_CERT_PATH,
|
||||
nodeMtlsTrustBundlePath: e.NODE_MTLS_TRUST_BUNDLE_PATH,
|
||||
baseDomain: e.BASE_DOMAIN,
|
||||
heartbeatTtlSec: e.HEARTBEAT_TTL_SEC,
|
||||
pairingTtlSec: e.PAIRING_TTL_SEC,
|
||||
pairingMaxRedeemAttempts: e.PAIRING_MAX_REDEEM_ATTEMPTS,
|
||||
}
|
||||
}
|
||||
71
control-plane/src/flat-store.ts
Normal file
71
control-plane/src/flat-store.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* T0 (v0.8 MVP) — flat account store + manual provisioning. Deliberately minimal: no
|
||||
* immutable-record ceremony (that arrives in CP1/T3-T4, which RETIRE this shim). It exists
|
||||
* only to gate the café demo. All INV5 guarantees (hashes-at-rest, constant-time compare,
|
||||
* raw-secret-returned-once) are honoured and re-shipped properly in CP1.
|
||||
*
|
||||
* Backing store is injectable. The default is in-memory; production points it at the
|
||||
* `db/flat.sqlite.sql` schema (SQLite) — an integration seam, not exercised in unit tests.
|
||||
*/
|
||||
import { randomUUID, randomBytes } from 'node:crypto'
|
||||
import { hashSecret, verifySecret } from './util/hash.js'
|
||||
|
||||
export interface FlatAccount {
|
||||
readonly accountId: string
|
||||
readonly subdomain: string
|
||||
readonly agentTokenHash: string
|
||||
readonly clientTokenHash: string
|
||||
}
|
||||
|
||||
export interface FlatBackend {
|
||||
insert(row: FlatAccount): Promise<void>
|
||||
bySubdomain(subdomain: string): Promise<FlatAccount | null>
|
||||
}
|
||||
|
||||
/** In-memory backend (default). SQLite backend is the production swap (see db/flat.sqlite.sql). */
|
||||
export function inMemoryFlatBackend(): FlatBackend {
|
||||
const rows = new Map<string, FlatAccount>()
|
||||
return {
|
||||
async insert(row) {
|
||||
if (rows.has(row.subdomain)) throw new Error(`subdomain already provisioned: ${row.subdomain}`)
|
||||
rows.set(row.subdomain, row)
|
||||
},
|
||||
async bySubdomain(subdomain) {
|
||||
return rows.get(subdomain) ?? null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface FlatStore {
|
||||
provisionFlat(subdomain: string): Promise<{ accountId: string; agentToken: string; clientToken: string }>
|
||||
lookupBySubdomain(subdomain: string): Promise<FlatAccount | null>
|
||||
verifyAgentToken(subdomain: string, raw: string): Promise<boolean>
|
||||
}
|
||||
|
||||
const newToken = (): string => randomBytes(32).toString('base64url')
|
||||
|
||||
export function createFlatStore(backend: FlatBackend = inMemoryFlatBackend()): FlatStore {
|
||||
return {
|
||||
async provisionFlat(subdomain) {
|
||||
const agentToken = newToken()
|
||||
const clientToken = newToken()
|
||||
const row: FlatAccount = {
|
||||
accountId: randomUUID(),
|
||||
subdomain,
|
||||
agentTokenHash: hashSecret(agentToken), // hash at rest — raw discarded (INV5)
|
||||
clientTokenHash: hashSecret(clientToken),
|
||||
}
|
||||
await backend.insert(row)
|
||||
// Raw tokens returned exactly ONCE at mint time; never persisted.
|
||||
return { accountId: row.accountId, agentToken, clientToken }
|
||||
},
|
||||
async lookupBySubdomain(subdomain) {
|
||||
return backend.bySubdomain(subdomain)
|
||||
},
|
||||
async verifyAgentToken(subdomain, raw) {
|
||||
const row = await backend.bySubdomain(subdomain)
|
||||
if (row === null) return false
|
||||
return verifySecret(raw, row.agentTokenHash) // constant-time compare of hashes
|
||||
},
|
||||
}
|
||||
}
|
||||
103
control-plane/src/main.ts
Normal file
103
control-plane/src/main.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* T11 — control-plane bootstrap. Wires env → stores → services → Fastify routes. The runnable
|
||||
* v0.9-MVP default uses the in-memory stores; swapping in the Postgres/Redis-backed adapters
|
||||
* (db/pool.ts + db/migrations + an ioredis client) is the Testcontainers integration step (PLAN §10).
|
||||
*
|
||||
* CROSS-PACKAGE INTEGRATION POINTS (injected, not hard-imported):
|
||||
* - `CapabilityVerifier` — P5 owns `verifyCapabilityToken` + the signing key. Injected here; the
|
||||
* default stub REFUSES all tokens (fail-closed) until P5 is wired.
|
||||
* - `RevocationBus` — publish side over Redis `relay:revocations` (P1 subscribes). Default is the
|
||||
* in-memory bus for single-process dev.
|
||||
* - `KmsResolver` — P5/§3.1 KMS custody of the CA intermediate key. Default is an in-process
|
||||
* Ed25519 signer (DEV ONLY — NOT a real KMS).
|
||||
*/
|
||||
import Fastify, { type FastifyInstance } from 'fastify'
|
||||
import type { ControlPlaneEnv } from './env.js'
|
||||
import { createMemoryStores } from './store/memory.js'
|
||||
import type { Stores } from './store/ports.js'
|
||||
import { createAuditLog } from './audit/log.js'
|
||||
import { createAccountRegistry } from './registry/accounts.js'
|
||||
import { createHostRegistry } from './registry/hosts.js'
|
||||
import { createSessionRegistry } from './registry/sessions.js'
|
||||
import { createSubdomainAssigner } from './subdomain/assign.js'
|
||||
import { createPairingIssuer } from './pairing/issue.js'
|
||||
import { createPairingRedeemer } from './pairing/redeem.js'
|
||||
import { createLeafSigner } from './ca/sign.js'
|
||||
import { createRoutingTable } from './routing/table.js'
|
||||
import { createInMemoryRevocationBus, type TestableRevocationBus } from './routing/bus.js'
|
||||
import { createMeteringCollector } from './metering/collect.js'
|
||||
import { createDeprovisioner } from './deprovision/deprovision.js'
|
||||
import { createAuthorizer, type CapabilityVerifier } from './api/authz.js'
|
||||
import { buildRouter } from './api/provision.js'
|
||||
import { buildCaSigner, inProcessCaSigner, type KmsResolver } from './boot/ca-wiring.js'
|
||||
import type { RevocationBus } from 'relay-contracts'
|
||||
|
||||
export interface ControlPlaneOverrides {
|
||||
readonly stores?: Stores
|
||||
readonly verifier?: CapabilityVerifier
|
||||
readonly bus?: RevocationBus & Partial<TestableRevocationBus>
|
||||
readonly kmsResolver?: KmsResolver
|
||||
readonly caChainDer?: readonly Uint8Array[]
|
||||
}
|
||||
|
||||
/** Default fail-closed verifier — refuses everything until P5 is wired (INV6). */
|
||||
const refuseAllVerifier: CapabilityVerifier = {
|
||||
verify() {
|
||||
throw new Error('capability verification not configured (P5 integration point)')
|
||||
},
|
||||
}
|
||||
|
||||
/** In-process KMS resolver — DEV ONLY. Production injects a real non-exportable KMS key (§3.1). */
|
||||
function devKmsResolver(): KmsResolver {
|
||||
const signer = inProcessCaSigner()
|
||||
return {
|
||||
async resolve() {
|
||||
return { signer, policyRestrictedToServicePrincipal: true }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildControlPlane(
|
||||
env: ControlPlaneEnv,
|
||||
overrides: ControlPlaneOverrides = {},
|
||||
): Promise<{ app: FastifyInstance; stores: Stores }> {
|
||||
const stores = overrides.stores ?? createMemoryStores()
|
||||
const bus: RevocationBus = overrides.bus ?? createInMemoryRevocationBus()
|
||||
const caChainDer = overrides.caChainDer ?? []
|
||||
|
||||
const audit = createAuditLog(stores.audit)
|
||||
const accounts = createAccountRegistry({ accounts: stores.accounts, audit })
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts, audit })
|
||||
createSessionRegistry({ sessions: stores.sessions, hosts: stores.hosts })
|
||||
const subdomains = createSubdomainAssigner({ subdomains: stores.subdomains, audit })
|
||||
|
||||
const caSigner = await buildCaSigner(env, overrides.kmsResolver ?? devKmsResolver())
|
||||
const leafSigner = createLeafSigner({ hosts: stores.hosts, signer: caSigner, caChainDer })
|
||||
|
||||
const pairingIssuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: env.pairingTtlSec, audit })
|
||||
const redeemer = createPairingRedeemer({
|
||||
pairing: stores.pairing,
|
||||
hosts,
|
||||
subdomains,
|
||||
leafSigner,
|
||||
pairingMaxRedeemAttempts: env.pairingMaxRedeemAttempts,
|
||||
audit,
|
||||
})
|
||||
|
||||
const routing = createRoutingTable({
|
||||
routes: stores.routes,
|
||||
nodeStatus: async (nodeId) => (await stores.nodes.get(nodeId))?.status ?? null,
|
||||
})
|
||||
createMeteringCollector({ metering: stores.metering, hosts })
|
||||
const deprovisioner = createDeprovisioner({ hosts, routing })
|
||||
void bus // reserved for the node-coordinator / revoker wiring (drain + revoke publishers)
|
||||
|
||||
const authorizer = createAuthorizer({
|
||||
verifier: overrides.verifier ?? refuseAllVerifier,
|
||||
expectedAud: env.baseDomain,
|
||||
})
|
||||
|
||||
const app = Fastify({ logger: false })
|
||||
await app.register(buildRouter({ authorizer, accounts, hosts, pairingIssuer, redeemer, deprovisioner }))
|
||||
return { app, stores }
|
||||
}
|
||||
86
control-plane/src/metering/collect.ts
Normal file
86
control-plane/src/metering/collect.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* T12 — billing-metering hooks (EXPLORE §6): meter PAIRED HOSTS + CONCURRENT VIEWERS (not
|
||||
* bandwidth). The sample's `nodeId` is the caller's authenticated mTLS identity (INV3-analog) and
|
||||
* its `accountId` is re-derived SERVER-SIDE from the host registry (INV1) — a node-supplied account
|
||||
* is never trusted. Samples are append-only immutable rows (INV8); zero terminal payload (INV10).
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import type { MeteringSampleRow } from '../model/records.js'
|
||||
import type { MeteringStore } from '../store/ports.js'
|
||||
import type { HostRegistry } from '../registry/hosts.js'
|
||||
import type { NodeIdentity } from '../node-auth/identity.js'
|
||||
|
||||
export interface MeteringSample {
|
||||
readonly hostId: string
|
||||
readonly concurrentViewers: number
|
||||
readonly sampledAt: string
|
||||
// NO client/node-supplied accountId or nodeId — both are derived server-side.
|
||||
}
|
||||
|
||||
const MeteringSampleSchema = z
|
||||
.object({
|
||||
hostId: z.string().uuid(),
|
||||
concurrentViewers: z.number().int().nonnegative(),
|
||||
sampledAt: z.string().datetime({ offset: true }),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export class MeteringError extends Error {}
|
||||
|
||||
export interface UsageRollup {
|
||||
readonly pairedHostPeak: number
|
||||
readonly viewerPeak: number
|
||||
readonly viewerHours: number
|
||||
}
|
||||
|
||||
export interface MeteringCollector {
|
||||
ingestSample(caller: NodeIdentity, sample: MeteringSample): Promise<void>
|
||||
pairedHostCount(accountId: string): Promise<number>
|
||||
rollupUsage(accountId: string, from: string, to: string): Promise<UsageRollup>
|
||||
}
|
||||
|
||||
export interface MeteringDeps {
|
||||
readonly metering: MeteringStore
|
||||
readonly hosts: HostRegistry
|
||||
}
|
||||
|
||||
export function createMeteringCollector(deps: MeteringDeps): MeteringCollector {
|
||||
return {
|
||||
async ingestSample(caller, sample) {
|
||||
const parsed = MeteringSampleSchema.safeParse(sample)
|
||||
if (!parsed.success) throw new MeteringError(`invalid metering sample: ${parsed.error.issues[0]?.message}`)
|
||||
const host = await deps.hosts.getHost(parsed.data.hostId)
|
||||
// Attribution derived from ownership — a hostId the caller can't substantiate is rejected (INV1).
|
||||
if (host === null) throw new MeteringError('unknown hostId — cannot attribute usage')
|
||||
const row: MeteringSampleRow = {
|
||||
hostId: parsed.data.hostId,
|
||||
accountId: host.accountId, // server-derived, never node-asserted
|
||||
nodeId: caller.nodeId, // authenticated identity, never a body field
|
||||
concurrentViewers: parsed.data.concurrentViewers,
|
||||
sampledAt: parsed.data.sampledAt,
|
||||
}
|
||||
await deps.metering.append(row) // append-only (INV8)
|
||||
},
|
||||
async pairedHostCount(accountId) {
|
||||
const hosts = await deps.hosts.listHosts(accountId)
|
||||
return hosts.filter((h) => h.status !== 'revoked').length
|
||||
},
|
||||
async rollupUsage(accountId, from, to) {
|
||||
const samples = [...(await deps.metering.query(accountId, from, to))].sort(
|
||||
(a, b) => Date.parse(a.sampledAt) - Date.parse(b.sampledAt),
|
||||
)
|
||||
const viewerPeak = samples.reduce((m, s) => Math.max(m, s.concurrentViewers), 0)
|
||||
const distinctHosts = new Set(samples.map((s) => s.hostId))
|
||||
// Step-function integral of concurrent viewers over the window → viewer-hours.
|
||||
const toMs = Date.parse(to)
|
||||
let viewerHours = 0
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const cur = samples[i] as MeteringSampleRow
|
||||
const nextMs = i + 1 < samples.length ? Date.parse((samples[i + 1] as MeteringSampleRow).sampledAt) : toMs
|
||||
const spanHours = Math.max(0, nextMs - Date.parse(cur.sampledAt)) / 3_600_000
|
||||
viewerHours += cur.concurrentViewers * spanHours
|
||||
}
|
||||
return { pairedHostPeak: distinctHosts.size, viewerPeak, viewerHours }
|
||||
},
|
||||
}
|
||||
}
|
||||
49
control-plane/src/model/records.ts
Normal file
49
control-plane/src/model/records.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* T2 — control-plane record types. The frozen §4.2/§4.5 shapes are RE-EXPORTED from
|
||||
* relay-contracts, never redefined locally (INDEX §2 rule 1).
|
||||
*
|
||||
* OQ1 RESOLUTION: `AccountRecord`/`SessionRecord`/`PairingCodeRecord` were promoted into
|
||||
* relay-contracts (`model/account.ts`, `pairing/enroll.ts`), so this module re-exports them
|
||||
* instead of mirroring the SQL locally. The only P3-local additions are the INV8 companion
|
||||
* status-version ROW types (OQ6 — pending INDEX promotion of the `*_status_versions` tables).
|
||||
*/
|
||||
export type {
|
||||
AccountRecord,
|
||||
AccountStatus,
|
||||
SessionRecord,
|
||||
HostRecord,
|
||||
HostStatus,
|
||||
PlanTier,
|
||||
RouteEntry,
|
||||
} from 'relay-contracts'
|
||||
export type { PairingCodeRecord, EnrollResult } from 'relay-contracts'
|
||||
|
||||
import type { AccountStatus, HostStatus } from 'relay-contracts'
|
||||
|
||||
/** Append-only companion row for `accounts.status` versioning (INV8, OQ6). */
|
||||
export interface AccountStatusVersionRow {
|
||||
readonly accountId: string
|
||||
readonly version: number
|
||||
readonly status: AccountStatus
|
||||
readonly changedAt: string
|
||||
readonly changedBy: string
|
||||
}
|
||||
|
||||
/** Append-only companion row for `hosts.status`/`revoked_at` versioning (INV8, OQ6). */
|
||||
export interface HostStatusVersionRow {
|
||||
readonly hostId: string
|
||||
readonly version: number
|
||||
readonly status: HostStatus
|
||||
readonly revokedAt: string | null
|
||||
readonly changedAt: string
|
||||
readonly changedBy: string
|
||||
}
|
||||
|
||||
/** Immutable metering sample row (append-only, INV8). */
|
||||
export interface MeteringSampleRow {
|
||||
readonly hostId: string
|
||||
readonly accountId: string
|
||||
readonly nodeId: string
|
||||
readonly concurrentViewers: number
|
||||
readonly sampledAt: string
|
||||
}
|
||||
56
control-plane/src/node-auth/identity.ts
Normal file
56
control-plane/src/node-auth/identity.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* T9 — relay-node ↔ control-plane trust boundary (the node analog of INV3). `nodeId` is derived
|
||||
* from the VERIFIED relay-node mTLS client-cert subject — NEVER from a body/query/header field.
|
||||
* A request that carries a `nodeId` payload has it IGNORED for identity. Non-mutually-authenticated
|
||||
* connections throw 401.
|
||||
*
|
||||
* OQ5 (open): who ISSUES/ROTATES the node service certs + the SVID format the CP pins
|
||||
* (`nodeMtlsTrustBundlePath`) is a P5/P1 boundary. Here we consume an already-verified peer cert.
|
||||
*/
|
||||
export interface NodeIdentity {
|
||||
readonly nodeId: string
|
||||
}
|
||||
|
||||
export class NodeAuthError extends Error {
|
||||
readonly status = 401
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** The shape we need from a verified TLS peer certificate (subset of Node's PeerCertificate). */
|
||||
export interface VerifiedPeerCert {
|
||||
readonly authorized: boolean // TLS stack verified the client cert against the trust bundle
|
||||
readonly subjectCommonName: string | null // SPIFFE-style node id in the cert subject CN / SAN URI
|
||||
}
|
||||
|
||||
/** Pure derivation — the request wrapper below feeds it the extracted peer cert. */
|
||||
export function deriveNodeIdentity(cert: VerifiedPeerCert | null): NodeIdentity {
|
||||
if (cert === null || !cert.authorized) {
|
||||
throw new NodeAuthError('relay-node connection is not mutually authenticated')
|
||||
}
|
||||
const cn = cert.subjectCommonName
|
||||
if (cn === null || cn.trim() === '') {
|
||||
throw new NodeAuthError('relay-node client cert has no subject identity')
|
||||
}
|
||||
return { nodeId: cn }
|
||||
}
|
||||
|
||||
/** Minimal request shape carrying a TLS socket (Fastify/Node). */
|
||||
export interface RequestWithTls {
|
||||
readonly socket: {
|
||||
authorized?: boolean
|
||||
getPeerCertificate?: () => { subject?: { CN?: string } } | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the verified node identity from a live request's TLS session. INTEGRATION SEAM: the
|
||||
* exact SAN/URI SVID parsing is finalized with OQ5; here we read authorized + subject CN.
|
||||
*/
|
||||
export function nodeIdentityFromRequest(req: RequestWithTls): NodeIdentity {
|
||||
const authorized = req.socket.authorized === true
|
||||
const peer = req.socket.getPeerCertificate?.()
|
||||
const cn = peer?.subject?.CN ?? null
|
||||
return deriveNodeIdentity({ authorized, subjectCommonName: cn })
|
||||
}
|
||||
41
control-plane/src/pairing/code.ts
Normal file
41
control-plane/src/pairing/code.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Pairing-code format (Finding-4). The code is the SOLE credential gating `/enroll`, so it is
|
||||
* 26 Crockford-base32 characters = 130 bits of real entropy (NOT the ~32-bit `ABCD-1234` shape,
|
||||
* which is rejected). Displayed grouped for humans as 6 dash-separated groups; a paste/QR-first
|
||||
* credential, not a type-from-memory PIN.
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
/** Crockford base32 alphabet (excludes I L O U to avoid confusion). */
|
||||
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
|
||||
export const PAIRING_CODE_LEN = 26 // 26 * 5 bits = 130 bits
|
||||
export const PAIRING_CODE_BITS = PAIRING_CODE_LEN * 5
|
||||
|
||||
/** Generate a canonical (undashed, uppercase) 26-char Crockford code. */
|
||||
export function generatePairingCode(): string {
|
||||
// Draw enough random bytes, map 5 bits per char.
|
||||
const bytes = randomBytes(PAIRING_CODE_LEN)
|
||||
let out = ''
|
||||
for (let i = 0; i < PAIRING_CODE_LEN; i++) {
|
||||
out += CROCKFORD[(bytes[i] as number) & 0x1f]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Group the canonical code for display: XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-X. */
|
||||
export function formatPairingCode(canonical: string): string {
|
||||
return (canonical.match(/.{1,5}/g) ?? []).join('-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a user-presented code back to canonical form: uppercase, strip dashes/spaces, map
|
||||
* Crockford-confusable characters (I/L→1, O→0). Redemption hashes THIS canonical form.
|
||||
*/
|
||||
export function normalizePairingCode(raw: string): string {
|
||||
return raw
|
||||
.toUpperCase()
|
||||
.replace(/[\s-]/g, '')
|
||||
.replace(/[IL]/g, '1')
|
||||
.replace(/O/g, '0')
|
||||
.replace(/U/g, 'V')
|
||||
}
|
||||
52
control-plane/src/pairing/issue.ts
Normal file
52
control-plane/src/pairing/issue.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* T7 — pairing-code issuance (INDEX §4.5 step 1). Mints a single-use short-TTL code; stores
|
||||
* `{ code_hash, account_id, expires_at, redeem_attempts:0 }` — the RAW code is never stored
|
||||
* (INV5). `accountId` comes from the authenticated principal (INV3), never a request field.
|
||||
*/
|
||||
import type { PairingCodeRecord } from '../model/records.js'
|
||||
import type { PairingStore } from '../store/ports.js'
|
||||
import type { AuditWriter } from '../audit/log.js'
|
||||
import { noopAuditWriter } from '../audit/log.js'
|
||||
import { generatePairingCode, formatPairingCode } from './code.js'
|
||||
import { sha256Hex } from '../util/hash.js'
|
||||
import { nowIso } from '../util/ids.js'
|
||||
|
||||
export interface IssuedPairing {
|
||||
readonly code: string // display-grouped, returned ONCE
|
||||
readonly expiresAt: string
|
||||
}
|
||||
|
||||
export interface PairingIssuer {
|
||||
issuePairingCode(accountId: string): Promise<IssuedPairing>
|
||||
}
|
||||
|
||||
export interface PairingIssuerDeps {
|
||||
readonly pairing: PairingStore
|
||||
readonly pairingTtlSec: number
|
||||
readonly audit?: AuditWriter
|
||||
readonly actor?: string
|
||||
}
|
||||
|
||||
export function createPairingIssuer(deps: PairingIssuerDeps): PairingIssuer {
|
||||
const audit = deps.audit ?? noopAuditWriter()
|
||||
const actor = deps.actor ?? 'system'
|
||||
return {
|
||||
async issuePairingCode(accountId) {
|
||||
const canonical = generatePairingCode()
|
||||
const codeHash = sha256Hex(canonical) // deterministic hash of a 130-bit input (INV5)
|
||||
const expiresAt = new Date(Date.now() + deps.pairingTtlSec * 1000).toISOString()
|
||||
const record: PairingCodeRecord = { codeHash, accountId, expiresAt, redeemedAt: null }
|
||||
await deps.pairing.insert(record)
|
||||
await audit.writeAuditEvent({
|
||||
action: 'pairing.issue',
|
||||
principalId: actor,
|
||||
accountId,
|
||||
hostId: null,
|
||||
ts: nowIso(),
|
||||
meta: { expiresAt },
|
||||
})
|
||||
// Raw code returned ONCE, display-grouped; never persisted.
|
||||
return { code: formatPairingCode(canonical), expiresAt }
|
||||
},
|
||||
}
|
||||
}
|
||||
131
control-plane/src/pairing/redeem.ts
Normal file
131
control-plane/src/pairing/redeem.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* T8 — pairing-code redemption + BIND + bind-time cert + content-secret mint (INDEX §4.5 3–5).
|
||||
* ATOMIC single-use: `casRedeem` compare-and-sets `redeemed_at` so exactly one concurrent caller
|
||||
* wins (no double-spend). A CODE-SCOPED lockout (Finding-4) counts failed redemptions per
|
||||
* `code_hash` and locks after `pairingMaxRedeemAttempts` — independent of P5's per-tenant limiter.
|
||||
* `accountId` is taken from the pairing ROW (INV3), never the agent's request. Returns the FROZEN
|
||||
* `EnrollResult` (imported from relay-contracts, never redefined).
|
||||
*/
|
||||
import type { EnrollResult } from 'relay-contracts'
|
||||
import type { PairingStore } from '../store/ports.js'
|
||||
import type { HostRegistry } from '../registry/hosts.js'
|
||||
import type { SubdomainAssigner } from '../subdomain/assign.js'
|
||||
import type { LeafSigner } from '../ca/sign.js'
|
||||
import type { AuditWriter } from '../audit/log.js'
|
||||
import { noopAuditWriter } from '../audit/log.js'
|
||||
import { verifyCsrPoP } from '../ca/csr.js'
|
||||
import { fingerprint } from '../ca/fingerprint.js'
|
||||
import { sha256Hex } from '../util/hash.js'
|
||||
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
|
||||
import { sealToX25519 } from '../util/crypto.js'
|
||||
import { normalizePairingCode } from './code.js'
|
||||
import { nowIso } from '../util/ids.js'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
export type RedeemErrorCode =
|
||||
| 'unknown'
|
||||
| 'expired'
|
||||
| 'already_redeemed'
|
||||
| 'too_many_attempts'
|
||||
| 'bad_csr'
|
||||
|
||||
export class RedeemError extends Error {
|
||||
constructor(public readonly code: RedeemErrorCode) {
|
||||
super(`pairing redemption refused: ${code}`)
|
||||
}
|
||||
}
|
||||
|
||||
export interface RedeemInput {
|
||||
readonly code: string
|
||||
readonly agentPubkey: Uint8Array
|
||||
readonly csr: Uint8Array
|
||||
}
|
||||
|
||||
/**
|
||||
* FIX 3 — mint + WRAP the host-scoped content secret at BIND. A per-host 32-byte CSPRNG secret
|
||||
* is sealed to the host's enrolled identity; the raw secret is NEVER stored or logged (INV5/INV9)
|
||||
* and each wrap is distinct (per-host revocable, INV12). P2 unwraps locally with its private key.
|
||||
*/
|
||||
export async function mintHostContentSecret(agentPubkey: Uint8Array): Promise<Uint8Array> {
|
||||
const secret = new Uint8Array(randomBytes(32))
|
||||
const wrapped = sealToX25519(agentPubkey, secret)
|
||||
secret.fill(0) // best-effort scrub of the raw secret
|
||||
return wrapped
|
||||
}
|
||||
|
||||
export interface RedeemDeps {
|
||||
readonly pairing: PairingStore
|
||||
readonly hosts: HostRegistry
|
||||
readonly subdomains: SubdomainAssigner
|
||||
readonly leafSigner: LeafSigner
|
||||
readonly pairingMaxRedeemAttempts: number
|
||||
readonly audit?: AuditWriter
|
||||
/** Injectable content-secret minter (test seam); defaults to `mintHostContentSecret`. */
|
||||
readonly mintSecret?: (agentPubkey: Uint8Array) => Promise<Uint8Array>
|
||||
}
|
||||
|
||||
export interface PairingRedeemer {
|
||||
redeemPairingCode(input: RedeemInput): Promise<EnrollResult>
|
||||
}
|
||||
|
||||
function toPem(label: string, der: Uint8Array): string {
|
||||
const b64 = bytesToBase64(der)
|
||||
const lines = b64.match(/.{1,64}/g) ?? [b64]
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
|
||||
}
|
||||
|
||||
export function createPairingRedeemer(deps: RedeemDeps): PairingRedeemer {
|
||||
const audit = deps.audit ?? noopAuditWriter()
|
||||
const mint = deps.mintSecret ?? mintHostContentSecret
|
||||
return {
|
||||
async redeemPairingCode(input) {
|
||||
const codeHash = sha256Hex(normalizePairingCode(input.code))
|
||||
const row = await deps.pairing.get(codeHash)
|
||||
if (row === null) throw new RedeemError('unknown') // blind guess; P5 limiter covers volume
|
||||
|
||||
// Lockout FIRST — a locked code is refused even with a correct code (Finding-4).
|
||||
if (row.redeemAttempts >= deps.pairingMaxRedeemAttempts) throw new RedeemError('too_many_attempts')
|
||||
if (Date.parse(row.record.expiresAt) <= Date.now()) throw new RedeemError('expired')
|
||||
if (row.record.redeemedAt !== null) throw new RedeemError('already_redeemed')
|
||||
|
||||
// CSR proof-of-possession + no-substitution. A failure counts toward the code-scoped lockout.
|
||||
const pop = verifyCsrPoP(input.csr)
|
||||
if (!pop.ok || !timingSafeEqualBytes(pop.embeddedPub, input.agentPubkey)) {
|
||||
await deps.pairing.registerFailure(codeHash)
|
||||
throw new RedeemError('bad_csr')
|
||||
}
|
||||
|
||||
// Atomic single-use CAS (double-spend guard) — exactly one concurrent caller wins.
|
||||
const cas = await deps.pairing.casRedeem(codeHash, nowIso())
|
||||
if (cas !== 'ok') throw new RedeemError('already_redeemed')
|
||||
|
||||
const accountId = row.record.accountId // from the pairing row (INV3)
|
||||
const subdomain = await deps.subdomains.assignSubdomain(accountId)
|
||||
const host = await deps.hosts.bindHost({
|
||||
accountId,
|
||||
subdomain,
|
||||
agentPubkey: input.agentPubkey,
|
||||
enrollFpr: fingerprint(input.agentPubkey),
|
||||
})
|
||||
const leaf = await deps.leafSigner.signHostLeaf(host.hostId, input.agentPubkey, input.csr)
|
||||
const hostContentSecret = await mint(input.agentPubkey)
|
||||
|
||||
await audit.writeAuditEvent({
|
||||
action: 'pairing.redeem',
|
||||
principalId: `host:${host.hostId}`,
|
||||
accountId,
|
||||
hostId: host.hostId,
|
||||
ts: nowIso(),
|
||||
meta: { subdomain },
|
||||
})
|
||||
|
||||
return {
|
||||
hostId: host.hostId,
|
||||
subdomain,
|
||||
cert: toPem('CERTIFICATE', leaf.cert),
|
||||
caChain: leaf.caChain.map((der) => toPem('CERTIFICATE', der)).join(''),
|
||||
hostContentSecret,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
63
control-plane/src/registry/accounts.ts
Normal file
63
control-plane/src/registry/accounts.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* T3 — account registry (immutable, INV8). `createAccount` writes a version-1 snapshot;
|
||||
* `setAccountStatus` performs the atomic append-version + pointer-swap (prior snapshot stays
|
||||
* readable from `versions()`). Never mutates a lifecycle field in place.
|
||||
*/
|
||||
import type { AccountRecord, AccountStatus } from '../model/records.js'
|
||||
import type { AccountStore } from '../store/ports.js'
|
||||
import type { AuditWriter } from '../audit/log.js'
|
||||
import { noopAuditWriter } from '../audit/log.js'
|
||||
import type { PlanTier } from 'relay-contracts'
|
||||
import { newUuid, nowIso } from '../util/ids.js'
|
||||
|
||||
export interface AccountRegistry {
|
||||
createAccount(plan: PlanTier): Promise<AccountRecord>
|
||||
getAccount(accountId: string): Promise<AccountRecord | null>
|
||||
setAccountStatus(accountId: string, status: AccountStatus): Promise<AccountRecord>
|
||||
}
|
||||
|
||||
export interface AccountRegistryDeps {
|
||||
readonly accounts: AccountStore
|
||||
readonly audit?: AuditWriter
|
||||
readonly actor?: string
|
||||
}
|
||||
|
||||
export function createAccountRegistry(deps: AccountRegistryDeps): AccountRegistry {
|
||||
const audit = deps.audit ?? noopAuditWriter()
|
||||
const actor = deps.actor ?? 'system'
|
||||
return {
|
||||
async createAccount(plan) {
|
||||
const rec: AccountRecord = {
|
||||
accountId: newUuid(), // unguessable, never recycled (INV1 precondition)
|
||||
plan,
|
||||
createdAt: nowIso(),
|
||||
status: 'active',
|
||||
}
|
||||
await deps.accounts.insert(rec)
|
||||
await audit.writeAuditEvent({
|
||||
action: 'account.create',
|
||||
principalId: actor,
|
||||
accountId: rec.accountId,
|
||||
hostId: null,
|
||||
ts: rec.createdAt,
|
||||
meta: { plan },
|
||||
})
|
||||
return rec
|
||||
},
|
||||
async getAccount(accountId) {
|
||||
return deps.accounts.get(accountId)
|
||||
},
|
||||
async setAccountStatus(accountId, status) {
|
||||
const next = await deps.accounts.swapStatus(accountId, status, actor) // NEW snapshot, atomic swap
|
||||
await audit.writeAuditEvent({
|
||||
action: status === 'suspended' ? 'account.suspend' : 'manage',
|
||||
principalId: actor,
|
||||
accountId,
|
||||
hostId: null,
|
||||
ts: nowIso(),
|
||||
meta: { status },
|
||||
})
|
||||
return next
|
||||
},
|
||||
}
|
||||
}
|
||||
103
control-plane/src/registry/hosts.ts
Normal file
103
control-plane/src/registry/hosts.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* T4 — host registry: the ownership source of truth (INV1). A host is resolvable ONLY via
|
||||
* `hostId`/`subdomain` bound to an account — there is NO raw address/port/hostname resolution
|
||||
* path in this module. `ownsHost` is the deny-by-default ownership predicate (INV1/INV6). DB
|
||||
* stores only the PUBLIC Ed25519 key (INV4). Status changes are versioned snapshots (INV8).
|
||||
*/
|
||||
import type { HostRecord, HostStatus } from '../model/records.js'
|
||||
import type { HostStore } from '../store/ports.js'
|
||||
import type { AuditWriter } from '../audit/log.js'
|
||||
import { noopAuditWriter } from '../audit/log.js'
|
||||
import { newUuid, nowIso } from '../util/ids.js'
|
||||
import { fingerprint } from '../ca/fingerprint.js'
|
||||
|
||||
export interface BindHostInput {
|
||||
readonly accountId: string
|
||||
readonly subdomain: string
|
||||
readonly agentPubkey: Uint8Array
|
||||
readonly enrollFpr: string
|
||||
}
|
||||
|
||||
export interface HostRegistry {
|
||||
bindHost(input: BindHostInput): Promise<HostRecord>
|
||||
getHost(hostId: string): Promise<HostRecord | null>
|
||||
getHostBySubdomain(subdomain: string): Promise<HostRecord | null>
|
||||
listHosts(accountId: string): Promise<readonly HostRecord[]>
|
||||
setHostStatus(hostId: string, status: HostStatus): Promise<HostRecord>
|
||||
ownsHost(accountId: string, hostId: string): Promise<boolean>
|
||||
touchLastSeen(hostId: string): Promise<void>
|
||||
}
|
||||
|
||||
export interface HostRegistryDeps {
|
||||
readonly hosts: HostStore
|
||||
readonly audit?: AuditWriter
|
||||
readonly actor?: string
|
||||
}
|
||||
|
||||
export function createHostRegistry(deps: HostRegistryDeps): HostRegistry {
|
||||
const audit = deps.audit ?? noopAuditWriter()
|
||||
const actor = deps.actor ?? 'system'
|
||||
return {
|
||||
async bindHost(input) {
|
||||
// enroll_fpr MUST be the fingerprint of the presented pubkey (defence-in-depth: recompute).
|
||||
const expectedFpr = fingerprint(input.agentPubkey)
|
||||
if (input.enrollFpr !== expectedFpr) {
|
||||
throw new Error('enrollFpr does not match agentPubkey fingerprint')
|
||||
}
|
||||
const rec: HostRecord = {
|
||||
hostId: newUuid(), // unguessable UUIDv4, never recycled (INV1)
|
||||
accountId: input.accountId,
|
||||
subdomain: input.subdomain,
|
||||
// Defensive copy → fresh ArrayBuffer-backed array (immutability + TS6 variance). PUBLIC key only (INV4).
|
||||
agentPubkey: new Uint8Array(input.agentPubkey),
|
||||
enrollFpr: input.enrollFpr,
|
||||
status: 'offline',
|
||||
lastSeen: nowIso(),
|
||||
createdAt: nowIso(),
|
||||
revokedAt: null,
|
||||
}
|
||||
await deps.hosts.insert(rec)
|
||||
await audit.writeAuditEvent({
|
||||
action: 'host.bind',
|
||||
principalId: actor,
|
||||
accountId: input.accountId,
|
||||
hostId: rec.hostId,
|
||||
ts: rec.createdAt,
|
||||
meta: { subdomain: input.subdomain, enrollFpr: input.enrollFpr },
|
||||
})
|
||||
return rec
|
||||
},
|
||||
async getHost(hostId) {
|
||||
return deps.hosts.get(hostId)
|
||||
},
|
||||
async getHostBySubdomain(subdomain) {
|
||||
return deps.hosts.getBySubdomain(subdomain)
|
||||
},
|
||||
async listHosts(accountId) {
|
||||
return deps.hosts.listByAccount(accountId) // ownership-scoped
|
||||
},
|
||||
async setHostStatus(hostId, status) {
|
||||
const revokedAt = status === 'revoked' ? nowIso() : null
|
||||
const next = await deps.hosts.swapStatus(hostId, status, revokedAt, actor)
|
||||
if (status === 'revoked') {
|
||||
await audit.writeAuditEvent({
|
||||
action: 'host.revoke',
|
||||
principalId: actor,
|
||||
accountId: next.accountId,
|
||||
hostId,
|
||||
ts: nowIso(),
|
||||
meta: { status },
|
||||
})
|
||||
}
|
||||
return next
|
||||
},
|
||||
async ownsHost(accountId, hostId) {
|
||||
const host = await deps.hosts.get(hostId)
|
||||
// Deny-by-default: unknown host or account mismatch ⇒ false (INV1/INV6).
|
||||
return host !== null && host.accountId === accountId
|
||||
},
|
||||
async touchLastSeen(hostId) {
|
||||
await deps.hosts.touchLastSeen(hostId, nowIso()) // advisory, not versioned (INV7)
|
||||
},
|
||||
}
|
||||
}
|
||||
49
control-plane/src/registry/sessions.ts
Normal file
49
control-plane/src/registry/sessions.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* T5 — session registry. `account_id` is DENORMALIZED from the host at write time — NEVER
|
||||
* supplied by the caller/client (INV3) — giving an O(1) deny-by-default authz fact for reattach
|
||||
* (INV6). `sessionAccount` returns that server-derived owner; it never echoes a client value.
|
||||
*/
|
||||
import type { SessionRecord } from '../model/records.js'
|
||||
import type { HostStore, SessionStore } from '../store/ports.js'
|
||||
import { nowIso } from '../util/ids.js'
|
||||
|
||||
export interface RecordSessionInput {
|
||||
readonly sessionId: string
|
||||
readonly hostId: string
|
||||
}
|
||||
|
||||
export interface SessionRegistry {
|
||||
recordSession(input: RecordSessionInput): Promise<SessionRecord>
|
||||
getSession(sessionId: string): Promise<SessionRecord | null>
|
||||
sessionAccount(sessionId: string): Promise<string | null>
|
||||
}
|
||||
|
||||
export interface SessionRegistryDeps {
|
||||
readonly sessions: SessionStore
|
||||
readonly hosts: HostStore
|
||||
}
|
||||
|
||||
export function createSessionRegistry(deps: SessionRegistryDeps): SessionRegistry {
|
||||
return {
|
||||
async recordSession(input) {
|
||||
const host = await deps.hosts.get(input.hostId)
|
||||
if (host === null) throw new Error('cannot record session for unknown host')
|
||||
const rec: SessionRecord = {
|
||||
sessionId: input.sessionId,
|
||||
hostId: input.hostId,
|
||||
accountId: host.accountId, // derived from the host — caller cannot influence (INV3)
|
||||
createdAt: nowIso(),
|
||||
lastAttachAt: nowIso(),
|
||||
}
|
||||
await deps.sessions.insert(rec)
|
||||
return rec
|
||||
},
|
||||
async getSession(sessionId) {
|
||||
return deps.sessions.get(sessionId)
|
||||
},
|
||||
async sessionAccount(sessionId) {
|
||||
const s = await deps.sessions.get(sessionId)
|
||||
return s?.accountId ?? null // O(1) authz fact; server-derived, never client-supplied
|
||||
},
|
||||
}
|
||||
}
|
||||
109
control-plane/src/revoke/revoke.ts
Normal file
109
control-plane/src/revoke/revoke.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* T13 — revocation (global + per-host), INV12. The within-seconds tunnel-kill PUBLISHES a
|
||||
* KillSignal on the FROZEN `relay:revocations` bus (INDEX §4.2 FIX 4 — SAME channel T10 drain
|
||||
* uses); every P1 node subscribes and injects the §4.1 CLOSE+RST within REVOCATION_PUSH_BUDGET_MS.
|
||||
* KillSignal / RevocationScope / the channel + budget constants are IMPORTED from relay-contracts,
|
||||
* never redefined. `reason` is metadata only (INV10, zero payload). Revocation writes an immutable
|
||||
* host snapshot (INV8) and is idempotent.
|
||||
*/
|
||||
import { REVOCATION_PUSH_BUDGET_MS, type RevocationBus } from 'relay-contracts'
|
||||
import type { HostRegistry } from '../registry/hosts.js'
|
||||
import type { RoutingTable } from '../routing/table.js'
|
||||
import type { AuditWriter } from '../audit/log.js'
|
||||
import { noopAuditWriter } from '../audit/log.js'
|
||||
|
||||
export { REVOCATION_PUSH_BUDGET_MS }
|
||||
|
||||
/** Short-TTL token revocation store (Redis `revoked:{jti}` in prod). Shared key space with P5. */
|
||||
export interface RevokedTokenStore {
|
||||
revoke(jti: string, ttlSec: number): Promise<void>
|
||||
isRevoked(jti: string): Promise<boolean>
|
||||
}
|
||||
|
||||
/** In-memory implementation with TTL expiry (default; Redis is the production swap). */
|
||||
export function createInMemoryRevokedTokenStore(): RevokedTokenStore {
|
||||
const until = new Map<string, number>()
|
||||
return {
|
||||
async revoke(jti, ttlSec) {
|
||||
until.set(jti, Date.now() + Math.max(0, ttlSec) * 1000)
|
||||
},
|
||||
async isRevoked(jti) {
|
||||
const exp = until.get(jti)
|
||||
if (exp === undefined) return false
|
||||
if (exp <= Date.now()) {
|
||||
until.delete(jti)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface Revoker {
|
||||
revokeHost(hostId: string): Promise<void>
|
||||
revokeAccount(accountId: string): Promise<void>
|
||||
revokeToken(jti: string, expUnix: number): Promise<void>
|
||||
isTokenRevoked(jti: string): Promise<boolean>
|
||||
}
|
||||
|
||||
export interface RevokerDeps {
|
||||
readonly hosts: HostRegistry
|
||||
readonly routing: RoutingTable
|
||||
readonly bus: RevocationBus
|
||||
readonly tokens: RevokedTokenStore
|
||||
readonly audit?: AuditWriter
|
||||
readonly actor?: string
|
||||
}
|
||||
|
||||
export function createRevoker(deps: RevokerDeps): Revoker {
|
||||
const audit = deps.audit ?? noopAuditWriter()
|
||||
const actor = deps.actor ?? 'system'
|
||||
const at = () => Math.floor(Date.now() / 1000)
|
||||
return {
|
||||
async revokeHost(hostId) {
|
||||
const host = await deps.hosts.getHost(hostId)
|
||||
if (host === null) return // idempotent: nothing to revoke
|
||||
if (host.status !== 'revoked') {
|
||||
await deps.hosts.setHostStatus(hostId, 'revoked') // immutable snapshot (INV8) + audit host.revoke
|
||||
}
|
||||
await deps.routing.systemDropRoute(hostId) // resolveRoute → null after this
|
||||
await deps.bus.publish({ scope: { kind: 'host', hostId }, at: at(), reason: 'revoked' })
|
||||
await audit.writeAuditEvent({
|
||||
action: 'revoke',
|
||||
principalId: actor,
|
||||
accountId: host.accountId,
|
||||
hostId,
|
||||
ts: new Date().toISOString(),
|
||||
meta: { scope: 'host' },
|
||||
})
|
||||
},
|
||||
async revokeAccount(accountId) {
|
||||
const hosts = await deps.hosts.listHosts(accountId)
|
||||
for (const h of hosts) {
|
||||
if (h.status !== 'revoked') {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deps.hosts.setHostStatus(h.hostId, 'revoked')
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deps.routing.systemDropRoute(h.hostId)
|
||||
}
|
||||
// One account-scoped KillSignal; P1 tears down all of the account's live streams.
|
||||
await deps.bus.publish({ scope: { kind: 'account', accountId }, at: at(), reason: 'revoked' })
|
||||
await audit.writeAuditEvent({
|
||||
action: 'revoke',
|
||||
principalId: actor,
|
||||
accountId,
|
||||
hostId: null,
|
||||
ts: new Date().toISOString(),
|
||||
meta: { scope: 'account', hosts: String(hosts.length) },
|
||||
})
|
||||
},
|
||||
async revokeToken(jti, expUnix) {
|
||||
const ttlSec = Math.max(0, expUnix - Math.floor(Date.now() / 1000))
|
||||
await deps.tokens.revoke(jti, ttlSec)
|
||||
},
|
||||
async isTokenRevoked(jti) {
|
||||
return deps.tokens.isRevoked(jti)
|
||||
},
|
||||
}
|
||||
}
|
||||
49
control-plane/src/routing/bus.ts
Normal file
49
control-plane/src/routing/bus.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* RevocationBus implementations over the FROZEN `relay:revocations` channel (INDEX §4.2 FIX 4).
|
||||
* P3 only PUBLISHES (P1 owns the subscriber that injects §4.1 CLOSE+RST / GOAWAY). Drain (T10)
|
||||
* and revoke (T13) use the SAME named channel. The KillSignal shape + channel name are imported
|
||||
* from relay-contracts and NEVER redefined.
|
||||
*/
|
||||
import {
|
||||
RELAY_REVOCATIONS_CHANNEL,
|
||||
KillSignalSchema,
|
||||
type KillSignal,
|
||||
type RevocationBus,
|
||||
} from 'relay-contracts'
|
||||
|
||||
/** In-memory bus with a subscribe hook — used by tests and single-process wiring. */
|
||||
export interface TestableRevocationBus extends RevocationBus {
|
||||
subscribe(handler: (signal: KillSignal) => void): () => void
|
||||
readonly channel: typeof RELAY_REVOCATIONS_CHANNEL
|
||||
}
|
||||
|
||||
export function createInMemoryRevocationBus(): TestableRevocationBus {
|
||||
const handlers = new Set<(signal: KillSignal) => void>()
|
||||
return {
|
||||
channel: RELAY_REVOCATIONS_CHANNEL,
|
||||
async publish(signal) {
|
||||
// Validate at the boundary before it hits the wire (INV10: reason is metadata only).
|
||||
const parsed = KillSignalSchema.parse(signal)
|
||||
for (const h of handlers) h(parsed as KillSignal)
|
||||
},
|
||||
subscribe(handler) {
|
||||
handlers.add(handler)
|
||||
return () => handlers.delete(handler)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal publisher surface of an ioredis client (integration seam; typed, not unit-tested). */
|
||||
export interface RedisPublisher {
|
||||
publish(channel: string, message: string): Promise<number>
|
||||
}
|
||||
|
||||
/** Redis-backed publisher (production). P1 relay nodes SUBSCRIBE to the same channel. */
|
||||
export function createRedisRevocationBus(redis: RedisPublisher): RevocationBus {
|
||||
return {
|
||||
async publish(signal) {
|
||||
const parsed = KillSignalSchema.parse(signal)
|
||||
await redis.publish(RELAY_REVOCATIONS_CHANNEL, JSON.stringify(parsed))
|
||||
},
|
||||
}
|
||||
}
|
||||
67
control-plane/src/routing/nodes.ts
Normal file
67
control-plane/src/routing/nodes.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* T10 — relay-node registry + graceful drain (INV7). Every mutation is scoped to the caller's
|
||||
* authenticated `NodeIdentity`: a node can only register/heartbeat/drain ITSELF. Drain marks the
|
||||
* node 'draining', enumerates its live routes, and PUBLISHES a per-host `KillSignal` on the frozen
|
||||
* `relay:revocations` bus (INDEX §4.2 FIX 4 — SAME channel T13 revoke uses); P1 nodes translate it
|
||||
* into the §4.1 GOAWAY. This module does NOT encode the frame (that is P1). Drain touches NO
|
||||
* Postgres host/session row — the running task lives on the customer machine (PTY survives).
|
||||
*
|
||||
* OQ4 residual: the frozen RevocationScope has no `node` kind, so a single-node operator drain is
|
||||
* expressed as per-host KillSignals with a drain reason (flagged to the INDEX for a future scope).
|
||||
*/
|
||||
import type { RevocationBus } from 'relay-contracts'
|
||||
import type { NodeStore, RouteStore } from '../store/ports.js'
|
||||
import type { NodeIdentity } from '../node-auth/identity.js'
|
||||
import type { AuditWriter } from '../audit/log.js'
|
||||
import { noopAuditWriter } from '../audit/log.js'
|
||||
import { nowIso } from '../util/ids.js'
|
||||
|
||||
export const DRAIN_REASON = 'operatorDrain'
|
||||
|
||||
export interface NodeCoordinator {
|
||||
registerNode(caller: NodeIdentity, addr: string): Promise<void>
|
||||
nodeHeartbeat(caller: NodeIdentity): Promise<void>
|
||||
beginDrain(caller: NodeIdentity): Promise<{ readonly hostIds: readonly string[] }>
|
||||
completeDrain(caller: NodeIdentity): Promise<void>
|
||||
}
|
||||
|
||||
export interface NodeCoordinatorDeps {
|
||||
readonly nodes: NodeStore
|
||||
readonly routes: RouteStore
|
||||
readonly bus: RevocationBus
|
||||
readonly audit?: AuditWriter
|
||||
}
|
||||
|
||||
export function createNodeCoordinator(deps: NodeCoordinatorDeps): NodeCoordinator {
|
||||
const audit = deps.audit ?? noopAuditWriter()
|
||||
return {
|
||||
async registerNode(caller, addr) {
|
||||
await deps.nodes.upsert({ nodeId: caller.nodeId, addr, status: 'active', lastSeen: nowIso() })
|
||||
},
|
||||
async nodeHeartbeat(caller) {
|
||||
await deps.nodes.touch(caller.nodeId, nowIso())
|
||||
},
|
||||
async beginDrain(caller) {
|
||||
await deps.nodes.setStatus(caller.nodeId, 'draining')
|
||||
const routed = await deps.routes.listByNode(caller.nodeId)
|
||||
const at = Math.floor(Date.now() / 1000)
|
||||
for (const { hostId } of routed) {
|
||||
// Per-host KillSignal with a drain reason on the frozen bus (P1 emits GOAWAY).
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deps.bus.publish({ scope: { kind: 'host', hostId }, at, reason: DRAIN_REASON })
|
||||
}
|
||||
await audit.writeAuditEvent({
|
||||
action: 'node.drain',
|
||||
principalId: `node:${caller.nodeId}`,
|
||||
accountId: 'system',
|
||||
hostId: null,
|
||||
ts: nowIso(),
|
||||
meta: { nodeId: caller.nodeId, routes: String(routed.length) },
|
||||
})
|
||||
return { hostIds: routed.map((r) => r.hostId) }
|
||||
},
|
||||
async completeDrain(caller) {
|
||||
await deps.nodes.delete(caller.nodeId) // deregister after routes re-homed
|
||||
},
|
||||
}
|
||||
}
|
||||
62
control-plane/src/routing/table.ts
Normal file
62
control-plane/src/routing/table.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* T9 — Redis routing table (`route:{host_id}`, heartbeat-TTL, INV7). Redis holds only LOCATION,
|
||||
* never ownership or secrets; a missing/expired key fails CLOSED (host treated offline). Every
|
||||
* mutation is scoped to the AUTHENTICATED node identity: a node can only route hosts to ITSELF
|
||||
* (`entry.relayNodeId === caller.nodeId`) and can only heartbeat/drop a route it currently holds.
|
||||
*/
|
||||
import type { RouteEntry } from '../model/records.js'
|
||||
import type { NodeStatus, RouteStore } from '../store/ports.js'
|
||||
import type { NodeIdentity } from '../node-auth/identity.js'
|
||||
|
||||
export class RouteAuthError extends Error {}
|
||||
|
||||
export interface RoutingTable {
|
||||
upsertRoute(caller: NodeIdentity, hostId: string, entry: RouteEntry, ttlSec: number): Promise<void>
|
||||
heartbeatRoute(caller: NodeIdentity, hostId: string, ttlSec: number): Promise<void>
|
||||
resolveRoute(hostId: string): Promise<RouteEntry | null>
|
||||
dropRoute(caller: NodeIdentity, hostId: string): Promise<void>
|
||||
/** System teardown path (drain/revoke) — bypasses the holding-node check by design. */
|
||||
systemDropRoute(hostId: string): Promise<void>
|
||||
}
|
||||
|
||||
export interface RoutingTableDeps {
|
||||
readonly routes: RouteStore
|
||||
/** Optional node-status hook: a draining node stops receiving new upserts (T10). */
|
||||
readonly nodeStatus?: (nodeId: string) => Promise<NodeStatus | null>
|
||||
}
|
||||
|
||||
export function createRoutingTable(deps: RoutingTableDeps): RoutingTable {
|
||||
return {
|
||||
async upsertRoute(caller, hostId, entry, ttlSec) {
|
||||
// A node may ONLY route hosts to itself — never inject a route for another node's id.
|
||||
if (entry.relayNodeId !== caller.nodeId) {
|
||||
throw new RouteAuthError('entry.relayNodeId must equal the authenticated caller nodeId')
|
||||
}
|
||||
if (deps.nodeStatus !== undefined) {
|
||||
const status = await deps.nodeStatus(caller.nodeId)
|
||||
if (status === 'draining') throw new RouteAuthError('node is draining; not accepting new routes')
|
||||
}
|
||||
await deps.routes.set(hostId, entry, ttlSec)
|
||||
},
|
||||
async heartbeatRoute(caller, hostId, ttlSec) {
|
||||
const current = await deps.routes.get(hostId)
|
||||
// A stale/foreign node cannot refresh (hijack) another node's live tunnel; missing ⇒ no-op.
|
||||
if (current === null || current.relayNodeId !== caller.nodeId) return
|
||||
await deps.routes.refreshTtl(hostId, ttlSec)
|
||||
},
|
||||
async resolveRoute(hostId) {
|
||||
return deps.routes.get(hostId) // null ⇒ offline (fails closed, INV7)
|
||||
},
|
||||
async dropRoute(caller, hostId) {
|
||||
const current = await deps.routes.get(hostId)
|
||||
if (current === null) return
|
||||
if (current.relayNodeId !== caller.nodeId) {
|
||||
throw new RouteAuthError('only the holding node may drop its route')
|
||||
}
|
||||
await deps.routes.delete(hostId)
|
||||
},
|
||||
async systemDropRoute(hostId) {
|
||||
await deps.routes.delete(hostId)
|
||||
},
|
||||
}
|
||||
}
|
||||
320
control-plane/src/store/memory.ts
Normal file
320
control-plane/src/store/memory.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* In-memory adapter for the repository ports — the tested + v0.9-MVP-shipped default.
|
||||
*
|
||||
* INV8: lifecycle/business fields (account.status, host.status/revoked_at) are versioned via
|
||||
* append-only companion rows + an atomic single-row pointer swap. Each `swapStatus` mutation is
|
||||
* a synchronous critical section (no `await` inside) so a concurrent reader sees whole-old or
|
||||
* whole-new — never a torn mix. Prior snapshots stay readable from the `versions()` list.
|
||||
* High-frequency liveness (`last_seen`, route TTL) is advisory and NOT versioned (INV7).
|
||||
*/
|
||||
import type {
|
||||
AccountRecord,
|
||||
AccountStatus,
|
||||
AccountStatusVersionRow,
|
||||
HostRecord,
|
||||
HostStatus,
|
||||
HostStatusVersionRow,
|
||||
MeteringSampleRow,
|
||||
PairingCodeRecord,
|
||||
RouteEntry,
|
||||
SessionRecord,
|
||||
} from '../model/records.js'
|
||||
import type {
|
||||
AccountStore,
|
||||
AuditRow,
|
||||
AuditStore,
|
||||
CasOutcome,
|
||||
HostStore,
|
||||
MeteringStore,
|
||||
NodeRow,
|
||||
NodeStatus,
|
||||
NodeStore,
|
||||
PairingRow,
|
||||
PairingStore,
|
||||
RouteStore,
|
||||
SessionStore,
|
||||
Stores,
|
||||
SubdomainStore,
|
||||
} from './ports.js'
|
||||
|
||||
interface AccountCell {
|
||||
record: AccountRecord
|
||||
version: number
|
||||
versions: AccountStatusVersionRow[]
|
||||
}
|
||||
interface HostCell {
|
||||
record: HostRecord
|
||||
version: number
|
||||
versions: HostStatusVersionRow[]
|
||||
}
|
||||
|
||||
function memAccountStore(): AccountStore {
|
||||
const cells = new Map<string, AccountCell>()
|
||||
return {
|
||||
async insert(rec) {
|
||||
if (cells.has(rec.accountId)) throw new Error('duplicate accountId')
|
||||
cells.set(rec.accountId, {
|
||||
record: rec,
|
||||
version: 1,
|
||||
versions: [
|
||||
{ accountId: rec.accountId, version: 1, status: rec.status, changedAt: rec.createdAt, changedBy: 'system' },
|
||||
],
|
||||
})
|
||||
},
|
||||
async get(id) {
|
||||
return cells.get(id)?.record ?? null
|
||||
},
|
||||
async swapStatus(id, status, changedBy) {
|
||||
const cell = cells.get(id)
|
||||
if (cell === undefined) throw new Error('account not found')
|
||||
// --- atomic critical section (no await) ---
|
||||
const nextVersion = cell.version + 1
|
||||
const changedAt = new Date().toISOString()
|
||||
const nextRecord: AccountRecord = { ...cell.record, status }
|
||||
cell.versions.push({ accountId: id, version: nextVersion, status, changedAt, changedBy })
|
||||
cell.record = nextRecord
|
||||
cell.version = nextVersion
|
||||
// --- end critical section ---
|
||||
return nextRecord
|
||||
},
|
||||
async versions(id) {
|
||||
return (cells.get(id)?.versions ?? []).slice()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function memHostStore(): HostStore {
|
||||
const cells = new Map<string, HostCell>()
|
||||
const bySub = new Map<string, string>()
|
||||
return {
|
||||
async insert(rec) {
|
||||
if (cells.has(rec.hostId)) throw new Error('duplicate hostId')
|
||||
if (bySub.has(rec.subdomain)) throw new Error('duplicate subdomain')
|
||||
cells.set(rec.hostId, {
|
||||
record: rec,
|
||||
version: 1,
|
||||
versions: [
|
||||
{
|
||||
hostId: rec.hostId,
|
||||
version: 1,
|
||||
status: rec.status,
|
||||
revokedAt: rec.revokedAt,
|
||||
changedAt: rec.createdAt,
|
||||
changedBy: 'system',
|
||||
},
|
||||
],
|
||||
})
|
||||
bySub.set(rec.subdomain, rec.hostId)
|
||||
},
|
||||
async get(id) {
|
||||
return cells.get(id)?.record ?? null
|
||||
},
|
||||
async getBySubdomain(sub) {
|
||||
const id = bySub.get(sub)
|
||||
return id === undefined ? null : (cells.get(id)?.record ?? null)
|
||||
},
|
||||
async listByAccount(accountId) {
|
||||
return [...cells.values()].filter((c) => c.record.accountId === accountId).map((c) => c.record)
|
||||
},
|
||||
async swapStatus(id, status, revokedAt, changedBy) {
|
||||
const cell = cells.get(id)
|
||||
if (cell === undefined) throw new Error('host not found')
|
||||
const nextVersion = cell.version + 1
|
||||
const changedAt = new Date().toISOString()
|
||||
const nextRecord: HostRecord = { ...cell.record, status, revokedAt }
|
||||
cell.versions.push({ hostId: id, version: nextVersion, status, revokedAt, changedAt, changedBy })
|
||||
cell.record = nextRecord
|
||||
cell.version = nextVersion
|
||||
return nextRecord
|
||||
},
|
||||
async touchLastSeen(id, ts) {
|
||||
const cell = cells.get(id)
|
||||
if (cell === undefined) return
|
||||
// Advisory cache — overwrite in place, NO version row (INV7).
|
||||
cell.record = { ...cell.record, lastSeen: ts }
|
||||
},
|
||||
async versions(id) {
|
||||
return (cells.get(id)?.versions ?? []).slice()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function memSessionStore(): SessionStore {
|
||||
const rows = new Map<string, SessionRecord>()
|
||||
return {
|
||||
async insert(rec) {
|
||||
if (rows.has(rec.sessionId)) throw new Error('duplicate sessionId')
|
||||
rows.set(rec.sessionId, rec)
|
||||
},
|
||||
async get(id) {
|
||||
return rows.get(id) ?? null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function memSubdomainStore(hosts: HostStore): SubdomainStore {
|
||||
const reserved = new Set<string>()
|
||||
return {
|
||||
async reserve(sub) {
|
||||
// Synchronous claim FIRST (no await before add) so two concurrent callers can't both win.
|
||||
if (reserved.has(sub)) return false
|
||||
reserved.add(sub)
|
||||
if ((await hosts.getBySubdomain(sub)) !== null) {
|
||||
reserved.delete(sub) // an already-bound host owns this label — release the optimistic claim
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
async isTaken(sub) {
|
||||
return reserved.has(sub) || (await hosts.getBySubdomain(sub)) !== null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface PairingCell {
|
||||
record: PairingCodeRecord
|
||||
attempts: number
|
||||
}
|
||||
|
||||
function memPairingStore(): PairingStore {
|
||||
const cells = new Map<string, PairingCell>()
|
||||
return {
|
||||
async insert(rec) {
|
||||
if (cells.has(rec.codeHash)) throw new Error('duplicate code_hash')
|
||||
cells.set(rec.codeHash, { record: rec, attempts: 0 })
|
||||
},
|
||||
async get(codeHash): Promise<PairingRow | null> {
|
||||
const cell = cells.get(codeHash)
|
||||
return cell === undefined ? null : { record: cell.record, redeemAttempts: cell.attempts }
|
||||
},
|
||||
async casRedeem(codeHash, nowIso): Promise<CasOutcome> {
|
||||
const cell = cells.get(codeHash)
|
||||
if (cell === undefined) return 'unknown'
|
||||
// --- atomic critical section: CAS redeemedAt from null ---
|
||||
if (cell.record.redeemedAt !== null) return 'already_redeemed'
|
||||
cell.record = { ...cell.record, redeemedAt: nowIso }
|
||||
return 'ok'
|
||||
// --- end critical section ---
|
||||
},
|
||||
async registerFailure(codeHash) {
|
||||
const cell = cells.get(codeHash)
|
||||
if (cell === undefined) return 0
|
||||
cell.attempts += 1
|
||||
return cell.attempts
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface RouteCell {
|
||||
entry: RouteEntry
|
||||
expiresAtMs: number
|
||||
}
|
||||
function memRouteStore(): RouteStore {
|
||||
const cells = new Map<string, RouteCell>()
|
||||
const live = (id: string): RouteCell | null => {
|
||||
const c = cells.get(id)
|
||||
if (c === undefined) return null
|
||||
if (c.expiresAtMs <= Date.now()) {
|
||||
cells.delete(id) // fail closed: expired ⇒ offline (INV7)
|
||||
return null
|
||||
}
|
||||
return c
|
||||
}
|
||||
return {
|
||||
async set(hostId, entry, ttlSec) {
|
||||
cells.set(hostId, { entry, expiresAtMs: Date.now() + ttlSec * 1000 })
|
||||
},
|
||||
async refreshTtl(hostId, ttlSec) {
|
||||
const c = live(hostId)
|
||||
if (c === null) return false
|
||||
c.expiresAtMs = Date.now() + ttlSec * 1000
|
||||
return true
|
||||
},
|
||||
async get(hostId) {
|
||||
return live(hostId)?.entry ?? null
|
||||
},
|
||||
async delete(hostId) {
|
||||
cells.delete(hostId)
|
||||
},
|
||||
async listByNode(nodeId) {
|
||||
const out: { hostId: string; entry: RouteEntry }[] = []
|
||||
for (const hostId of [...cells.keys()]) {
|
||||
const c = live(hostId)
|
||||
if (c !== null && c.entry.relayNodeId === nodeId) out.push({ hostId, entry: c.entry })
|
||||
}
|
||||
return out
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function memNodeStore(): NodeStore {
|
||||
const rows = new Map<string, NodeRow>()
|
||||
return {
|
||||
async upsert(row) {
|
||||
rows.set(row.nodeId, row)
|
||||
},
|
||||
async get(id) {
|
||||
return rows.get(id) ?? null
|
||||
},
|
||||
async setStatus(id, status: NodeStatus) {
|
||||
const r = rows.get(id)
|
||||
if (r === undefined) throw new Error('node not found')
|
||||
rows.set(id, { ...r, status })
|
||||
},
|
||||
async touch(id, ts) {
|
||||
const r = rows.get(id)
|
||||
if (r !== undefined) rows.set(id, { ...r, lastSeen: ts })
|
||||
},
|
||||
async delete(id) {
|
||||
rows.delete(id)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function memMeteringStore(): MeteringStore {
|
||||
const rows: MeteringSampleRow[] = []
|
||||
return {
|
||||
async append(row) {
|
||||
rows.push(row) // append-only
|
||||
},
|
||||
async query(accountId, fromIso, toIso) {
|
||||
const from = Date.parse(fromIso)
|
||||
const to = Date.parse(toIso)
|
||||
return rows.filter(
|
||||
(r) => r.accountId === accountId && Date.parse(r.sampledAt) >= from && Date.parse(r.sampledAt) <= to,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function memAuditStore(): AuditStore {
|
||||
const rows: AuditRow[] = []
|
||||
return {
|
||||
async append(row) {
|
||||
rows.push(row) // append-only, no update/delete exposed (INV10)
|
||||
},
|
||||
async query(accountId, fromIso, toIso) {
|
||||
const from = Date.parse(fromIso)
|
||||
const to = Date.parse(toIso)
|
||||
return rows.filter(
|
||||
(r) => r.accountId === accountId && Date.parse(r.ts) >= from && Date.parse(r.ts) <= to,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a full in-memory store set (all ports wired). */
|
||||
export function createMemoryStores(): Stores {
|
||||
const hosts = memHostStore()
|
||||
return {
|
||||
accounts: memAccountStore(),
|
||||
hosts,
|
||||
sessions: memSessionStore(),
|
||||
subdomains: memSubdomainStore(hosts),
|
||||
pairing: memPairingStore(),
|
||||
routes: memRouteStore(),
|
||||
nodes: memNodeStore(),
|
||||
metering: memMeteringStore(),
|
||||
audit: memAuditStore(),
|
||||
}
|
||||
}
|
||||
142
control-plane/src/store/ports.ts
Normal file
142
control-plane/src/store/ports.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Repository ports (Repository Pattern, patterns.md). Registries/services depend on these
|
||||
* abstractions, NOT on a concrete storage engine. Two adapters implement them:
|
||||
* - `store/memory.ts` — in-memory, the tested + v0.9-MVP-shipped default. Immutable/versioned
|
||||
* discipline (INV8) is enforced here in synchronous critical sections (JS is single-threaded,
|
||||
* so a mutation with no `await` inside is atomic — no torn read).
|
||||
* - `store/pg.ts` — parameterized Postgres SQL over `db/pool.ts` (integration seam, typechecked).
|
||||
*
|
||||
* The INV8 versioning logic lives in the store so both adapters agree on the contract; the
|
||||
* registries orchestrate and add authz/ownership predicates.
|
||||
*/
|
||||
import type {
|
||||
AccountRecord,
|
||||
AccountStatus,
|
||||
AccountStatusVersionRow,
|
||||
HostRecord,
|
||||
HostStatus,
|
||||
HostStatusVersionRow,
|
||||
MeteringSampleRow,
|
||||
PairingCodeRecord,
|
||||
RouteEntry,
|
||||
SessionRecord,
|
||||
} from '../model/records.js'
|
||||
|
||||
export interface AccountStore {
|
||||
insert(rec: AccountRecord): Promise<void>
|
||||
get(accountId: string): Promise<AccountRecord | null>
|
||||
/** Atomic: append a status version then swap the single-row pointer. Returns the NEW record. */
|
||||
swapStatus(accountId: string, status: AccountStatus, changedBy: string): Promise<AccountRecord>
|
||||
versions(accountId: string): Promise<readonly AccountStatusVersionRow[]>
|
||||
}
|
||||
|
||||
export interface HostStore {
|
||||
insert(rec: HostRecord): Promise<void>
|
||||
get(hostId: string): Promise<HostRecord | null>
|
||||
getBySubdomain(subdomain: string): Promise<HostRecord | null>
|
||||
listByAccount(accountId: string): Promise<readonly HostRecord[]>
|
||||
/** Atomic status version + pointer swap. `revokedAt` set only on 'revoked'. Returns NEW record. */
|
||||
swapStatus(
|
||||
hostId: string,
|
||||
status: HostStatus,
|
||||
revokedAt: string | null,
|
||||
changedBy: string,
|
||||
): Promise<HostRecord>
|
||||
/** Advisory liveness cache update — NOT versioned (INV7; live truth is Redis). */
|
||||
touchLastSeen(hostId: string, ts: string): Promise<void>
|
||||
versions(hostId: string): Promise<readonly HostStatusVersionRow[]>
|
||||
}
|
||||
|
||||
export interface SessionStore {
|
||||
insert(rec: SessionRecord): Promise<void>
|
||||
get(sessionId: string): Promise<SessionRecord | null>
|
||||
}
|
||||
|
||||
/** Reservation of a subdomain label; atomic single-winner under concurrency (INV1). */
|
||||
export interface SubdomainStore {
|
||||
reserve(subdomain: string): Promise<boolean> // false ⇒ already taken
|
||||
isTaken(subdomain: string): Promise<boolean>
|
||||
}
|
||||
|
||||
export interface PairingRow {
|
||||
readonly record: PairingCodeRecord
|
||||
readonly redeemAttempts: number
|
||||
}
|
||||
|
||||
export type RedeemOutcome =
|
||||
| 'ok'
|
||||
| 'already_redeemed'
|
||||
| 'expired'
|
||||
| 'unknown'
|
||||
| 'too_many_attempts'
|
||||
| 'bad_csr'
|
||||
|
||||
export type CasOutcome = 'ok' | 'already_redeemed' | 'unknown'
|
||||
|
||||
export interface PairingStore {
|
||||
insert(rec: PairingCodeRecord): Promise<void>
|
||||
get(codeHash: string): Promise<PairingRow | null>
|
||||
/**
|
||||
* Atomic compare-and-set of `redeemedAt` from null (double-spend guard). Exactly one
|
||||
* concurrent caller wins 'ok'; the loser gets 'already_redeemed'. Lockout/expiry are
|
||||
* pre-checked by the registry from `get()`.
|
||||
*/
|
||||
casRedeem(codeHash: string, nowIso: string): Promise<CasOutcome>
|
||||
/** Atomic failed-attempt increment for a known code_hash; returns the new attempt count. */
|
||||
registerFailure(codeHash: string): Promise<number>
|
||||
}
|
||||
|
||||
/** Redis-like routing table with per-key heartbeat TTL (INV7). */
|
||||
export interface RouteStore {
|
||||
set(hostId: string, entry: RouteEntry, ttlSec: number): Promise<void>
|
||||
refreshTtl(hostId: string, ttlSec: number): Promise<boolean> // false if key already expired/absent
|
||||
get(hostId: string): Promise<RouteEntry | null>
|
||||
delete(hostId: string): Promise<void>
|
||||
/** Live routes whose entry targets `nodeId` (used by drain enumeration). */
|
||||
listByNode(nodeId: string): Promise<readonly { hostId: string; entry: RouteEntry }[]>
|
||||
}
|
||||
|
||||
export type NodeStatus = 'active' | 'draining'
|
||||
export interface NodeRow {
|
||||
readonly nodeId: string
|
||||
readonly addr: string
|
||||
readonly status: NodeStatus
|
||||
readonly lastSeen: string
|
||||
}
|
||||
export interface NodeStore {
|
||||
upsert(row: NodeRow): Promise<void>
|
||||
get(nodeId: string): Promise<NodeRow | null>
|
||||
setStatus(nodeId: string, status: NodeStatus): Promise<void>
|
||||
touch(nodeId: string, ts: string): Promise<void>
|
||||
delete(nodeId: string): Promise<void>
|
||||
}
|
||||
|
||||
export interface MeteringStore {
|
||||
append(row: MeteringSampleRow): Promise<void> // append-only (INV8)
|
||||
query(accountId: string, fromIso: string, toIso: string): Promise<readonly MeteringSampleRow[]>
|
||||
}
|
||||
|
||||
export interface AuditRow {
|
||||
readonly action: string
|
||||
readonly principalId: string
|
||||
readonly accountId: string
|
||||
readonly hostId: string | null
|
||||
readonly ts: string
|
||||
readonly meta: Readonly<Record<string, string>>
|
||||
}
|
||||
export interface AuditStore {
|
||||
append(row: AuditRow): Promise<void> // append-only, no update/delete path (INV10)
|
||||
query(accountId: string, fromIso: string, toIso: string): Promise<readonly AuditRow[]>
|
||||
}
|
||||
|
||||
export interface Stores {
|
||||
readonly accounts: AccountStore
|
||||
readonly hosts: HostStore
|
||||
readonly sessions: SessionStore
|
||||
readonly subdomains: SubdomainStore
|
||||
readonly pairing: PairingStore
|
||||
readonly routes: RouteStore
|
||||
readonly nodes: NodeStore
|
||||
readonly metering: MeteringStore
|
||||
readonly audit: AuditStore
|
||||
}
|
||||
95
control-plane/src/subdomain/assign.ts
Normal file
95
control-plane/src/subdomain/assign.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* T6 — per-tenant subdomain assignment. The subdomain is the browser-origin isolation boundary
|
||||
* (EXPLORE §3, INV1): a malformed/duplicate label must never collapse two tenants into one
|
||||
* origin. Validation is RFC-1123-label strict; assignment is an atomic single-winner reserve.
|
||||
*/
|
||||
import type { SubdomainStore } from '../store/ports.js'
|
||||
import type { AuditWriter } from '../audit/log.js'
|
||||
import { noopAuditWriter } from '../audit/log.js'
|
||||
import { nowIso } from '../util/ids.js'
|
||||
|
||||
/** Names that must never become a tenant subdomain (host-header / infra confusion). */
|
||||
export const RESERVED_SUBDOMAINS: ReadonlySet<string> = new Set([
|
||||
'www',
|
||||
'api',
|
||||
'admin',
|
||||
'app',
|
||||
'relay',
|
||||
'term',
|
||||
'root',
|
||||
'localhost',
|
||||
'_',
|
||||
])
|
||||
|
||||
const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/
|
||||
|
||||
/** Lowercase, strip characters invalid in an RFC-1123 label; collapse to a candidate label. */
|
||||
export function normalizeSubdomain(raw: string): string {
|
||||
return raw
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/^-+/, '')
|
||||
.replace(/-+$/, '')
|
||||
.slice(0, 63)
|
||||
}
|
||||
|
||||
/** 1–63 chars, [a-z0-9-], not leading/trailing '-', not reserved. */
|
||||
export function isValidSubdomain(raw: string): boolean {
|
||||
if (raw.length < 1 || raw.length > 63) return false
|
||||
if (!LABEL_RE.test(raw)) return false
|
||||
if (RESERVED_SUBDOMAINS.has(raw)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** 'alice' + 'term.example.com' → 'alice.term.example.com'. */
|
||||
export function assembleFqdn(subdomain: string, baseDomain: string): string {
|
||||
return `${subdomain}.${baseDomain}`
|
||||
}
|
||||
|
||||
const MAX_COLLISION_RETRIES = 5
|
||||
|
||||
export interface SubdomainAssigner {
|
||||
assignSubdomain(accountId: string, requested?: string): Promise<string>
|
||||
}
|
||||
|
||||
export interface SubdomainAssignerDeps {
|
||||
readonly subdomains: SubdomainStore
|
||||
readonly audit?: AuditWriter
|
||||
readonly actor?: string
|
||||
/** Deterministic suffix source (test seam); defaults to random 4-hex. */
|
||||
readonly suffix?: () => string
|
||||
}
|
||||
|
||||
export function createSubdomainAssigner(deps: SubdomainAssignerDeps): SubdomainAssigner {
|
||||
const audit = deps.audit ?? noopAuditWriter()
|
||||
const actor = deps.actor ?? 'system'
|
||||
const suffix = deps.suffix ?? (() => Math.floor(Math.random() * 0xffff).toString(16).padStart(4, '0'))
|
||||
return {
|
||||
async assignSubdomain(accountId, requested) {
|
||||
const base = requested !== undefined ? normalizeSubdomain(requested) : `h${suffix()}`
|
||||
if (!isValidSubdomain(base)) {
|
||||
throw new Error(`invalid subdomain: ${requested ?? base}`)
|
||||
}
|
||||
// A requested name that collides is NEVER silently reassigned to someone else's tenant.
|
||||
// We try the exact name, then deterministic '-<suffix>' variants, else 409-equivalent throw.
|
||||
const candidates = [base, ...Array.from({ length: MAX_COLLISION_RETRIES }, () => `${base}-${suffix()}`)]
|
||||
for (const candidate of candidates) {
|
||||
if (!isValidSubdomain(candidate)) continue
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (await deps.subdomains.reserve(candidate)) {
|
||||
await audit.writeAuditEvent({
|
||||
action: 'subdomain.assign',
|
||||
principalId: actor,
|
||||
accountId,
|
||||
hostId: null,
|
||||
ts: nowIso(),
|
||||
meta: { subdomain: candidate },
|
||||
})
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
throw new Error(`subdomain collision: ${base} is taken (409)`)
|
||||
},
|
||||
}
|
||||
}
|
||||
29
control-plane/src/util/bytes.ts
Normal file
29
control-plane/src/util/bytes.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/** Byte / base64 helpers (dependency-free; Node Buffer under the hood). */
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString('base64')
|
||||
}
|
||||
|
||||
export function base64ToBytes(b64: string): Uint8Array {
|
||||
// Buffer.from is lenient; assert round-trip to reject clearly-invalid input.
|
||||
const buf = Buffer.from(b64, 'base64')
|
||||
if (buf.toString('base64').replace(/=+$/, '') !== b64.replace(/=+$/, '')) {
|
||||
throw new Error('invalid base64')
|
||||
}
|
||||
return new Uint8Array(buf)
|
||||
}
|
||||
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString('hex')
|
||||
}
|
||||
|
||||
/** Constant-time equality of two byte arrays (length-independent short-circuit avoided). */
|
||||
export function timingSafeEqualBytes(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
let diff = 0
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
// Non-null via loop bounds; noUncheckedIndexedAccess guarded below.
|
||||
diff |= (a[i] as number) ^ (b[i] as number)
|
||||
}
|
||||
return diff === 0
|
||||
}
|
||||
119
control-plane/src/util/crypto.ts
Normal file
119
control-plane/src/util/crypto.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Asymmetric crypto helpers built on Node's builtin `crypto` (no native deps).
|
||||
*
|
||||
* - Ed25519 raw-key <-> KeyObject bridging for CSR proof-of-possession (T8/T15 INV14) and
|
||||
* the bind-time CA leaf signature.
|
||||
* - X25519 seal/open (ECIES-style) for wrapping the per-host content secret to the enrolled
|
||||
* identity (T8 FIX 3 INV4/INV5).
|
||||
*
|
||||
* NOTE (integration seam): production uses real PKCS#10 CSRs + X.509 leaves (`@peculiar/x509`)
|
||||
* and converts the Ed25519 enrollment key to X25519 (ed2curve). Here the CSR is modelled as an
|
||||
* Ed25519 proof-of-possession signature and the wrap targets a raw X25519 public key, which
|
||||
* exercises the SAME security properties (possession proof, per-host wrap, no raw secret at rest)
|
||||
* with builtin crypto only. Swapping in the X.509 stack does not change any §4 contract shape.
|
||||
*/
|
||||
import {
|
||||
createPublicKey,
|
||||
createPrivateKey,
|
||||
sign as edSign,
|
||||
verify as edVerify,
|
||||
generateKeyPairSync,
|
||||
diffieHellman,
|
||||
hkdfSync,
|
||||
randomBytes,
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
type KeyObject,
|
||||
} from 'node:crypto'
|
||||
|
||||
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
|
||||
const X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex')
|
||||
|
||||
/** Wrap a raw 32-byte Ed25519 public key into a verifiable KeyObject. */
|
||||
export function ed25519PublicKeyFromRaw(raw: Uint8Array): KeyObject {
|
||||
if (raw.length !== 32) throw new Error('ed25519 public key must be 32 bytes')
|
||||
return createPublicKey({
|
||||
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(raw)]),
|
||||
format: 'der',
|
||||
type: 'spki',
|
||||
})
|
||||
}
|
||||
|
||||
/** Verify an Ed25519 signature over `message` by the raw public key. Never throws. */
|
||||
export function ed25519Verify(rawPub: Uint8Array, message: Uint8Array, sig: Uint8Array): boolean {
|
||||
try {
|
||||
const key = ed25519PublicKeyFromRaw(rawPub)
|
||||
return edVerify(null, Buffer.from(message), key, Buffer.from(sig))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Test/util: generate an Ed25519 keypair, returning raw pubkey + a KeyObject private key. */
|
||||
export function generateEd25519(): { publicKeyRaw: Uint8Array; privateKey: KeyObject } {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
|
||||
const spki = publicKey.export({ format: 'der', type: 'spki' }) as Buffer
|
||||
const raw = spki.subarray(spki.length - 32)
|
||||
return { publicKeyRaw: new Uint8Array(raw), privateKey }
|
||||
}
|
||||
|
||||
/** Test/util: sign a message with an Ed25519 private KeyObject. */
|
||||
export function ed25519Sign(privateKey: KeyObject, message: Uint8Array): Uint8Array {
|
||||
return new Uint8Array(edSign(null, Buffer.from(message), privateKey))
|
||||
}
|
||||
|
||||
// ---- X25519 wrap/unwrap (host content secret, FIX 3) --------------------------------------
|
||||
|
||||
export function generateX25519(): { publicKeyRaw: Uint8Array; privateKey: KeyObject } {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('x25519')
|
||||
const spki = publicKey.export({ format: 'der', type: 'spki' }) as Buffer
|
||||
const raw = spki.subarray(spki.length - 32)
|
||||
return { publicKeyRaw: new Uint8Array(raw), privateKey }
|
||||
}
|
||||
|
||||
function x25519PublicKeyFromRaw(raw: Uint8Array): KeyObject {
|
||||
if (raw.length !== 32) throw new Error('x25519 public key must be 32 bytes')
|
||||
return createPublicKey({
|
||||
key: Buffer.concat([X25519_SPKI_PREFIX, Buffer.from(raw)]),
|
||||
format: 'der',
|
||||
type: 'spki',
|
||||
})
|
||||
}
|
||||
|
||||
const WRAP_INFO = Buffer.from('relay-cp/host-content-secret/v1')
|
||||
|
||||
/**
|
||||
* Seal `secret` to a recipient X25519 public key (ephemeral-static ECDH → HKDF → AES-256-GCM).
|
||||
* Output layout: ephPub(32) || iv(12) || tag(16) || ciphertext. The raw secret never appears in
|
||||
* the output. A fresh ephemeral key per call makes every wrap distinct (per-host revocable).
|
||||
*/
|
||||
export function sealToX25519(recipientRawPub: Uint8Array, secret: Uint8Array): Uint8Array {
|
||||
const recipient = x25519PublicKeyFromRaw(recipientRawPub)
|
||||
const eph = generateKeyPairSync('x25519')
|
||||
const ephSpki = eph.publicKey.export({ format: 'der', type: 'spki' }) as Buffer
|
||||
const ephPubRaw = ephSpki.subarray(ephSpki.length - 32)
|
||||
const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: recipient })
|
||||
const aeadKey = Buffer.from(hkdfSync('sha256', shared, ephPubRaw, WRAP_INFO, 32))
|
||||
const iv = randomBytes(12)
|
||||
const cipher = createCipheriv('aes-256-gcm', aeadKey, iv)
|
||||
const ct = Buffer.concat([cipher.update(Buffer.from(secret)), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return new Uint8Array(Buffer.concat([ephPubRaw, iv, tag, ct]))
|
||||
}
|
||||
|
||||
/** Open a sealed blob with the recipient's X25519 private key. Throws on tamper/wrong key. */
|
||||
export function openFromX25519(recipientPriv: KeyObject, blob: Uint8Array): Uint8Array {
|
||||
const buf = Buffer.from(blob)
|
||||
const ephPubRaw = buf.subarray(0, 32)
|
||||
const iv = buf.subarray(32, 44)
|
||||
const tag = buf.subarray(44, 60)
|
||||
const ct = buf.subarray(60)
|
||||
const ephPub = x25519PublicKeyFromRaw(new Uint8Array(ephPubRaw))
|
||||
const shared = diffieHellman({ privateKey: recipientPriv, publicKey: ephPub })
|
||||
const aeadKey = Buffer.from(hkdfSync('sha256', shared, ephPubRaw, WRAP_INFO, 32))
|
||||
const decipher = createDecipheriv('aes-256-gcm', aeadKey, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
return new Uint8Array(Buffer.concat([decipher.update(ct), decipher.final()]))
|
||||
}
|
||||
|
||||
export { createPrivateKey, type KeyObject }
|
||||
44
control-plane/src/util/hash.ts
Normal file
44
control-plane/src/util/hash.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Secret hashing at rest (INV5). Pairing codes and v0.8 tokens are stored as salted scrypt
|
||||
* hashes, never raw. Verification is constant-time. scrypt is a Node builtin (no native dep);
|
||||
* argon2id would be the production upgrade (integration note) but scrypt satisfies INV5 here.
|
||||
*/
|
||||
import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Deterministic hash for high-entropy lookup keys (pairing codes: ≥128-bit input, so a fast
|
||||
* preimage-resistant hash is safe at rest — the input space is infeasible to brute-force, INV5).
|
||||
* A salted scrypt cannot be used here because redemption must find the row by hash of the
|
||||
* presented code. Production may HMAC this with a server-side pepper (integration note).
|
||||
*/
|
||||
export function sha256Hex(raw: string): string {
|
||||
return createHash('sha256').update(raw, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
const SCRYPT_KEYLEN = 32
|
||||
const SCRYPT_COST = 1 << 14 // N=16384 — modest, fast enough for tests, memory-hard
|
||||
const SALT_LEN = 16
|
||||
|
||||
/** Produce a self-describing `scrypt$<saltHex>$<hashHex>` string. Raw secret is discarded. */
|
||||
export function hashSecret(raw: string): string {
|
||||
const salt = randomBytes(SALT_LEN)
|
||||
const hash = scryptSync(raw, salt, SCRYPT_KEYLEN, { N: SCRYPT_COST })
|
||||
return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`
|
||||
}
|
||||
|
||||
/** Constant-time verify a raw secret against a stored `scrypt$salt$hash`. */
|
||||
export function verifySecret(raw: string, stored: string): boolean {
|
||||
const parts = stored.split('$')
|
||||
if (parts.length !== 3 || parts[0] !== 'scrypt') return false
|
||||
const saltHex = parts[1] as string
|
||||
const hashHex = parts[2] as string
|
||||
let expected: Buffer
|
||||
try {
|
||||
expected = Buffer.from(hashHex, 'hex')
|
||||
const salt = Buffer.from(saltHex, 'hex')
|
||||
const actual = scryptSync(raw, salt, expected.length, { N: SCRYPT_COST })
|
||||
return timingSafeEqual(actual, expected)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
10
control-plane/src/util/ids.ts
Normal file
10
control-plane/src/util/ids.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Identifier + timestamp helpers. UUIDv4 host/account/session ids are unguessable (INV1). */
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export function newUuid(): string {
|
||||
return randomUUID()
|
||||
}
|
||||
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
Reference in New Issue
Block a user