/** * Self-signed TLS certificate generation + fingerprint. * * Generates a self-signed cert for the sidecar server. The SHA-256 fingerprint * is included in the QR pairing URL (IC-3 `fp` field) so the iOS app can pin. * * Uses openssl CLI (available on macOS/Linux). Falls back to plain HTTP if * openssl is not available. * * Owner: T-1.3 */ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); export interface TlsCert { certPath: string; keyPath: string; /** SHA-256 hex fingerprint of the DER-encoded cert */ fingerprint: string; } export interface TlsCertOptions { stateDir?: string; /** Common name for the cert (default: "pi-remote") */ cn?: string; /** Days the cert is valid (default: 3650 — 10 years) */ days?: number; } function certDir(stateDir?: string): string { return path.join( stateDir ?? path.join(os.homedir(), ".local", "share", "pi-remote"), "tls", ); } /** * Load existing cert or generate a new one. * Returns paths + fingerprint. */ export async function loadOrCreateCert( opts: TlsCertOptions = {}, ): Promise { const { stateDir, cn = "pi-remote", days = 3650 } = opts; const dir = certDir(stateDir); await fs.mkdir(dir, { recursive: true }); const certPath = path.join(dir, "cert.pem"); const keyPath = path.join(dir, "key.pem"); // Reuse existing cert if present try { await fs.access(certPath); await fs.access(keyPath); const fingerprint = await computeFingerprint(certPath); return { certPath, keyPath, fingerprint }; } catch { // generate new } // Generate via openssl await execFileAsync("openssl", [ "req", "-x509", "-newkey", "rsa:2048", "-keyout", keyPath, "-out", certPath, "-days", String(days), "-nodes", "-subj", `/CN=${cn}`, ]); await fs.chmod(keyPath, 0o600); const fingerprint = await computeFingerprint(certPath); return { certPath, keyPath, fingerprint }; } /** * Compute SHA-256 fingerprint of a PEM cert. * Returns hex string (no colons), e.g. "a1b2c3...". */ export async function computeFingerprint(certPath: string): Promise { // Use openssl to get DER bytes, then hash const { stdout } = await execFileAsync("openssl", [ "x509", "-in", certPath, "-outform", "DER", ]); return createHash("sha256").update(stdout).digest("hex"); } /** * Check if openssl is available on PATH. */ export async function isOpensslAvailable(): Promise { try { await execFileAsync("openssl", ["version"]); return true; } catch { return false; } }