feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.
Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay: native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent: host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e: browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth: Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web: browser login + Web Crypto E2E + client-side preview rendering
Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.
NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
This commit is contained in:
41
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,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user