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/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..7bc050b --- /dev/null +++ b/src/server/tls.test.ts @@ -0,0 +1,253 @@ +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', () => { + const bundle = 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', () => { + const bundle = 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', () => { + const bundle = 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.verify(ca.publicKey)).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', () => { + const bundle = ensureTls(tmpDir); + const server = new crypto.X509Certificate(bundle.serverCert); + const sanStr = server.subjectAltName ?? ''; + + expect(sanStr).toContain('IP Address:127.0.0.1'); + + for (const ip of bundle.sans) { + expect(sanStr).toContain(`IP Address:${ip}`); + } + }); + + test('persists files with correct permissions', () => { + 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); + expect(stat.mode & 0o777).toBe(0o600); + } + }); + + test('reuses valid certs without rewriting', async () => { + const bundle1 = ensureTls(tmpDir); + const mtime1 = fs.statSync(tlsCaCertPath(tmpDir)).mtimeMs; + + // Small delay to ensure mtime would differ if rewritten + await new Promise((r) => setTimeout(r, 50)); + + const bundle2 = 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', () => { + const bundle1 = ensureTls(tmpDir); + + // Replace server cert with CA cert (has no SANs matching LAN IPs) + fs.writeFileSync(tlsServerCertPath(tmpDir), bundle1.caCert, { mode: 0o600 }); + + 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 + expect(bundle2.serverCert).not.toBe(bundle1.caCert); + const server = new crypto.X509Certificate(bundle2.serverCert); + 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); + + fs.unlinkSync(tlsCaCertPath(tmpDir)); + + const bundle2 = ensureTls(tmpDir); + + 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); + + // 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', () => { + const bundle1 = ensureTls(tmpDir); + const bundle2 = 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..577b671 --- /dev/null +++ b/src/server/tls.ts @@ -0,0 +1,502 @@ +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]); + 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 { + 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 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 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 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() + options.validityYears); + + return buildTbs({ + serial: generateSerial(), + issuer: buildName(options.issuerCn), + subject: buildName(options.subjectCn), + validity: buildValidity(now, notAfter), + publicKeyInfo: Buffer.from(options.publicKeyDer), + extensions: buildExtensions(options.extensions), + }); +} + +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), + ], + }); + + return wrapAndSign(tbs, privateKey, privateKey); +} + +function generateServerCert(caKey: string, sans: string[]): { cert: string; key: string } { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + + 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), + ], + }); + + return wrapAndSign(tbs, crypto.createPrivateKey(caKey), privateKey); +} + +// --------------------------------------------------------------------------- +// 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 sans = parseIpSans(serverX509.subjectAltName); + + return { caCert, caKey, serverCert, serverKey, caFingerprint: fingerprint, sans }; + } catch { + return null; + } +} + +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; + } catch { + return false; + } +} + +function isServerCertValid( + serverCert: string, + serverKey: string, + caCert: string, + requiredIps: string[], + minDaysRemaining: number, +): boolean { + try { + 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 certIps = new Set(parseIpSans(serverX509.subjectAltName)); + + return requiredIps.every((ip) => certIps.has(ip)); + } catch { + return false; + } +} + +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 +// --------------------------------------------------------------------------- + +export function ensureTls(projectRoot: string): TlsBundle { + 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, existing.caKey, 30); + const serverOk = isServerCertValid( + existing.serverCert, + existing.serverKey, + existing.caCert, + 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, + }; +}