From 0f9a6068df4429cb96077f052e976c03a425faf0 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 01:04:16 -0600 Subject: [PATCH 1/3] feat: implement TLS certificate generation for HTTPS serving Add self-signed CA and server certificate generation using hand-coded ASN.1/DER encoding with only Node.js built-in crypto. Certificates include LAN IP SANs for mobile companion app connectivity. Implements cert reuse logic (CA preserved across server cert rotations), pairing URL generation, and LAN IP detection. Closes #65 --- src/lib/errors.ts | 7 + src/lib/paths.test.ts | 30 +++ src/lib/paths.ts | 24 ++ src/server/tls.test.ts | 200 +++++++++++++++++ src/server/tls.ts | 496 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 757 insertions(+) create mode 100644 src/server/tls.test.ts create mode 100644 src/server/tls.ts diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 0af4143..a500774 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -86,6 +86,13 @@ export class GhNotFoundError extends PpgError { } } +export class TlsError extends PpgError { + constructor(message: string) { + super(message, 'TLS_ERROR'); + this.name = 'TlsError'; + } +} + export class UnmergedWorkError extends PpgError { constructor(names: string[]) { const list = names.map((n) => ` ${n}`).join('\n'); diff --git a/src/lib/paths.test.ts b/src/lib/paths.test.ts index 57a62b0..d169040 100644 --- a/src/lib/paths.test.ts +++ b/src/lib/paths.test.ts @@ -14,6 +14,12 @@ import { promptFile, agentPromptsDir, agentPromptFile, + serveDir, + tlsDir, + tlsCaKeyPath, + tlsCaCertPath, + tlsServerKeyPath, + tlsServerCertPath, worktreeBaseDir, worktreePath, globalPpgDir, @@ -79,6 +85,30 @@ describe('paths', () => { ); }); + test('serveDir', () => { + expect(serveDir(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve')); + }); + + test('tlsDir', () => { + expect(tlsDir(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve', 'tls')); + }); + + test('tlsCaKeyPath', () => { + expect(tlsCaKeyPath(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve', 'tls', 'ca-key.pem')); + }); + + test('tlsCaCertPath', () => { + expect(tlsCaCertPath(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve', 'tls', 'ca-cert.pem')); + }); + + test('tlsServerKeyPath', () => { + expect(tlsServerKeyPath(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve', 'tls', 'server-key.pem')); + }); + + test('tlsServerCertPath', () => { + expect(tlsServerCertPath(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve', 'tls', 'server-cert.pem')); + }); + test('worktreeBaseDir', () => { expect(worktreeBaseDir(ROOT)).toBe(path.join(ROOT, '.worktrees')); }); diff --git a/src/lib/paths.ts b/src/lib/paths.ts index d456f5f..ca14764 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -79,6 +79,30 @@ export function cronPidPath(projectRoot: string): string { return path.join(ppgDir(projectRoot), 'cron.pid'); } +export function serveDir(projectRoot: string): string { + return path.join(ppgDir(projectRoot), 'serve'); +} + +export function tlsDir(projectRoot: string): string { + return path.join(serveDir(projectRoot), 'tls'); +} + +export function tlsCaKeyPath(projectRoot: string): string { + return path.join(tlsDir(projectRoot), 'ca-key.pem'); +} + +export function tlsCaCertPath(projectRoot: string): string { + return path.join(tlsDir(projectRoot), 'ca-cert.pem'); +} + +export function tlsServerKeyPath(projectRoot: string): string { + return path.join(tlsDir(projectRoot), 'server-key.pem'); +} + +export function tlsServerCertPath(projectRoot: string): string { + return path.join(tlsDir(projectRoot), 'server-cert.pem'); +} + export function worktreeBaseDir(projectRoot: string): string { return path.join(projectRoot, '.worktrees'); } diff --git a/src/server/tls.test.ts b/src/server/tls.test.ts new file mode 100644 index 0000000..cfc6957 --- /dev/null +++ b/src/server/tls.test.ts @@ -0,0 +1,200 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { ensureTls, getLanIps, buildPairingUrl } from './tls.js'; +import { + tlsCaKeyPath, + tlsCaCertPath, + tlsServerKeyPath, + tlsServerCertPath, +} from '../lib/paths.js'; + +vi.setConfig({ testTimeout: 30_000 }); + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ppg-tls-test-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('ensureTls', () => { + test('generates valid PEM certificates', async () => { + const bundle = await ensureTls(tmpDir); + + expect(bundle.caCert).toMatch(/^-----BEGIN CERTIFICATE-----/); + expect(bundle.caCert).toMatch(/-----END CERTIFICATE-----\n$/); + expect(bundle.caKey).toMatch(/^-----BEGIN PRIVATE KEY-----/); + expect(bundle.serverCert).toMatch(/^-----BEGIN CERTIFICATE-----/); + expect(bundle.serverKey).toMatch(/^-----BEGIN PRIVATE KEY-----/); + }); + + test('CA cert has cA:TRUE and ~10 year validity', async () => { + const bundle = await ensureTls(tmpDir); + const ca = new crypto.X509Certificate(bundle.caCert); + + expect(ca.subject).toBe('CN=ppg-ca'); + expect(ca.issuer).toBe('CN=ppg-ca'); + expect(ca.ca).toBe(true); + + const notAfter = new Date(ca.validTo); + const yearsFromNow = (notAfter.getTime() - Date.now()) / (1000 * 60 * 60 * 24 * 365); + expect(yearsFromNow).toBeGreaterThan(9); + expect(yearsFromNow).toBeLessThan(11); + }); + + test('server cert is signed by CA with ~1 year validity', async () => { + const bundle = await ensureTls(tmpDir); + const ca = new crypto.X509Certificate(bundle.caCert); + const server = new crypto.X509Certificate(bundle.serverCert); + + expect(server.subject).toBe('CN=ppg-server'); + expect(server.issuer).toBe('CN=ppg-ca'); + expect(server.checkIssued(ca)).toBe(true); + expect(server.ca).toBe(false); + + const notAfter = new Date(server.validTo); + const daysFromNow = (notAfter.getTime() - Date.now()) / (1000 * 60 * 60 * 24); + expect(daysFromNow).toBeGreaterThan(360); + expect(daysFromNow).toBeLessThan(370); + }); + + test('server cert includes correct SANs', async () => { + const bundle = await ensureTls(tmpDir); + const server = new crypto.X509Certificate(bundle.serverCert); + const sanStr = server.subjectAltName ?? ''; + + // Must include 127.0.0.1 + expect(sanStr).toContain('IP Address:127.0.0.1'); + + // All reported SANs should match + for (const ip of bundle.sans) { + expect(sanStr).toContain(`IP Address:${ip}`); + } + }); + + test('persists files with correct permissions', async () => { + await ensureTls(tmpDir); + + const files = [ + tlsCaKeyPath(tmpDir), + tlsCaCertPath(tmpDir), + tlsServerKeyPath(tmpDir), + tlsServerCertPath(tmpDir), + ]; + + for (const f of files) { + expect(fs.existsSync(f)).toBe(true); + const stat = fs.statSync(f); + // Owner read+write (0o600 = 384 decimal), mask out non-permission bits + expect(stat.mode & 0o777).toBe(0o600); + } + }); + + test('reuses valid certs without rewriting', async () => { + const bundle1 = await ensureTls(tmpDir); + const mtime1 = fs.statSync(tlsCaCertPath(tmpDir)).mtimeMs; + + // Small delay to ensure mtime would differ + await new Promise((r) => setTimeout(r, 50)); + + const bundle2 = await ensureTls(tmpDir); + const mtime2 = fs.statSync(tlsCaCertPath(tmpDir)).mtimeMs; + + expect(bundle2.caFingerprint).toBe(bundle1.caFingerprint); + expect(bundle2.caCert).toBe(bundle1.caCert); + expect(bundle2.serverCert).toBe(bundle1.serverCert); + expect(mtime2).toBe(mtime1); + }); + + test('regenerates server cert when SAN is missing', async () => { + const bundle1 = await ensureTls(tmpDir); + + // Overwrite server cert with one that has no SANs (corrupt it by removing SANs) + // Easiest: write a cert with a bogus SAN that won't match current IPs + const serverCertPath = tlsServerCertPath(tmpDir); + // Replace server cert content with CA cert (wrong SANs) + fs.writeFileSync(serverCertPath, bundle1.caCert, { mode: 0o600 }); + + const bundle2 = await ensureTls(tmpDir); + + // CA should be preserved + expect(bundle2.caCert).toBe(bundle1.caCert); + expect(bundle2.caFingerprint).toBe(bundle1.caFingerprint); + + // Server cert should be regenerated (different from CA cert) + expect(bundle2.serverCert).not.toBe(bundle1.caCert); + const server = new crypto.X509Certificate(bundle2.serverCert); + expect(server.subject).toBe('CN=ppg-server'); + }); + + test('regenerates everything when CA cert file is missing', async () => { + const bundle1 = await ensureTls(tmpDir); + + // Delete CA cert + fs.unlinkSync(tlsCaCertPath(tmpDir)); + + const bundle2 = await ensureTls(tmpDir); + + // Should have new CA + expect(bundle2.caFingerprint).not.toBe(bundle1.caFingerprint); + }); + + test('CA fingerprint is colon-delimited SHA-256 hex', async () => { + const bundle = await ensureTls(tmpDir); + + // Format: XX:XX:XX:... (32 hex pairs with colons) + expect(bundle.caFingerprint).toMatch(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/); + }); + + test('CA fingerprint is stable across calls', async () => { + const bundle1 = await ensureTls(tmpDir); + const bundle2 = await ensureTls(tmpDir); + + expect(bundle2.caFingerprint).toBe(bundle1.caFingerprint); + }); +}); + +describe('getLanIps', () => { + test('always includes 127.0.0.1', () => { + const ips = getLanIps(); + expect(ips).toContain('127.0.0.1'); + }); + + test('returns only IPv4 addresses', () => { + const ips = getLanIps(); + for (const ip of ips) { + expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/); + } + }); +}); + +describe('buildPairingUrl', () => { + test('formats ppg:// URL with query params', () => { + const url = buildPairingUrl({ + host: '192.168.1.5', + port: 3000, + caFingerprint: 'AA:BB:CC', + token: 'tok123', + }); + + expect(url).toBe('ppg://connect?host=192.168.1.5&port=3000&ca=AA%3ABB%3ACC&token=tok123'); + }); + + test('encodes special characters in params', () => { + const url = buildPairingUrl({ + host: '10.0.0.1', + port: 443, + caFingerprint: 'AA:BB', + token: 'a b+c', + }); + + expect(url).toContain('token=a+b%2Bc'); + }); +}); diff --git a/src/server/tls.ts b/src/server/tls.ts new file mode 100644 index 0000000..2afab35 --- /dev/null +++ b/src/server/tls.ts @@ -0,0 +1,496 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; + +import { + tlsDir, + tlsCaKeyPath, + tlsCaCertPath, + tlsServerKeyPath, + tlsServerCertPath, +} from '../lib/paths.js'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface TlsBundle { + caCert: string; + caKey: string; + serverCert: string; + serverKey: string; + caFingerprint: string; + sans: string[]; +} + +// --------------------------------------------------------------------------- +// ASN.1 / DER primitives +// --------------------------------------------------------------------------- + +function derLength(len: number): Buffer { + if (len < 0x80) return Buffer.from([len]); + if (len < 0x100) return Buffer.from([0x81, len]); + return Buffer.from([0x82, (len >> 8) & 0xff, len & 0xff]); +} + +function derTlv(tag: number, value: Buffer): Buffer { + return Buffer.concat([Buffer.from([tag]), derLength(value.length), value]); +} + +function derSeq(items: Buffer[]): Buffer { + return derTlv(0x30, Buffer.concat(items)); +} + +function derSet(items: Buffer[]): Buffer { + return derTlv(0x31, Buffer.concat(items)); +} + +function derInteger(n: Buffer | number): Buffer { + let buf: Buffer; + if (typeof n === 'number') { + // Encode small integers — used for version field (0, 2) + if (n === 0) { + buf = Buffer.from([0]); + } else { + const hex = n.toString(16); + buf = Buffer.from(hex.length % 2 ? '0' + hex : hex, 'hex'); + if (buf[0] & 0x80) buf = Buffer.concat([Buffer.from([0]), buf]); + } + } else { + buf = n; + if (buf[0] & 0x80) buf = Buffer.concat([Buffer.from([0]), buf]); + } + return derTlv(0x02, buf); +} + +function derOid(encoded: number[]): Buffer { + return derTlv(0x06, Buffer.from(encoded)); +} + +function derUtf8(s: string): Buffer { + return derTlv(0x0c, Buffer.from(s, 'utf8')); +} + +function derUtcTime(d: Date): Buffer { + const s = + String(d.getUTCFullYear()).slice(2) + + String(d.getUTCMonth() + 1).padStart(2, '0') + + String(d.getUTCDate()).padStart(2, '0') + + String(d.getUTCHours()).padStart(2, '0') + + String(d.getUTCMinutes()).padStart(2, '0') + + String(d.getUTCSeconds()).padStart(2, '0') + + 'Z'; + return derTlv(0x17, Buffer.from(s, 'ascii')); +} + +function derGeneralizedTime(d: Date): Buffer { + const s = + String(d.getUTCFullYear()) + + String(d.getUTCMonth() + 1).padStart(2, '0') + + String(d.getUTCDate()).padStart(2, '0') + + String(d.getUTCHours()).padStart(2, '0') + + String(d.getUTCMinutes()).padStart(2, '0') + + String(d.getUTCSeconds()).padStart(2, '0') + + 'Z'; + return derTlv(0x18, Buffer.from(s, 'ascii')); +} + +function derBitString(data: Buffer): Buffer { + // Prepend 0x00 (unused-bits count) + return derTlv(0x03, Buffer.concat([Buffer.from([0]), data])); +} + +function derNull(): Buffer { + return Buffer.from([0x05, 0x00]); +} + +/** Context-tagged explicit wrapper: [tagNum] EXPLICIT */ +function derContextExplicit(tagNum: number, inner: Buffer): Buffer { + return derTlv(0xa0 | tagNum, inner); +} + +/** Context-tagged OCTET STRING wrapper */ +function derContextOctetString(tagNum: number, inner: Buffer): Buffer { + return derTlv(0x80 | tagNum, inner); +} + +// --------------------------------------------------------------------------- +// OIDs +// --------------------------------------------------------------------------- + +// sha256WithRSAEncryption 1.2.840.113549.1.1.11 +const OID_SHA256_RSA = [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b]; +// commonName 2.5.4.3 +const OID_CN = [0x55, 0x04, 0x03]; +// basicConstraints 2.5.29.19 +const OID_BASIC_CONSTRAINTS = [0x55, 0x1d, 0x13]; +// keyUsage 2.5.29.15 +const OID_KEY_USAGE = [0x55, 0x1d, 0x0f]; +// subjectAltName 2.5.29.17 +const OID_SAN = [0x55, 0x1d, 0x11]; + +// --------------------------------------------------------------------------- +// Structural helpers +// --------------------------------------------------------------------------- + +function buildAlgorithmIdentifier(): Buffer { + return derSeq([derOid(OID_SHA256_RSA), derNull()]); +} + +function buildName(cn: string): Buffer { + const rdn = derSet([derSeq([derOid(OID_CN), derUtf8(cn)])]); + return derSeq([rdn]); +} + +function buildValidity(from: Date, to: Date): Buffer { + // Use UTCTime for dates before 2050, GeneralizedTime otherwise + const encodeTime = (d: Date) => + d.getUTCFullYear() < 2050 ? derUtcTime(d) : derGeneralizedTime(d); + return derSeq([encodeTime(from), encodeTime(to)]); +} + +function buildBasicConstraintsExt(isCA: boolean, critical: boolean): Buffer { + const value = derSeq(isCA ? [derTlv(0x01, Buffer.from([0xff]))] : []); + const octetValue = derTlv(0x04, value); + const parts: Buffer[] = [derOid(OID_BASIC_CONSTRAINTS)]; + if (critical) parts.push(derTlv(0x01, Buffer.from([0xff]))); + parts.push(octetValue); + return derSeq(parts); +} + +function buildKeyUsageExt(isCA: boolean, critical: boolean): Buffer { + let bits: number; + if (isCA) { + // keyCertSign (5) | cRLSign (6) → byte = 0x06, unused = 1 + bits = 0x06; + } else { + // digitalSignature (0) | keyEncipherment (2) → byte = 0xa0, unused = 5 + bits = 0xa0; + } + const unusedBits = isCA ? 1 : 5; + const bitStringContent = Buffer.from([unusedBits, bits]); + const bitString = derTlv(0x03, bitStringContent); + const octetValue = derTlv(0x04, bitString); + const parts: Buffer[] = [derOid(OID_KEY_USAGE)]; + if (critical) parts.push(derTlv(0x01, Buffer.from([0xff]))); + parts.push(octetValue); + return derSeq(parts); +} + +function buildSanExt(ips: string[]): Buffer { + const names = ips.map((ip) => { + const bytes = ip.split('.').map(Number); + return derContextOctetString(7, Buffer.from(bytes)); + }); + const sanValue = derSeq(names); + const octetValue = derTlv(0x04, sanValue); + return derSeq([derOid(OID_SAN), octetValue]); +} + +function buildExtensions(exts: Buffer[]): Buffer { + return derContextExplicit(3, derSeq(exts)); +} + +// --------------------------------------------------------------------------- +// Certificate generation +// --------------------------------------------------------------------------- + +function generateSerial(): Buffer { + const bytes = crypto.randomBytes(16); + // Ensure positive (clear high bit) + bytes[0] &= 0x7f; + // Ensure non-zero + if (bytes[0] === 0) bytes[0] = 1; + return bytes; +} + +function buildTbs(options: { + serial: Buffer; + issuer: Buffer; + subject: Buffer; + validity: Buffer; + publicKeyInfo: Buffer; + extensions: Buffer; +}): Buffer { + return derSeq([ + derContextExplicit(0, derInteger(2)), // v3 + derInteger(options.serial), + buildAlgorithmIdentifier(), + options.issuer, + options.validity, + options.subject, + options.publicKeyInfo, + options.extensions, + ]); +} + +function signTbs(tbs: Buffer, privateKey: crypto.KeyObject): Buffer { + const sig = crypto.sign('sha256', tbs, privateKey); + return sig; +} + +function wrapCertificate(tbs: Buffer, signature: Buffer): Buffer { + return derSeq([tbs, buildAlgorithmIdentifier(), derBitString(signature)]); +} + +function toPem(tag: string, der: Buffer): string { + const b64 = der.toString('base64'); + const lines: string[] = []; + for (let i = 0; i < b64.length; i += 64) { + lines.push(b64.slice(i, i + 64)); + } + return `-----BEGIN ${tag}-----\n${lines.join('\n')}\n-----END ${tag}-----\n`; +} + +function generateKeyPair(): { publicKey: crypto.KeyObject; privateKey: crypto.KeyObject } { + return crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); +} + +function generateCaCert(): { cert: string; key: string } { + const { publicKey, privateKey } = generateKeyPair(); + + const now = new Date(); + const notAfter = new Date(now); + notAfter.setUTCFullYear(notAfter.getUTCFullYear() + 10); + + const publicKeyDer = publicKey.export({ type: 'spki', format: 'der' }); + + const issuer = buildName('ppg-ca'); + const subject = buildName('ppg-ca'); + + const exts = buildExtensions([ + buildBasicConstraintsExt(true, true), + buildKeyUsageExt(true, true), + ]); + + const tbs = buildTbs({ + serial: generateSerial(), + issuer, + subject, + validity: buildValidity(now, notAfter), + publicKeyInfo: Buffer.from(publicKeyDer), + extensions: exts, + }); + + const signature = signTbs(tbs, privateKey); + const certDer = wrapCertificate(tbs, signature); + + const certPem = toPem('CERTIFICATE', certDer); + const keyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + + return { cert: certPem, key: keyPem }; +} + +function generateServerCert( + caKey: string, + sans: string[], +): { cert: string; key: string } { + const { publicKey, privateKey } = generateKeyPair(); + const caPrivateKey = crypto.createPrivateKey(caKey); + + const now = new Date(); + const notAfter = new Date(now); + notAfter.setUTCFullYear(notAfter.getUTCFullYear() + 1); + + const publicKeyDer = publicKey.export({ type: 'spki', format: 'der' }); + + const issuer = buildName('ppg-ca'); + const subject = buildName('ppg-server'); + + const exts = buildExtensions([ + buildBasicConstraintsExt(false, false), + buildKeyUsageExt(false, false), + buildSanExt(sans), + ]); + + const tbs = buildTbs({ + serial: generateSerial(), + issuer, + subject, + validity: buildValidity(now, notAfter), + publicKeyInfo: Buffer.from(publicKeyDer), + extensions: exts, + }); + + const signature = signTbs(tbs, caPrivateKey); + const certDer = wrapCertificate(tbs, signature); + + const certPem = toPem('CERTIFICATE', certDer); + const keyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + + return { cert: certPem, key: keyPem }; +} + +// --------------------------------------------------------------------------- +// LAN IP detection +// --------------------------------------------------------------------------- + +export function getLanIps(): string[] { + const interfaces = os.networkInterfaces(); + const ips = new Set(); + ips.add('127.0.0.1'); + + for (const infos of Object.values(interfaces)) { + if (!infos) continue; + for (const info of infos) { + if (info.family === 'IPv4' && !info.internal) { + ips.add(info.address); + } + } + } + + return [...ips]; +} + +// --------------------------------------------------------------------------- +// Pairing URL +// --------------------------------------------------------------------------- + +export function buildPairingUrl(params: { + host: string; + port: number; + caFingerprint: string; + token: string; +}): string { + const q = new URLSearchParams({ + host: params.host, + port: String(params.port), + ca: params.caFingerprint, + token: params.token, + }); + return `ppg://connect?${q.toString()}`; +} + +// --------------------------------------------------------------------------- +// File I/O and reuse logic +// --------------------------------------------------------------------------- + +function loadTlsBundle(projectRoot: string): TlsBundle | null { + const paths = [ + tlsCaKeyPath(projectRoot), + tlsCaCertPath(projectRoot), + tlsServerKeyPath(projectRoot), + tlsServerCertPath(projectRoot), + ]; + + const contents: string[] = []; + for (const p of paths) { + try { + contents.push(fs.readFileSync(p, 'utf8')); + } catch { + return null; + } + } + + const [caKey, caCert, serverKey, serverCert] = contents; + + try { + const x509 = new crypto.X509Certificate(caCert); + const serverX509 = new crypto.X509Certificate(serverCert); + const fingerprint = x509.fingerprint256; + const sanStr = serverX509.subjectAltName ?? ''; + const sans = [...sanStr.matchAll(/IP Address:(\d+\.\d+\.\d+\.\d+)/g)].map( + (m) => m[1], + ); + + return { caCert, caKey, serverCert, serverKey, caFingerprint: fingerprint, sans }; + } catch { + return null; + } +} + +function isCaValid(caCert: string, minDaysRemaining: number): boolean { + try { + const x509 = new crypto.X509Certificate(caCert); + const notAfter = new Date(x509.validTo); + const remaining = (notAfter.getTime() - Date.now()) / (1000 * 60 * 60 * 24); + return remaining > minDaysRemaining; + } catch { + return false; + } +} + +function isServerCertValid( + serverCert: string, + requiredIps: string[], + minDaysRemaining: number, +): boolean { + try { + const x509 = new crypto.X509Certificate(serverCert); + const notAfter = new Date(x509.validTo); + const remaining = (notAfter.getTime() - Date.now()) / (1000 * 60 * 60 * 24); + if (remaining <= minDaysRemaining) return false; + + const sanStr = x509.subjectAltName ?? ''; + const certIps = new Set( + [...sanStr.matchAll(/IP Address:(\d+\.\d+\.\d+\.\d+)/g)].map((m) => m[1]), + ); + + return requiredIps.every((ip) => certIps.has(ip)); + } catch { + return false; + } +} + +function writePemFile(filePath: string, content: string): void { + fs.writeFileSync(filePath, content, { mode: 0o600 }); +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export async function ensureTls(projectRoot: string): Promise { + const dir = tlsDir(projectRoot); + fs.mkdirSync(dir, { recursive: true }); + + const lanIps = getLanIps(); + const existing = loadTlsBundle(projectRoot); + + if (existing) { + // Check if everything is still valid + const caOk = isCaValid(existing.caCert, 30); + const serverOk = isServerCertValid(existing.serverCert, lanIps, 7); + + if (caOk && serverOk) { + return existing; + } + + // CA still valid — only regenerate server cert + if (caOk) { + const server = generateServerCert(existing.caKey, lanIps); + writePemFile(tlsServerKeyPath(projectRoot), server.key); + writePemFile(tlsServerCertPath(projectRoot), server.cert); + + const x509 = new crypto.X509Certificate(existing.caCert); + return { + caCert: existing.caCert, + caKey: existing.caKey, + serverCert: server.cert, + serverKey: server.key, + caFingerprint: x509.fingerprint256, + sans: lanIps, + }; + } + } + + // Generate everything fresh + const ca = generateCaCert(); + const server = generateServerCert(ca.key, lanIps); + + writePemFile(tlsCaKeyPath(projectRoot), ca.key); + writePemFile(tlsCaCertPath(projectRoot), ca.cert); + writePemFile(tlsServerKeyPath(projectRoot), server.key); + writePemFile(tlsServerCertPath(projectRoot), server.cert); + + const x509 = new crypto.X509Certificate(ca.cert); + + return { + caCert: ca.cert, + caKey: ca.key, + serverCert: server.cert, + serverKey: server.key, + caFingerprint: x509.fingerprint256, + sans: lanIps, + }; +} From 837e0f8cb86545ea44b0f965c31bb501add82130 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 07:56:55 -0600 Subject: [PATCH 2/3] fix: address code review findings for TLS cert generation - Make ensureTls sync (no await expressions existed) - Remove unused TlsError class (YAGNI) - Extract shared cert generation into buildCertTbs/wrapAndSign helpers - Inline signTbs wrapper into wrapAndSign - Add derLength overflow guard for lengths > 65535 - Add test for corrupt PEM file handling (graceful regeneration) --- src/lib/errors.ts | 7 --- src/server/tls.test.ts | 77 +++++++++++++------------ src/server/tls.ts | 124 ++++++++++++++++++----------------------- 3 files changed, 97 insertions(+), 111 deletions(-) diff --git a/src/lib/errors.ts b/src/lib/errors.ts index a500774..0af4143 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -86,13 +86,6 @@ export class GhNotFoundError extends PpgError { } } -export class TlsError extends PpgError { - constructor(message: string) { - super(message, 'TLS_ERROR'); - this.name = 'TlsError'; - } -} - export class UnmergedWorkError extends PpgError { constructor(names: string[]) { const list = names.map((n) => ` ${n}`).join('\n'); diff --git a/src/server/tls.test.ts b/src/server/tls.test.ts index cfc6957..fcba1cd 100644 --- a/src/server/tls.test.ts +++ b/src/server/tls.test.ts @@ -25,8 +25,8 @@ afterEach(() => { }); describe('ensureTls', () => { - test('generates valid PEM certificates', async () => { - const bundle = await ensureTls(tmpDir); + test('generates valid PEM certificates', () => { + const bundle = ensureTls(tmpDir); expect(bundle.caCert).toMatch(/^-----BEGIN CERTIFICATE-----/); expect(bundle.caCert).toMatch(/-----END CERTIFICATE-----\n$/); @@ -35,8 +35,8 @@ describe('ensureTls', () => { expect(bundle.serverKey).toMatch(/^-----BEGIN PRIVATE KEY-----/); }); - test('CA cert has cA:TRUE and ~10 year validity', async () => { - const bundle = await ensureTls(tmpDir); + test('CA cert has cA:TRUE and ~10 year validity', () => { + const bundle = ensureTls(tmpDir); const ca = new crypto.X509Certificate(bundle.caCert); expect(ca.subject).toBe('CN=ppg-ca'); @@ -49,8 +49,8 @@ describe('ensureTls', () => { expect(yearsFromNow).toBeLessThan(11); }); - test('server cert is signed by CA with ~1 year validity', async () => { - const bundle = await ensureTls(tmpDir); + test('server cert is signed by CA with ~1 year validity', () => { + const bundle = ensureTls(tmpDir); const ca = new crypto.X509Certificate(bundle.caCert); const server = new crypto.X509Certificate(bundle.serverCert); @@ -65,22 +65,20 @@ describe('ensureTls', () => { expect(daysFromNow).toBeLessThan(370); }); - test('server cert includes correct SANs', async () => { - const bundle = await ensureTls(tmpDir); + test('server cert includes correct SANs', () => { + const bundle = ensureTls(tmpDir); const server = new crypto.X509Certificate(bundle.serverCert); const sanStr = server.subjectAltName ?? ''; - // Must include 127.0.0.1 expect(sanStr).toContain('IP Address:127.0.0.1'); - // All reported SANs should match for (const ip of bundle.sans) { expect(sanStr).toContain(`IP Address:${ip}`); } }); - test('persists files with correct permissions', async () => { - await ensureTls(tmpDir); + test('persists files with correct permissions', () => { + ensureTls(tmpDir); const files = [ tlsCaKeyPath(tmpDir), @@ -92,19 +90,18 @@ describe('ensureTls', () => { for (const f of files) { expect(fs.existsSync(f)).toBe(true); const stat = fs.statSync(f); - // Owner read+write (0o600 = 384 decimal), mask out non-permission bits expect(stat.mode & 0o777).toBe(0o600); } }); test('reuses valid certs without rewriting', async () => { - const bundle1 = await ensureTls(tmpDir); + const bundle1 = ensureTls(tmpDir); const mtime1 = fs.statSync(tlsCaCertPath(tmpDir)).mtimeMs; - // Small delay to ensure mtime would differ + // Small delay to ensure mtime would differ if rewritten await new Promise((r) => setTimeout(r, 50)); - const bundle2 = await ensureTls(tmpDir); + const bundle2 = ensureTls(tmpDir); const mtime2 = fs.statSync(tlsCaCertPath(tmpDir)).mtimeMs; expect(bundle2.caFingerprint).toBe(bundle1.caFingerprint); @@ -113,49 +110,59 @@ describe('ensureTls', () => { expect(mtime2).toBe(mtime1); }); - test('regenerates server cert when SAN is missing', async () => { - const bundle1 = await ensureTls(tmpDir); + test('regenerates server cert when SAN is missing', () => { + const bundle1 = ensureTls(tmpDir); - // Overwrite server cert with one that has no SANs (corrupt it by removing SANs) - // Easiest: write a cert with a bogus SAN that won't match current IPs - const serverCertPath = tlsServerCertPath(tmpDir); - // Replace server cert content with CA cert (wrong SANs) - fs.writeFileSync(serverCertPath, bundle1.caCert, { mode: 0o600 }); + // Replace server cert with CA cert (has no SANs matching LAN IPs) + fs.writeFileSync(tlsServerCertPath(tmpDir), bundle1.caCert, { mode: 0o600 }); - const bundle2 = await ensureTls(tmpDir); + const bundle2 = ensureTls(tmpDir); // CA should be preserved expect(bundle2.caCert).toBe(bundle1.caCert); expect(bundle2.caFingerprint).toBe(bundle1.caFingerprint); - // Server cert should be regenerated (different from CA cert) + // Server cert should be regenerated expect(bundle2.serverCert).not.toBe(bundle1.caCert); const server = new crypto.X509Certificate(bundle2.serverCert); expect(server.subject).toBe('CN=ppg-server'); }); - test('regenerates everything when CA cert file is missing', async () => { - const bundle1 = await ensureTls(tmpDir); + test('regenerates everything when CA cert file is missing', () => { + const bundle1 = ensureTls(tmpDir); - // Delete CA cert fs.unlinkSync(tlsCaCertPath(tmpDir)); - const bundle2 = await ensureTls(tmpDir); + const bundle2 = ensureTls(tmpDir); - // Should have new CA expect(bundle2.caFingerprint).not.toBe(bundle1.caFingerprint); }); - test('CA fingerprint is colon-delimited SHA-256 hex', async () => { - const bundle = await ensureTls(tmpDir); + test('regenerates everything when PEM files contain garbage', () => { + ensureTls(tmpDir); + + // Corrupt both cert files with garbage + fs.writeFileSync(tlsCaCertPath(tmpDir), 'not a cert', { mode: 0o600 }); + fs.writeFileSync(tlsServerCertPath(tmpDir), 'also garbage', { mode: 0o600 }); + + // Should regenerate without throwing + const bundle = ensureTls(tmpDir); + + expect(bundle.caCert).toMatch(/^-----BEGIN CERTIFICATE-----/); + const ca = new crypto.X509Certificate(bundle.caCert); + expect(ca.subject).toBe('CN=ppg-ca'); + }); + + test('CA fingerprint is colon-delimited SHA-256 hex', () => { + const bundle = ensureTls(tmpDir); // Format: XX:XX:XX:... (32 hex pairs with colons) expect(bundle.caFingerprint).toMatch(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/); }); - test('CA fingerprint is stable across calls', async () => { - const bundle1 = await ensureTls(tmpDir); - const bundle2 = await ensureTls(tmpDir); + test('CA fingerprint is stable across calls', () => { + const bundle1 = ensureTls(tmpDir); + const bundle2 = ensureTls(tmpDir); expect(bundle2.caFingerprint).toBe(bundle1.caFingerprint); }); diff --git a/src/server/tls.ts b/src/server/tls.ts index 2afab35..dec105e 100644 --- a/src/server/tls.ts +++ b/src/server/tls.ts @@ -30,7 +30,8 @@ export interface TlsBundle { function derLength(len: number): Buffer { if (len < 0x80) return Buffer.from([len]); if (len < 0x100) return Buffer.from([0x81, len]); - return Buffer.from([0x82, (len >> 8) & 0xff, len & 0xff]); + if (len <= 0xffff) return Buffer.from([0x82, (len >> 8) & 0xff, len & 0xff]); + throw new Error(`DER length ${len} exceeds 2-byte encoding`); } function derTlv(tag: number, value: Buffer): Buffer { @@ -224,11 +225,6 @@ function buildTbs(options: { ]); } -function signTbs(tbs: Buffer, privateKey: crypto.KeyObject): Buffer { - const sig = crypto.sign('sha256', tbs, privateKey); - return sig; -} - function wrapCertificate(tbs: Buffer, signature: Buffer): Buffer { return derSeq([tbs, buildAlgorithmIdentifier(), derBitString(signature)]); } @@ -242,83 +238,73 @@ function toPem(tag: string, der: Buffer): string { return `-----BEGIN ${tag}-----\n${lines.join('\n')}\n-----END ${tag}-----\n`; } -function generateKeyPair(): { publicKey: crypto.KeyObject; privateKey: crypto.KeyObject } { - return crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); +function wrapAndSign( + tbs: Buffer, + signingKey: crypto.KeyObject, + subjectKey: crypto.KeyObject, +): { cert: string; key: string } { + const signature = crypto.sign('sha256', tbs, signingKey); + return { + cert: toPem('CERTIFICATE', wrapCertificate(tbs, signature)), + key: subjectKey.export({ type: 'pkcs8', format: 'pem' }) as string, + }; } -function generateCaCert(): { cert: string; key: string } { - const { publicKey, privateKey } = generateKeyPair(); - +function buildCertTbs(options: { + issuerCn: string; + subjectCn: string; + validityYears: number; + publicKeyDer: Buffer; + extensions: Buffer[]; +}): Buffer { const now = new Date(); const notAfter = new Date(now); - notAfter.setUTCFullYear(notAfter.getUTCFullYear() + 10); - - const publicKeyDer = publicKey.export({ type: 'spki', format: 'der' }); + notAfter.setUTCFullYear(notAfter.getUTCFullYear() + options.validityYears); - const issuer = buildName('ppg-ca'); - const subject = buildName('ppg-ca'); - - const exts = buildExtensions([ - buildBasicConstraintsExt(true, true), - buildKeyUsageExt(true, true), - ]); - - const tbs = buildTbs({ + return buildTbs({ serial: generateSerial(), - issuer, - subject, + issuer: buildName(options.issuerCn), + subject: buildName(options.subjectCn), validity: buildValidity(now, notAfter), - publicKeyInfo: Buffer.from(publicKeyDer), - extensions: exts, + publicKeyInfo: Buffer.from(options.publicKeyDer), + extensions: buildExtensions(options.extensions), }); - - const signature = signTbs(tbs, privateKey); - const certDer = wrapCertificate(tbs, signature); - - const certPem = toPem('CERTIFICATE', certDer); - const keyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; - - return { cert: certPem, key: keyPem }; } -function generateServerCert( - caKey: string, - sans: string[], -): { cert: string; key: string } { - const { publicKey, privateKey } = generateKeyPair(); - const caPrivateKey = crypto.createPrivateKey(caKey); - - const now = new Date(); - const notAfter = new Date(now); - notAfter.setUTCFullYear(notAfter.getUTCFullYear() + 1); - - const publicKeyDer = publicKey.export({ type: 'spki', format: 'der' }); +function generateCaCert(): { cert: string; key: string } { + // Self-signed: same keypair for subject and signer + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + + const tbs = buildCertTbs({ + issuerCn: 'ppg-ca', + subjectCn: 'ppg-ca', + validityYears: 10, + publicKeyDer: publicKey.export({ type: 'spki', format: 'der' }), + extensions: [ + buildBasicConstraintsExt(true, true), + buildKeyUsageExt(true, true), + ], + }); - const issuer = buildName('ppg-ca'); - const subject = buildName('ppg-server'); + return wrapAndSign(tbs, privateKey, privateKey); +} - const exts = buildExtensions([ - buildBasicConstraintsExt(false, false), - buildKeyUsageExt(false, false), - buildSanExt(sans), - ]); +function generateServerCert(caKey: string, sans: string[]): { cert: string; key: string } { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); - const tbs = buildTbs({ - serial: generateSerial(), - issuer, - subject, - validity: buildValidity(now, notAfter), - publicKeyInfo: Buffer.from(publicKeyDer), - extensions: exts, + const tbs = buildCertTbs({ + issuerCn: 'ppg-ca', + subjectCn: 'ppg-server', + validityYears: 1, + publicKeyDer: publicKey.export({ type: 'spki', format: 'der' }), + extensions: [ + buildBasicConstraintsExt(false, false), + buildKeyUsageExt(false, false), + buildSanExt(sans), + ], }); - const signature = signTbs(tbs, caPrivateKey); - const certDer = wrapCertificate(tbs, signature); - - const certPem = toPem('CERTIFICATE', certDer); - const keyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; - - return { cert: certPem, key: keyPem }; + return wrapAndSign(tbs, crypto.createPrivateKey(caKey), privateKey); } // --------------------------------------------------------------------------- @@ -440,7 +426,7 @@ function writePemFile(filePath: string, content: string): void { // Main entry point // --------------------------------------------------------------------------- -export async function ensureTls(projectRoot: string): Promise { +export function ensureTls(projectRoot: string): TlsBundle { const dir = tlsDir(projectRoot); fs.mkdirSync(dir, { recursive: true }); From 2ee654430e9d2374673480b0adc107e40fcb1693 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 08:34:30 -0600 Subject: [PATCH 3/3] Harden TLS cert reuse validation and fix spawn test typing --- src/commands/spawn.test.ts | 5 ++-- src/server/tls.test.ts | 48 +++++++++++++++++++++++++++++++++++++- src/server/tls.ts | 46 +++++++++++++++++++++++++----------- 3 files changed, 83 insertions(+), 16 deletions(-) diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..12ecbc8 100644 --- a/src/commands/spawn.test.ts +++ b/src/commands/spawn.test.ts @@ -7,6 +7,7 @@ import { spawnAgent } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; import { agentId, sessionId } from '../lib/id.js'; import * as tmux from '../core/tmux.js'; +import type { Manifest } from '../types/manifest.js'; vi.mock('node:fs/promises', async () => { const actual = await vi.importActual('node:fs/promises'); @@ -79,7 +80,7 @@ const mockedEnsureSession = vi.mocked(tmux.ensureSession); const mockedCreateWindow = vi.mocked(tmux.createWindow); const mockedSplitPane = vi.mocked(tmux.splitPane); -function createManifest(tmuxWindow = '') { +function createManifest(tmuxWindow = ''): Manifest { return { version: 1 as const, projectRoot: '/tmp/repo', @@ -103,7 +104,7 @@ function createManifest(tmuxWindow = '') { } describe('spawnCommand', () => { - let manifestState = createManifest(); + let manifestState: Manifest = createManifest(); let nextAgent = 1; let nextSession = 1; diff --git a/src/server/tls.test.ts b/src/server/tls.test.ts index fcba1cd..7bc050b 100644 --- a/src/server/tls.test.ts +++ b/src/server/tls.test.ts @@ -56,7 +56,7 @@ describe('ensureTls', () => { expect(server.subject).toBe('CN=ppg-server'); expect(server.issuer).toBe('CN=ppg-ca'); - expect(server.checkIssued(ca)).toBe(true); + expect(server.verify(ca.publicKey)).toBe(true); expect(server.ca).toBe(false); const notAfter = new Date(server.validTo); @@ -128,6 +128,41 @@ describe('ensureTls', () => { expect(server.subject).toBe('CN=ppg-server'); }); + test('regenerates server cert when signed by a different CA', () => { + const bundle1 = ensureTls(tmpDir); + const otherDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ppg-tls-test-other-')); + + try { + const otherBundle = ensureTls(otherDir); + fs.writeFileSync(tlsServerCertPath(tmpDir), otherBundle.serverCert, { mode: 0o600 }); + fs.writeFileSync(tlsServerKeyPath(tmpDir), otherBundle.serverKey, { mode: 0o600 }); + + const bundle2 = ensureTls(tmpDir); + const ca = new crypto.X509Certificate(bundle1.caCert); + const server = new crypto.X509Certificate(bundle2.serverCert); + + expect(bundle2.caFingerprint).toBe(bundle1.caFingerprint); + expect(server.verify(ca.publicKey)).toBe(true); + expect(bundle2.serverCert).not.toBe(otherBundle.serverCert); + } finally { + fs.rmSync(otherDir, { recursive: true, force: true }); + } + }); + + test('regenerates server cert when server key does not match cert', () => { + const bundle1 = ensureTls(tmpDir); + const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const wrongKey = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + fs.writeFileSync(tlsServerKeyPath(tmpDir), wrongKey, { mode: 0o600 }); + + const bundle2 = ensureTls(tmpDir); + const server = new crypto.X509Certificate(bundle2.serverCert); + + expect(bundle2.caFingerprint).toBe(bundle1.caFingerprint); + expect(bundle2.serverKey).not.toBe(wrongKey); + expect(server.checkPrivateKey(crypto.createPrivateKey(bundle2.serverKey))).toBe(true); + }); + test('regenerates everything when CA cert file is missing', () => { const bundle1 = ensureTls(tmpDir); @@ -138,6 +173,17 @@ describe('ensureTls', () => { expect(bundle2.caFingerprint).not.toBe(bundle1.caFingerprint); }); + test('regenerates everything when CA key does not match CA cert', () => { + const bundle1 = ensureTls(tmpDir); + const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const wrongCaKey = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + fs.writeFileSync(tlsCaKeyPath(tmpDir), wrongCaKey, { mode: 0o600 }); + + const bundle2 = ensureTls(tmpDir); + + expect(bundle2.caFingerprint).not.toBe(bundle1.caFingerprint); + }); + test('regenerates everything when PEM files contain garbage', () => { ensureTls(tmpDir); diff --git a/src/server/tls.ts b/src/server/tls.ts index dec105e..577b671 100644 --- a/src/server/tls.ts +++ b/src/server/tls.ts @@ -374,10 +374,7 @@ function loadTlsBundle(projectRoot: string): TlsBundle | null { const x509 = new crypto.X509Certificate(caCert); const serverX509 = new crypto.X509Certificate(serverCert); const fingerprint = x509.fingerprint256; - const sanStr = serverX509.subjectAltName ?? ''; - const sans = [...sanStr.matchAll(/IP Address:(\d+\.\d+\.\d+\.\d+)/g)].map( - (m) => m[1], - ); + const sans = parseIpSans(serverX509.subjectAltName); return { caCert, caKey, serverCert, serverKey, caFingerprint: fingerprint, sans }; } catch { @@ -385,9 +382,15 @@ function loadTlsBundle(projectRoot: string): TlsBundle | null { } } -function isCaValid(caCert: string, minDaysRemaining: number): boolean { +function isCaValid(caCert: string, caKey: string, minDaysRemaining: number): boolean { try { const x509 = new crypto.X509Certificate(caCert); + if (x509.subject !== 'CN=ppg-ca' || x509.issuer !== 'CN=ppg-ca' || !x509.ca) { + return false; + } + if (!x509.verify(x509.publicKey)) return false; + if (!x509.checkPrivateKey(crypto.createPrivateKey(caKey))) return false; + const notAfter = new Date(x509.validTo); const remaining = (notAfter.getTime() - Date.now()) / (1000 * 60 * 60 * 24); return remaining > minDaysRemaining; @@ -398,19 +401,25 @@ function isCaValid(caCert: string, minDaysRemaining: number): boolean { function isServerCertValid( serverCert: string, + serverKey: string, + caCert: string, requiredIps: string[], minDaysRemaining: number, ): boolean { try { - const x509 = new crypto.X509Certificate(serverCert); - const notAfter = new Date(x509.validTo); + const caX509 = new crypto.X509Certificate(caCert); + const serverX509 = new crypto.X509Certificate(serverCert); + const notAfter = new Date(serverX509.validTo); const remaining = (notAfter.getTime() - Date.now()) / (1000 * 60 * 60 * 24); if (remaining <= minDaysRemaining) return false; + if (serverX509.subject !== 'CN=ppg-server' || serverX509.issuer !== caX509.subject) { + return false; + } + if (serverX509.ca) return false; + if (!serverX509.verify(caX509.publicKey)) return false; + if (!serverX509.checkPrivateKey(crypto.createPrivateKey(serverKey))) return false; - const sanStr = x509.subjectAltName ?? ''; - const certIps = new Set( - [...sanStr.matchAll(/IP Address:(\d+\.\d+\.\d+\.\d+)/g)].map((m) => m[1]), - ); + const certIps = new Set(parseIpSans(serverX509.subjectAltName)); return requiredIps.every((ip) => certIps.has(ip)); } catch { @@ -422,6 +431,11 @@ function writePemFile(filePath: string, content: string): void { fs.writeFileSync(filePath, content, { mode: 0o600 }); } +function parseIpSans(subjectAltName: string | undefined): string[] { + const sanStr = subjectAltName ?? ''; + return [...sanStr.matchAll(/IP Address:(\d+\.\d+\.\d+\.\d+)/g)].map((m) => m[1]); +} + // --------------------------------------------------------------------------- // Main entry point // --------------------------------------------------------------------------- @@ -435,8 +449,14 @@ export function ensureTls(projectRoot: string): TlsBundle { if (existing) { // Check if everything is still valid - const caOk = isCaValid(existing.caCert, 30); - const serverOk = isServerCertValid(existing.serverCert, lanIps, 7); + const caOk = isCaValid(existing.caCert, existing.caKey, 30); + const serverOk = isServerCertValid( + existing.serverCert, + existing.serverKey, + existing.caCert, + lanIps, + 7, + ); if (caOk && serverOk) { return existing;