diff --git a/README.md b/README.md index 9b5238a..a7796a9 100644 --- a/README.md +++ b/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::`) 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/ diff --git a/backend/package-lock.json b/backend/package-lock.json index f55e39b..87a8715 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index b6d14e3..073d8bd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 30ea5a3..12e49f1 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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], diff --git a/backend/src/certificates/pki.service.ts b/backend/src/certificates/pki.service.ts index 6f9903f..e9367f1 100644 --- a/backend/src/certificates/pki.service.ts +++ b/backend/src/certificates/pki.service.ts @@ -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 { + // `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'); } @@ -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 { + 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:]+)/); diff --git a/backend/src/device-mqtt-auth/device-mqtt-auth.controller.ts b/backend/src/device-mqtt-auth/device-mqtt-auth.controller.ts new file mode 100644 index 0000000..46df089 --- /dev/null +++ b/backend/src/device-mqtt-auth/device-mqtt-auth.controller.ts @@ -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); + } +} diff --git a/backend/src/device-mqtt-auth/device-mqtt-auth.module.ts b/backend/src/device-mqtt-auth/device-mqtt-auth.module.ts new file mode 100644 index 0000000..324832f --- /dev/null +++ b/backend/src/device-mqtt-auth/device-mqtt-auth.module.ts @@ -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 {} diff --git a/backend/src/device-mqtt-auth/device-mqtt-auth.service.ts b/backend/src/device-mqtt-auth/device-mqtt-auth.service.ts new file mode 100644 index 0000000..6bd8004 --- /dev/null +++ b/backend/src/device-mqtt-auth/device-mqtt-auth.service.ts @@ -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(); + 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); + } +} diff --git a/backend/src/device-mqtt-auth/dto/device-mqtt-auth.dto.ts b/backend/src/device-mqtt-auth/dto/device-mqtt-auth.dto.ts new file mode 100644 index 0000000..9b019a0 --- /dev/null +++ b/backend/src/device-mqtt-auth/dto/device-mqtt-auth.dto.ts @@ -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; +} diff --git a/docker-compose.yml b/docker-compose.yml index e43cc54..8a68c56 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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"