diff --git a/agent/src/certs/nativeRenew.ts b/agent/src/certs/nativeRenew.ts index 34e5910..ac92762 100644 --- a/agent/src/certs/nativeRenew.ts +++ b/agent/src/certs/nativeRenew.ts @@ -72,7 +72,9 @@ const defaultMtlsRequest: MtlsRequest = (url, tls, init) => headers: init.headers, cert: tls.cert, key: tls.key, - ca: tls.ca, + // ca omitted ⇒ verify the server against the system roots (LE-fronted CP). Present only when a + // private CA is pinned (not for /renew). + ...(tls.ca !== undefined ? { ca: tls.ca } : {}), rejectUnauthorized: tls.rejectUnauthorized, // always true (anti-MITM, INV14) }, (res) => { @@ -129,7 +131,12 @@ export function createMtlsFetch( const request = opts.request ?? defaultMtlsRequest const shim = async (input: Parameters[0], init?: RequestInit): Promise => { const url = typeof input === 'string' ? input : input.toString() - const tls = buildTlsOptions(ks, { ...(opts.certParser ? { certParser: opts.certParser } : {}) }) + // Present the current frp-client leaf (client auth), but verify the /renew SERVER cert against the + // SYSTEM roots — its host (the LE-fronted control-plane) is publicly trusted; pinning the private + // enroll caChain here fails with "unable to get local issuer certificate". So drop `ca` (absent → + // node uses the default roots); rejectUnauthorized stays true. + const full = buildTlsOptions(ks, { ...(opts.certParser ? { certParser: opts.certParser } : {}) }) + const tls: TlsClientOptions = { cert: full.cert, key: full.key, rejectUnauthorized: full.rejectUnauthorized } const reqInit: MtlsRequestInit = { method: init?.method ?? 'GET', headers: toHeaderRecord(init?.headers), diff --git a/agent/src/certs/pem.ts b/agent/src/certs/pem.ts new file mode 100644 index 0000000..b031f31 --- /dev/null +++ b/agent/src/certs/pem.ts @@ -0,0 +1,33 @@ +/** + * PEM helpers shared by the native enroll (enroll/pair.ts) and renew (certs/rotation.ts) paths. + * + * The control-plane returns the frp-client leaf + CA chain as base64-encoded DER (cert as a string, + * caChain as a string[]); the keystore + frpc need PEM files. `derBase64ToPem` wraps a base64 DER body + * back into CERTIFICATE armor at 64 columns. + */ + +/** base64(DER) → PEM (CERTIFICATE armor, 64-col wrapped). */ +export function derBase64ToPem(derBase64: string, label = 'CERTIFICATE'): string { + const body = derBase64.replace(/\s+/g, '') + const lines = body.match(/.{1,64}/g) ?? [] + return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n` +} + +/** + * Normalize a control-plane cert response ({cert: base64 DER, caChain: base64 DER[]}) to PEM strings + * for the keystore. Throws if the shape is wrong. Shared by enroll + renew so both stay in lockstep. + */ +export function certResponseToPem(cert: unknown, caChain: unknown): { certPem: string; caChainPem: string } { + if ( + typeof cert !== 'string' || + !Array.isArray(caChain) || + caChain.length === 0 || + !caChain.every((c) => typeof c === 'string') + ) { + throw new Error('cert response missing cert/caChain') + } + return { + certPem: derBase64ToPem(cert), + caChainPem: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''), + } +} diff --git a/agent/src/certs/rotation.ts b/agent/src/certs/rotation.ts index 6d7b345..9ad15e0 100644 --- a/agent/src/certs/rotation.ts +++ b/agent/src/certs/rotation.ts @@ -15,6 +15,7 @@ import type { Keystore } from '../keys/keystore.js' import type { TimerLike } from '../transport/seams.js' import { createBackoff, type BackoffPolicy } from '../transport/backoff.js' import { buildCsr } from '../enroll/csr.js' +import { certResponseToPem } from './pem.js' export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry @@ -64,11 +65,11 @@ export async function renewCert( }) if (res.status === 403) return 'revoked' if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`) - const json = (await res.json()) as { cert?: string; caChain?: string } - if (typeof json.cert !== 'string' || typeof json.caChain !== 'string') { - throw new Error('cert renewal response missing cert/caChain') - } - ks.saveCert(json.cert, json.caChain) // atomic whole-file install + // The control-plane returns cert=base64(DER) + caChain=base64(DER)[]; normalize to PEM for the + // keystore + frpc (same shape as native enroll). + const json = (await res.json()) as { cert?: unknown; caChain?: unknown } + const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain) + ks.saveCert(certPem, caChainPem) // atomic whole-file install return 'rotated' } diff --git a/agent/src/enroll/pair.ts b/agent/src/enroll/pair.ts index 3dffb3e..ea7a0a9 100644 --- a/agent/src/enroll/pair.ts +++ b/agent/src/enroll/pair.ts @@ -15,6 +15,7 @@ import type { EnrollResult } from 'relay-contracts' import type { AgentIdentity } from '../keys/identity.js' import type { Keystore } from '../keys/keystore.js' import { buildCsr } from './csr.js' +import { derBase64ToPem } from '../certs/pem.js' /** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */ export type EnrollMode = 'token' | 'ed25519' @@ -69,16 +70,6 @@ interface EnrollResponseJson { hostContentSecret: string // base64url over the wire } -/** - * base64(DER) → PEM. The native /enroll returns the leaf + CA certs as base64-encoded DER; the - * keystore + frpc need PEM files, so wrap the base64 body at 64 columns in CERTIFICATE armor. - */ -function derBase64ToPem(derBase64: string, label = 'CERTIFICATE'): string { - const body = derBase64.replace(/\s+/g, '') - const lines = body.match(/.{1,64}/g) ?? [] - return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n` -} - function parseEnrollResult(json: unknown, allowMissingContentSecret = false): EnrollResult { const j = json as Partial if (typeof j.hostContentSecret !== 'string') { diff --git a/agent/src/transport/dial.ts b/agent/src/transport/dial.ts index cd2d9dc..8e645c9 100644 --- a/agent/src/transport/dial.ts +++ b/agent/src/transport/dial.ts @@ -25,7 +25,13 @@ export class CertExpiredError extends Error { export interface TlsClientOptions { readonly cert: string readonly key: string - readonly ca: string + /** + * CA(s) to verify the SERVER cert against. Set to the private tunnel CA for the relay dial; left + * undefined for a request whose server is publicly trusted (the LE-fronted control-plane /renew) so + * Node verifies against the system roots — pinning the private CA there fails with "unable to get + * local issuer certificate". + */ + readonly ca?: string readonly rejectUnauthorized: true } diff --git a/agent/test/nativeRenew.test.ts b/agent/test/nativeRenew.test.ts index def5445..b4d3a4e 100644 --- a/agent/test/nativeRenew.test.ts +++ b/agent/test/nativeRenew.test.ts @@ -69,7 +69,9 @@ describe('createMtlsFetch (A5)', () => { expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' }) expect(seen[0]!.url).toBe('https://cp.example.com/renew') expect(seen[0]!.tls.cert).toBe('LEAFCERT') - expect(seen[0]!.tls.ca).toBe('CACHAIN') + // /renew verifies the LE-fronted control-plane against the SYSTEM roots, so no private CA is pinned + // (pinning the enroll caChain here fails with "unable to get local issuer certificate"). + expect(seen[0]!.tls.ca).toBeUndefined() expect(seen[0]!.tls.rejectUnauthorized).toBe(true) expect(String(seen[0]!.tls.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key, mTLS only expect(seen[0]!.init.method).toBe('POST') @@ -166,7 +168,7 @@ describe('startNativeAutoRenew (A5 end-to-end)', () => { const timer = new FakeTimer() const request: MtlsRequest = async () => ({ status: 200, - body: JSON.stringify({ cert: 'FRESHLEAF', caChain: 'CACHAIN' }), + body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }), }) const restartChild = vi.fn() const stop = vi.fn() @@ -183,7 +185,7 @@ describe('startNativeAutoRenew (A5 end-to-end)', () => { timer.advance(1000) // renewal fires at ~2/3 TTL await flush() - expect(ks.loadCert()!.certPem).toBe('FRESHLEAF') // atomic persist + expect(ks.loadCert()!.certPem).toContain('FRESHLEAF') // atomic persist expect(restartChild).toHaveBeenCalledTimes(1) // frpc restarted onto the fresh leaf expect(stop).not.toHaveBeenCalled() controller.stop() @@ -222,7 +224,7 @@ describe('startNativeAutoRenew (A5 end-to-end)', () => { const request: MtlsRequest = async () => { calls += 1 if (calls === 1) throw new Error('ECONNREFUSED') - return { status: 200, body: JSON.stringify({ cert: 'FRESHLEAF', caChain: 'CACHAIN' }) } + return { status: 200, body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }) } } const restartChild = vi.fn() const stop = vi.fn() @@ -252,7 +254,7 @@ describe('startNativeAutoRenew (A5 end-to-end)', () => { timer.advance(500) // backoff retry fires and succeeds await flush() - expect(ks.loadCert()!.certPem).toBe('FRESHLEAF') + expect(ks.loadCert()!.certPem).toContain('FRESHLEAF') expect(restartChild).toHaveBeenCalledTimes(1) expect(lines.join('\n')).not.toContain('LEAFCERT') controller.stop() diff --git a/agent/test/nativeRenewTransport.test.ts b/agent/test/nativeRenewTransport.test.ts index e8f8d1f..893462e 100644 --- a/agent/test/nativeRenewTransport.test.ts +++ b/agent/test/nativeRenewTransport.test.ts @@ -115,7 +115,8 @@ describe('defaultMtlsRequest transport (A5 — real node:https)', () => { expect(seenOpts.rejectUnauthorized).toBe(true) // anti-MITM (INV14) expect(seenOpts.method).toBe('POST') expect(seenOpts.cert).toBe('LEAFCERT') - expect(seenOpts.ca).toBe('CACHAIN') + // ca omitted ⇒ verify the LE-fronted control-plane against the system roots (not the private CA). + expect(seenOpts.ca).toBeUndefined() expect(String(seenOpts.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key // HIGH fix: a socket timeout is armed with the sane default so a stall can never hang forever. expect(seenReq!.setTimeoutCalls).toHaveLength(1) diff --git a/agent/test/rotation.test.ts b/agent/test/rotation.test.ts index 1410079..2567f30 100644 --- a/agent/test/rotation.test.ts +++ b/agent/test/rotation.test.ts @@ -53,10 +53,10 @@ describe('renewCert (T13)', () => { it('installs a fresh cert atomically on success (same key)', async () => { const { dir, ks } = enrolledKs() const before = ks.loadIdentity()!.publicKey - const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) + const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })) const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch) expect(out).toBe('rotated') - expect(ks.loadCert()).toEqual({ certPem: 'NEWCERT', caChainPem: 'NEWCA' }) + expect(ks.loadCert()!.certPem).toContain('NEWCERT'); expect(ks.loadCert()!.caChainPem).toContain('NEWCA') // pubkey unchanged — only the cert rotated expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true) rmSync(dir, { recursive: true, force: true }) @@ -102,7 +102,7 @@ describe('createCertRotator (T13)', () => { const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, { timer, renewBeforeMs: 1000, - fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) as unknown as typeof fetch, + fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })) as unknown as typeof fetch, now: () => new Date(0), parseCert: () => new Date(2000), }) @@ -114,7 +114,7 @@ describe('createCertRotator (T13)', () => { timer.advance(1000) await flush() expect(rotated).toBe(1) - expect(ks.loadCert()!.certPem).toBe('NEWCERT') + expect(ks.loadCert()!.certPem).toContain('NEWCERT') rotator.stop() rmSync(dir, { recursive: true, force: true }) }) @@ -126,7 +126,7 @@ describe('createCertRotator (T13)', () => { const fetchImpl = (async () => { calls += 1 if (calls === 1) throw new Error('network down') - return jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' }) + return jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] }) }) as unknown as typeof fetch const errors: unknown[] = [] let rotated = 0 @@ -154,7 +154,7 @@ describe('createCertRotator (T13)', () => { timer.advance(500) await flush() expect(rotated).toBe(1) - expect(ks.loadCert()!.certPem).toBe('NEWCERT') + expect(ks.loadCert()!.certPem).toContain('NEWCERT') rotator.stop() rmSync(dir, { recursive: true, force: true }) })