117 lines
2.8 KiB
TypeScript
117 lines
2.8 KiB
TypeScript
/**
|
|
* 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<TlsCert> {
|
|
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<string> {
|
|
// 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<boolean> {
|
|
try {
|
|
await execFileAsync("openssl", ["version"]);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|