import { execFile } from 'child_process'; import { existsSync } from 'fs'; import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; import { promisify } from 'util'; import { BadRequestException, ConflictException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; const execFileAsync = promisify(execFile); const CA_KEY = 'ca.key'; const CA_CERT = 'ca.crt'; const SERVER_KEY = 'server.key'; const SERVER_CERT = 'server.crt'; const CA_DAYS = 3650; const DEVICE_CERT_DAYS = 3650; // CN charsets are validated here (not just at the DTO boundary) because they // are interpolated into openssl's "-subj" string, where a stray "/" or "=" // could inject extra subject fields (e.g. "/CN=x/OU=admin") even though // execFile's argv-array form already rules out shell injection. const HOSTNAME_PATTERN = /^[a-zA-Z0-9.-]{1,253}$/; const SERIAL_PATTERN = /^[A-Z0-9-]{1,64}$/; export interface CaStatus { initialized: boolean; fingerprint?: string; expiresAt?: Date; } export interface IssuedCert { certificatePem: string; privateKeyPem: string; fingerprint: string; expiresAt: Date; } // Acts as the root CA for MQTT device client certificates: shells out to the // openssl CLI (added to backend/Dockerfile via apk) rather than a JS crypto // library. The CA/server key pair live only on disk (bind-mounted into the // mosquitto container at /mosquitto/certs) and are never written to the DB. @Injectable() export class PkiService { private readonly certsDir = process.env.MQTT_CERTS_DIR || '/mosquitto-certs'; private path(name: string): string { return join(this.certsDir, name); } private caExists(): boolean { return existsSync(this.path(CA_KEY)) && existsSync(this.path(CA_CERT)); } async caStatus(): Promise { if (!this.caExists()) { return { initialized: false }; } const [fingerprint, expiresAt] = await Promise.all([ this.fingerprintOf(this.path(CA_CERT)), this.expiryOf(this.path(CA_CERT)), ]); return { initialized: true, fingerprint, expiresAt }; } async initCa(): Promise { if (this.caExists()) { throw new ConflictException('CA already initialized'); } await mkdir(this.certsDir, { recursive: true }); await this.run([ 'req', '-x509', '-newkey', 'rsa:4096', '-sha256', '-days', String(CA_DAYS), '-nodes', '-keyout', this.path(CA_KEY), '-out', this.path(CA_CERT), '-subj', '/CN=UlHub Device CA', ]); } async provisionServerCert(hostname: string): Promise { if (!this.caExists()) { throw new NotFoundException('Initialize the CA before provisioning a broker certificate'); } if (!HOSTNAME_PATTERN.test(hostname)) { throw new BadRequestException('Invalid hostname'); } const dir = await mkdtemp(join(tmpdir(), 'ulhub-mqtt-server-')); try { const csrPath = join(dir, 'server.csr'); await this.run([ 'req', '-new', '-newkey', 'rsa:2048', '-nodes', '-keyout', this.path(SERVER_KEY), '-out', csrPath, '-subj', `/CN=${hostname}`, ]); await this.run([ 'x509', '-req', '-in', csrPath, '-CA', this.path(CA_CERT), '-CAkey', this.path(CA_KEY), '-CAcreateserial', '-out', this.path(SERVER_CERT), '-days', String(CA_DAYS), '-sha256', ]); // Mosquitto runs as its own non-root "mosquitto" user in-container; // openssl writes -keyout as 0600 (owner-only), which that user can't // read. The CA key itself stays 0600 (only this service ever reads // it) — only the broker's own key needs to be world-readable, mirroring // the reference implementation's documented reasoning exactly. await chmod(this.path(SERVER_KEY), 0o644); } finally { await rm(dir, { recursive: true, force: true }); } } async caCertPem(): Promise { if (!this.caExists()) { throw new NotFoundException('CA not initialized'); } return readFile(this.path(CA_CERT), 'utf8'); } // `days` defaults to the permanent 10-year validity used by the // ORG_ADMIN-issued device cert; callers minting short-lived session certs // (see DeviceMqttAuthService) pass a much shorter override. async issueDeviceCert(serialNumber: string, days: number = DEVICE_CERT_DAYS): Promise { if (!this.caExists()) { throw new NotFoundException('Initialize the CA before issuing device certificates'); } const cn = serialNumber.toUpperCase(); if (!SERIAL_PATTERN.test(cn)) { throw new BadRequestException( 'Device serial number must be 1-64 chars of A-Z, 0-9, or hyphen to be used as a certificate CN', ); } const dir = await mkdtemp(join(tmpdir(), 'ulhub-device-cert-')); try { const keyPath = join(dir, 'device.key'); const csrPath = join(dir, 'device.csr'); const certPath = join(dir, 'device.crt'); await this.run(['req', '-new', '-newkey', 'rsa:2048', '-nodes', '-keyout', keyPath, '-out', csrPath, '-subj', `/CN=${cn}`]); await this.run([ 'x509', '-req', '-in', csrPath, '-CA', this.path(CA_CERT), '-CAkey', this.path(CA_KEY), '-CAcreateserial', '-out', certPath, '-days', String(Math.max(1, Math.round(days))), '-sha256', ]); const [certificatePem, privateKeyPem, fingerprint, expiresAt] = await Promise.all([ readFile(certPath, 'utf8'), readFile(keyPath, 'utf8'), this.fingerprintOf(certPath), this.expiryOf(certPath), ]); return { certificatePem, privateKeyPem, fingerprint, expiresAt }; } finally { await rm(dir, { recursive: true, force: true }); } } // Verifies that `signatureBase64` is a valid RSA-SHA256 signature over // `payload`, produced by the private key matching `certificatePem`'s public // key — i.e. proof of possession without ever seeing the private key // itself. A non-zero openssl exit here means "signature didn't verify", // which is an expected outcome, not a server error — so this deliberately // doesn't go through the shared `run()` helper, which treats any non-zero // exit as a 500. async verifySignature(certificatePem: string, payload: string, signatureBase64: string): Promise { const dir = await mkdtemp(join(tmpdir(), 'ulhub-verify-')); try { const certPath = join(dir, 'cert.pem'); const pubkeyPath = join(dir, 'pubkey.pem'); const payloadPath = join(dir, 'payload.txt'); const sigPath = join(dir, 'signature.bin'); await writeFile(certPath, certificatePem, 'utf8'); await writeFile(payloadPath, payload, 'utf8'); let signature: Buffer; try { signature = Buffer.from(signatureBase64, 'base64'); } catch { return false; } await writeFile(sigPath, signature); try { await execFileAsync('openssl', ['x509', '-in', certPath, '-pubkey', '-noout', '-out', pubkeyPath]); } catch (err: any) { throw new InternalServerErrorException(`openssl x509 failed: ${err.stderr || err.message}`); } try { await execFileAsync('openssl', ['dgst', '-sha256', '-verify', pubkeyPath, '-signature', sigPath, payloadPath]); return true; } catch (err: any) { // Exit code 1 with "Verification Failure" / "Verification failure" is // the expected shape of a bad signature. Anything else (malformed // signature bytes openssl can't even parse, etc.) still resolves to // "not verified" from the caller's perspective — a forged or garbled // signature is not verified either way. return false; } } finally { await rm(dir, { recursive: true, force: true }); } } private async fingerprintOf(certPath: string): Promise { const { stdout } = await this.run(['x509', '-in', certPath, '-noout', '-fingerprint', '-sha256']); const match = stdout.match(/Fingerprint=([0-9A-Fa-f:]+)/); return match ? match[1] : stdout.trim(); } private async expiryOf(certPath: string): Promise { const { stdout } = await this.run(['x509', '-in', certPath, '-noout', '-enddate']); const match = stdout.match(/notAfter=(.+)/); if (!match) { throw new InternalServerErrorException('Could not read certificate expiry'); } return new Date(match[1].trim()); } private async run(args: string[]): Promise<{ stdout: string; stderr: string }> { try { return await execFileAsync('openssl', args); } catch (err: any) { throw new InternalServerErrorException(`openssl ${args[0]} failed: ${err.stderr || err.message}`); } } }