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:
ulhub
2026-08-13 20:51:58 +00:00
parent 842cb23e1f
commit 5de7b4d7a2
10 changed files with 264 additions and 3 deletions

View File

@@ -123,6 +123,38 @@ init/broker cert) and each device's "Certificate" action (issue/download/
revoke). See [Known gaps](#known-gaps) for the CA's on-disk storage and
revocation caveats.
### BLE-relayed devices: short-lived session certs
Some locators are BLE-only and have no network stack of their own — a phone
app relays their data, so it's the phone, not the locator, that would open
the MQTT/TLS connection. Handing the phone the locator's *permanent*
client-cert private key (as the mTLS flow above allows any ORG_ADMIN to
download) would export that device's identity to every phone it ever pairs
with, so `backend/src/device-mqtt-auth/` instead uses a challenge-response
handshake that never moves the permanent key off the device:
1. `POST /api/devices/:serial/mqtt-session/challenge` (public, unauthenticated
— same trust model as the device-status check below) returns a one-time
`nonce` and the exact `payload` string
(`ulhub-mqtt-auth-v1:<serial>:<nonce>`) the locator's firmware must sign
with its permanent private key (RSA-SHA256, PKCS#1v1.5) over BLE.
2. `POST /api/devices/:serial/mqtt-session` with `{ nonce, signature }`
(base64) verifies that signature against the device's stored permanent
certificate. On success it mints a **short-lived** client certificate
(`MQTT_SESSION_CERT_HOURS`, default 24h) — same `CN`, so the existing ACL
applies unchanged — and returns it plus the CA cert, for the phone to
connect to the *same* 8883 listener with. On failure: 400 for an
invalid/expired/reused nonce, 403 for a disabled device, 404 for an
unknown serial or a device with no permanent cert yet, 401 for a bad
signature.
Nonces live in memory only (single-use, `MQTT_CHALLENGE_TTL_SECONDS`, default
120s) and session certs are never persisted — Mosquitto validates any
CA-signed cert at connect time regardless of whether the backend remembers
issuing it. Both endpoints are rate-limited (`@nestjs/throttler`, 10
requests/min) since, unlike the read-only device-status check, each one does
real work (an openssl signature verification and/or a fresh cert issuance).
### Realtime
A plain WebSocket gateway at `/api/ws` (not socket.io) authenticates off the
@@ -201,6 +233,7 @@ backend/
realtime/ WebSocket gateway + pub/sub service
api-keys/ API key issuance/revocation
certificates/ MQTT device mTLS CA (openssl-backed)
device-mqtt-auth/ BLE challenge-response -> short-lived MQTT session certs
sim/ simulator's publish-to-broker endpoint (HTTP relay + real MQTTS/mTLS transport)
prisma/ PrismaService/PrismaModule
prisma/

View File

@@ -16,6 +16,7 @@
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-ws": "^10.0.0",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^5.1.2",
"@nestjs/websockets": "^10.0.0",
"@prisma/client": "^6.10.0",
"bcryptjs": "^2.4.3",
@@ -802,6 +803,17 @@
}
}
},
"node_modules/@nestjs/throttler": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-5.2.0.tgz",
"integrity": "sha512-G/G/MV3xf6sy1DwmnJsgeL+d2tQ/xGRNa9ZhZjm9Kyxp+3+ylGzwJtcnhWlN82PMEp3TiDQpTt+9waOIg/bpPg==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@nestjs/websockets": {
"version": "10.4.22",
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.22.tgz",

View File

@@ -21,6 +21,7 @@
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-ws": "^10.0.0",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^5.1.2",
"@nestjs/websockets": "^10.0.0",
"@prisma/client": "^6.10.0",
"bcryptjs": "^2.4.3",

View File

@@ -14,6 +14,7 @@ import { ApiKeysModule } from './api-keys/api-keys.module';
import { SimModule } from './sim/sim.module';
import { DeviceStatusModule } from './device-status/device-status.module';
import { CertificatesModule } from './certificates/certificates.module';
import { DeviceMqttAuthModule } from './device-mqtt-auth/device-mqtt-auth.module';
@Module({
imports: [
@@ -29,6 +30,7 @@ import { CertificatesModule } from './certificates/certificates.module';
SimModule,
DeviceStatusModule,
CertificatesModule,
DeviceMqttAuthModule,
],
controllers: [AppController, StatusController],
providers: [AppService],

View File

@@ -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:]+)/);

View File

@@ -0,0 +1,27 @@
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ThrottlerGuard } from '@nestjs/throttler';
import { RedeemMqttChallengeDto } from './dto/device-mqtt-auth.dto';
import { DeviceMqttAuthService } from './device-mqtt-auth.service';
// Public and unauthenticated by design, same trust model as
// DeviceStatusController: a BLE-only locator has no login of its own, only
// its permanent certificate. Throttled (unlike DeviceStatusController) since
// each request here does real work — an openssl signature verification and,
// on success, a fresh cert issuance — rather than a single indexed read.
@ApiTags('device-mqtt-auth (public)')
@Controller('devices/:serial/mqtt-session')
@UseGuards(ThrottlerGuard)
export class DeviceMqttAuthController {
constructor(private readonly deviceMqttAuth: DeviceMqttAuthService) {}
@Post('challenge')
challenge(@Param('serial') serial: string) {
return this.deviceMqttAuth.issueChallenge(serial);
}
@Post()
redeem(@Param('serial') serial: string, @Body() dto: RedeemMqttChallengeDto) {
return this.deviceMqttAuth.redeemChallenge(serial, dto.nonce, dto.signature);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
import { CertificatesModule } from '../certificates/certificates.module';
import { DeviceMqttAuthController } from './device-mqtt-auth.controller';
import { DeviceMqttAuthService } from './device-mqtt-auth.service';
@Module({
imports: [CertificatesModule, ThrottlerModule.forRoot([{ ttl: 60_000, limit: 10 }])],
controllers: [DeviceMqttAuthController],
providers: [DeviceMqttAuthService],
})
export class DeviceMqttAuthModule {}

View File

@@ -0,0 +1,104 @@
import { randomBytes } from 'crypto';
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
OnModuleDestroy,
UnauthorizedException,
} from '@nestjs/common';
import { PkiService } from '../certificates/pki.service';
import { PrismaService } from '../prisma/prisma.service';
const CHALLENGE_TTL_MS = Number(process.env.MQTT_CHALLENGE_TTL_SECONDS || 120) * 1000;
const SESSION_CERT_DAYS = Number(process.env.MQTT_SESSION_CERT_HOURS || 24) / 24;
const SWEEP_INTERVAL_MS = 60_000;
interface PendingChallenge {
serial: string;
expiresAt: number;
}
// Lets a BLE-only locator prove possession of its permanent private key
// (issued via PkiService.issueDeviceCert, never leaves the device) through a
// phone relay, without ever exporting that permanent key. The phone gets a
// short-lived session cert instead: mint-on-demand, never persisted, since
// Mosquitto validates any CA-signed cert at connect time regardless of
// whether this service remembers issuing it.
@Injectable()
export class DeviceMqttAuthService implements OnModuleDestroy {
private readonly pending = new Map<string, PendingChallenge>();
private readonly sweep = setInterval(() => this.sweepExpired(), SWEEP_INTERVAL_MS);
constructor(
private readonly prisma: PrismaService,
private readonly pki: PkiService,
) {}
async issueChallenge(serial: string) {
const device = await this.prisma.device.findUnique({ where: { serialNumber: serial } });
if (!device) {
throw new NotFoundException(`No device with serial "${serial}"`);
}
const nonce = randomBytes(32).toString('hex');
const expiresAt = Date.now() + CHALLENGE_TTL_MS;
this.pending.set(nonce, { serial, expiresAt });
return { nonce, payload: this.payloadFor(serial, nonce), expiresAt: new Date(expiresAt) };
}
async redeemChallenge(serial: string, nonce: string, signature: string) {
const entry = this.pending.get(nonce);
// Single-use: consumed here regardless of outcome, so a nonce can never
// be replayed even after a failed attempt.
this.pending.delete(nonce);
if (!entry || entry.expiresAt < Date.now() || entry.serial !== serial) {
throw new BadRequestException('Challenge is invalid, expired, or already used — request a new one');
}
const device = await this.prisma.device.findUnique({ where: { serialNumber: serial } });
if (!device) {
throw new NotFoundException(`No device with serial "${serial}"`);
}
if (!device.isActive) {
throw new ForbiddenException(`Device "${serial}" is disabled`);
}
const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId: device.id } });
if (!cert) {
throw new NotFoundException(`Device "${serial}" has no permanent certificate issued yet`);
}
const payload = this.payloadFor(serial, nonce);
const verified = await this.pki.verifySignature(cert.certificatePem, payload, signature);
if (!verified) {
throw new UnauthorizedException('Signature does not match this device\'s certificate');
}
const [issued, caCertPem] = await Promise.all([
this.pki.issueDeviceCert(serial, SESSION_CERT_DAYS),
this.pki.caCertPem(),
]);
return {
certificatePem: issued.certificatePem,
privateKeyPem: issued.privateKeyPem,
caCertPem,
expiresAt: issued.expiresAt,
};
}
private payloadFor(serial: string, nonce: string): string {
return `ulhub-mqtt-auth-v1:${serial}:${nonce}`;
}
private sweepExpired(): void {
const now = Date.now();
for (const [nonce, entry] of this.pending) {
if (entry.expiresAt < now) {
this.pending.delete(nonce);
}
}
}
onModuleDestroy(): void {
clearInterval(this.sweep);
}
}

View File

@@ -0,0 +1,15 @@
import { IsString, Length, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RedeemMqttChallengeDto {
@ApiProperty({ description: 'The nonce returned by POST .../mqtt-session/challenge' })
@IsString()
@Length(64, 64)
@Matches(/^[0-9a-f]{64}$/)
nonce: string;
@ApiProperty({ description: 'Base64-encoded RSA-SHA256 signature over the challenge payload' })
@IsString()
@Length(1, 2048)
signature: string;
}

View File

@@ -44,6 +44,11 @@ services:
# (Settings → MQTT Certs), not this container's docker-network hostname —
# used by the simulator's MQTTS transport to verify the broker's identity.
MQTT_TLS_SERVERNAME: ${MQTT_TLS_SERVERNAME:-localhost}
# BLE challenge-response session certs (backend/src/device-mqtt-auth):
# how long a nonce is redeemable for, and how long the short-lived
# session cert it produces is valid before a phone must re-challenge.
MQTT_CHALLENGE_TTL_SECONDS: ${MQTT_CHALLENGE_TTL_SECONDS:-120}
MQTT_SESSION_CERT_HOURS: ${MQTT_SESSION_CERT_HOURS:-24}
NODE_ENV: development
command: sh -c "npx prisma migrate deploy && npm run start:dev"