Customers install one command / log in once; hardware-generated keys never leave the device; CSRs return certs + subdomain; frpc + base-app run as durable services. No .p12, no manual cert import. Implements the MVP fast-path of docs/PLAN_TUNNEL_AUTOMATION.md. Control-plane / PKI (control-plane/): - ca/x509-assembler.ts: single KMS-signed real X.509 issuance primitive (Ed25519 + P-256) - ca/csr-ec.ts: P-256 PKCS#10 proof-of-possession (verifyCsrPoPEc) + CSR-key routing - ca/frpclient-issue.ts, ca/device-issue.ts: P-256 frp-client + device leaf signers - ca/rotate.ts + api/renew.ts: real-X.509 /renew + /device/:id/renew (mTLS current cert) - registry/devices.ts: device registry + per-account cap/rate-limit - auth/session.ts: device:enroll capability token mint/verify - api/device-enroll.ts: POST /device/enroll (ownership-gated, deny-by-default) - pairing/native-redeem.ts + shared gateAndConsumePairingCode; api/provision.ts native arm - boot/native-ca.ts + main.ts: wire two P-256 CAs + issuers + routers (dev / KMS fail-fast) Contracts: relay-contracts enroll right; relay-auth SPIFFE /device/ arm + spiffeIdFor(kind) Host agent (agent/): - transport/frpcToml.ts; provision/frpcBinary.ts + untar.ts (verify-download + traversal-safe extract) - keys P-256 keygen/CSR/loadIdentity; service two-unit install + BIND_HOST loopback S-GATE - net/loopbackLiteral.ts strict guard; health/probe.ts + transport/frpSupervise.ts; cli pair --install iOS (ios/Packages/ClientTLS): SecureEnclaveKey + CertificateSigningRequest + DeviceEnrollmentClient + Keychain enroll refactor (SecKey/Security.framework end-to-end, avoids the -25300 trap) Isolation (deploy/nginx): njs/getCertSub.js SAN parser + zone-anchored map -> 403 Verified: 758 tests green (control-plane 246, agent 267, relay-auth 133, relay-contracts 85, iOS ClientTLS 27), all tsc clean; real nginx+njs docker 403/200/400; Swift CSR accepted by the real control-plane verifier; frpc extract byte-identical to `tar -xO`. Cross-validation caught + fixed 5 real defects (1 critical, 4 high). Remaining = infra (KMS, nginx deploy, VPS frps, physical iPhone) per PROGRESS_LOG runbook. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
119 lines
4.5 KiB
TypeScript
119 lines
4.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), 1.3.101.112 (Ed25519), 1.2.840.10045.4.3.2 (ecdsa-with-SHA256) as
|
|
// pre-encoded DER value bytes.
|
|
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03])
|
|
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70])
|
|
const OID_ECDSA_WITH_SHA256 = Uint8Array.from([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02])
|
|
|
|
/** 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]))
|
|
}
|
|
|
|
/**
|
|
* SubjectPublicKeyInfo bytes for the CSR. For P-256, `id.publicKey` IS already the full EC SPKI DER
|
|
* (built by `keys/identity.ts`), so it is embedded verbatim; for Ed25519 the raw 32-byte key is
|
|
* wrapped into the fixed SPKI structure.
|
|
*/
|
|
function spkiFor(id: AgentIdentity): Uint8Array {
|
|
return id.alg === 'p256' ? id.publicKey : spkiFromRawEd25519(id.publicKey)
|
|
}
|
|
|
|
/**
|
|
* The signatureAlgorithm AlgorithmIdentifier: `SEQUENCE { OID }` (no parameters — RFC 5758 §3.2 for
|
|
* ecdsa-with-SHA256, and Ed25519 likewise omits parameters).
|
|
*/
|
|
function sigAlgFor(id: AgentIdentity): Uint8Array {
|
|
const oid = id.alg === 'p256' ? OID_ECDSA_WITH_SHA256 : OID_ED25519
|
|
return tlv(SEQUENCE, tlv(OID, oid))
|
|
}
|
|
|
|
/** 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 identity's key. The
|
|
* Ed25519 and P-256 (ecdsa-with-SHA256, FIX H-host-2) paths share this one encoder — only the SPKI
|
|
* and the signatureAlgorithm differ. 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 = spkiFor(id)
|
|
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) // over CertificationRequestInfo, per id.alg
|
|
const sigAlg = sigAlgFor(id)
|
|
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
|
|
|
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
|
return toPem(csr, 'CERTIFICATE REQUEST')
|
|
}
|