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.
97 lines
3.5 KiB
TypeScript
97 lines
3.5 KiB
TypeScript
/**
|
|
* PKCS#10 CSR over the Ed25519 identity — PLAN_RELAY_AGENT T4.
|
|
*
|
|
* Node has no built-in CSR generator, so this is a compact, self-contained DER encoder that
|
|
* emits a standard PKCS#10 CertificationRequest signed with the in-process Ed25519 key (INV4 —
|
|
* only the PUBLIC key + a signature leave; the private key is never serialized here).
|
|
*
|
|
* NOTE (cross-plan / open Q#1): the exact SPIFFE-style cert PROFILE (SAN = subdomain vs a SPIFFE
|
|
* URI) is set by P3's CA. This builds the standard subject-CN PKCS#10 shape; when P3 freezes its
|
|
* profile, extend `buildCsr`'s attributes here. That is the single integration point.
|
|
*/
|
|
import type { AgentIdentity } from '../keys/identity.js'
|
|
|
|
// --- minimal DER encoding helpers -------------------------------------------------------------
|
|
|
|
function derLen(len: number): Uint8Array {
|
|
if (len < 0x80) return Uint8Array.from([len])
|
|
const bytes: number[] = []
|
|
let n = len
|
|
while (n > 0) {
|
|
bytes.unshift(n & 0xff)
|
|
n >>= 8
|
|
}
|
|
return Uint8Array.from([0x80 | bytes.length, ...bytes])
|
|
}
|
|
|
|
function tlv(tag: number, value: Uint8Array): Uint8Array {
|
|
const len = derLen(value.length)
|
|
const out = new Uint8Array(1 + len.length + value.length)
|
|
out[0] = tag
|
|
out.set(len, 1)
|
|
out.set(value, 1 + len.length)
|
|
return out
|
|
}
|
|
|
|
function concat(chunks: readonly Uint8Array[]): Uint8Array {
|
|
const total = chunks.reduce((s, c) => s + c.length, 0)
|
|
const out = new Uint8Array(total)
|
|
let off = 0
|
|
for (const c of chunks) {
|
|
out.set(c, off)
|
|
off += c.length
|
|
}
|
|
return out
|
|
}
|
|
|
|
const SEQUENCE = 0x30
|
|
const SET = 0x31
|
|
const INTEGER = 0x02
|
|
const BIT_STRING = 0x03
|
|
const OID = 0x06
|
|
const UTF8_STRING = 0x0c
|
|
const CONTEXT_0 = 0xa0
|
|
|
|
// OID 2.5.4.3 (commonName) and 1.3.101.112 (Ed25519) as pre-encoded DER value bytes.
|
|
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03])
|
|
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70])
|
|
|
|
/** SubjectPublicKeyInfo DER for a raw Ed25519 public key (fixed 44-byte structure). */
|
|
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
|
const algId = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
|
const pubBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), raw]))
|
|
return tlv(SEQUENCE, concat([algId, pubBits]))
|
|
}
|
|
|
|
/** X.501 Name with a single CN=<subject> RDN. */
|
|
function nameFromCn(cn: string): Uint8Array {
|
|
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
|
|
const rdn = tlv(SET, atv)
|
|
return tlv(SEQUENCE, rdn)
|
|
}
|
|
|
|
function toPem(der: Uint8Array, label: string): string {
|
|
const b64 = Buffer.from(der).toString('base64')
|
|
const lines = b64.match(/.{1,64}/g) ?? []
|
|
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
|
|
}
|
|
|
|
/**
|
|
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the Ed25519 key.
|
|
* The private key is used in-process only; never serialized into the output (INV4).
|
|
*/
|
|
export function buildCsr(id: AgentIdentity, subject: string): string {
|
|
const version = tlv(INTEGER, Uint8Array.from([0x00]))
|
|
const name = nameFromCn(subject)
|
|
const spki = spkiFromRawEd25519(id.publicKey)
|
|
const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
|
|
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
|
|
|
|
const signature = id.sign(requestInfo) // Ed25519 over CertificationRequestInfo
|
|
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
|
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
|
|
|
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
|
return toPem(csr, 'CERTIFICATE REQUEST')
|
|
}
|