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:
33
README.md
33
README.md
@@ -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/
|
||||
|
||||
12
backend/package-lock.json
generated
12
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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:]+)/);
|
||||
|
||||
27
backend/src/device-mqtt-auth/device-mqtt-auth.controller.ts
Normal file
27
backend/src/device-mqtt-auth/device-mqtt-auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
backend/src/device-mqtt-auth/device-mqtt-auth.module.ts
Normal file
12
backend/src/device-mqtt-auth/device-mqtt-auth.module.ts
Normal 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 {}
|
||||
104
backend/src/device-mqtt-auth/device-mqtt-auth.service.ts
Normal file
104
backend/src/device-mqtt-auth/device-mqtt-auth.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
backend/src/device-mqtt-auth/dto/device-mqtt-auth.dto.ts
Normal file
15
backend/src/device-mqtt-auth/dto/device-mqtt-auth.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user