BLE-only locators can't hold the MQTT/TLS connection themselves — a phone relays their data — so handing the phone a device's permanent client-cert key would export its identity to every phone it pairs with. Instead the device signs a server-issued nonce with its permanent key over BLE; once verified, the backend mints a short-lived session certificate for the phone's actual MQTT connection, keeping the permanent key on-device always. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
266 lines
8.8 KiB
TypeScript
266 lines
8.8 KiB
TypeScript
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<CaStatus> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string> {
|
|
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<IssuedCert> {
|
|
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<boolean> {
|
|
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<string> {
|
|
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<Date> {
|
|
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}`);
|
|
}
|
|
}
|
|
}
|