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
|
revoke). See [Known gaps](#known-gaps) for the CA's on-disk storage and
|
||||||
revocation caveats.
|
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
|
### Realtime
|
||||||
|
|
||||||
A plain WebSocket gateway at `/api/ws` (not socket.io) authenticates off the
|
A plain WebSocket gateway at `/api/ws` (not socket.io) authenticates off the
|
||||||
@@ -201,6 +233,7 @@ backend/
|
|||||||
realtime/ WebSocket gateway + pub/sub service
|
realtime/ WebSocket gateway + pub/sub service
|
||||||
api-keys/ API key issuance/revocation
|
api-keys/ API key issuance/revocation
|
||||||
certificates/ MQTT device mTLS CA (openssl-backed)
|
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)
|
sim/ simulator's publish-to-broker endpoint (HTTP relay + real MQTTS/mTLS transport)
|
||||||
prisma/ PrismaService/PrismaModule
|
prisma/ PrismaService/PrismaModule
|
||||||
prisma/
|
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-express": "^10.0.0",
|
||||||
"@nestjs/platform-ws": "^10.0.0",
|
"@nestjs/platform-ws": "^10.0.0",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
|
"@nestjs/throttler": "^5.1.2",
|
||||||
"@nestjs/websockets": "^10.0.0",
|
"@nestjs/websockets": "^10.0.0",
|
||||||
"@prisma/client": "^6.10.0",
|
"@prisma/client": "^6.10.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"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": {
|
"node_modules/@nestjs/websockets": {
|
||||||
"version": "10.4.22",
|
"version": "10.4.22",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.22.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.22.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@nestjs/platform-ws": "^10.0.0",
|
"@nestjs/platform-ws": "^10.0.0",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
|
"@nestjs/throttler": "^5.1.2",
|
||||||
"@nestjs/websockets": "^10.0.0",
|
"@nestjs/websockets": "^10.0.0",
|
||||||
"@prisma/client": "^6.10.0",
|
"@prisma/client": "^6.10.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ApiKeysModule } from './api-keys/api-keys.module';
|
|||||||
import { SimModule } from './sim/sim.module';
|
import { SimModule } from './sim/sim.module';
|
||||||
import { DeviceStatusModule } from './device-status/device-status.module';
|
import { DeviceStatusModule } from './device-status/device-status.module';
|
||||||
import { CertificatesModule } from './certificates/certificates.module';
|
import { CertificatesModule } from './certificates/certificates.module';
|
||||||
|
import { DeviceMqttAuthModule } from './device-mqtt-auth/device-mqtt-auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -29,6 +30,7 @@ import { CertificatesModule } from './certificates/certificates.module';
|
|||||||
SimModule,
|
SimModule,
|
||||||
DeviceStatusModule,
|
DeviceStatusModule,
|
||||||
CertificatesModule,
|
CertificatesModule,
|
||||||
|
DeviceMqttAuthModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, StatusController],
|
controllers: [AppController, StatusController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import { existsSync } from 'fs';
|
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 { tmpdir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
@@ -143,7 +143,10 @@ export class PkiService {
|
|||||||
return readFile(this.path(CA_CERT), 'utf8');
|
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()) {
|
if (!this.caExists()) {
|
||||||
throw new NotFoundException('Initialize the CA before issuing device certificates');
|
throw new NotFoundException('Initialize the CA before issuing device certificates');
|
||||||
}
|
}
|
||||||
@@ -174,7 +177,7 @@ export class PkiService {
|
|||||||
'-out',
|
'-out',
|
||||||
certPath,
|
certPath,
|
||||||
'-days',
|
'-days',
|
||||||
String(DEVICE_CERT_DAYS),
|
String(Math.max(1, Math.round(days))),
|
||||||
'-sha256',
|
'-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> {
|
private async fingerprintOf(certPath: string): Promise<string> {
|
||||||
const { stdout } = await this.run(['x509', '-in', certPath, '-noout', '-fingerprint', '-sha256']);
|
const { stdout } = await this.run(['x509', '-in', certPath, '-noout', '-fingerprint', '-sha256']);
|
||||||
const match = stdout.match(/Fingerprint=([0-9A-Fa-f:]+)/);
|
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 —
|
# (Settings → MQTT Certs), not this container's docker-network hostname —
|
||||||
# used by the simulator's MQTTS transport to verify the broker's identity.
|
# used by the simulator's MQTTS transport to verify the broker's identity.
|
||||||
MQTT_TLS_SERVERNAME: ${MQTT_TLS_SERVERNAME:-localhost}
|
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
|
NODE_ENV: development
|
||||||
command: sh -c "npx prisma migrate deploy && npm run start:dev"
|
command: sh -c "npx prisma migrate deploy && npm run start:dev"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user