Add BLE challenge-response for short-lived MQTT session certs
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>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm } from 'fs/promises';
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
@@ -143,7 +143,10 @@ export class PkiService {
|
||||
return readFile(this.path(CA_CERT), 'utf8');
|
||||
}
|
||||
|
||||
async issueDeviceCert(serialNumber: string): Promise<IssuedCert> {
|
||||
// `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');
|
||||
}
|
||||
@@ -174,7 +177,7 @@ export class PkiService {
|
||||
'-out',
|
||||
certPath,
|
||||
'-days',
|
||||
String(DEVICE_CERT_DAYS),
|
||||
String(Math.max(1, Math.round(days))),
|
||||
'-sha256',
|
||||
]);
|
||||
|
||||
@@ -190,6 +193,53 @@ export class PkiService {
|
||||
}
|
||||
}
|
||||
|
||||
// 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:]+)/);
|
||||
|
||||
Reference in New Issue
Block a user