Device Identity and Registration

TrustMint is the device Certificate Authority for the FlexGalaxy.AI platform. It issues per-device X.509 certificates that EMQX uses to authenticate MQTT connections via mutual TLS (mTLS). Human operators authenticate through DotID (Keycloak OIDC); devices authenticate exclusively through TrustMint-issued credentials.

This document covers the enrollment flows, EMQX integration, revocation mechanism, and operational procedures shipped in Phase 6.

Overview

The device identity system connects three planes:

┌─────────────────────────────────────────────────────────────┐
│                     Tenant Plane                            │
│  Integrator (human) ──DotID OIDC──▶ ThingHub REST API      │
│  POST /api/v1/things             (register device)          │
│  POST /api/v1/things/claim-groups (bulk enrollment)         │
│  DELETE /api/v1/things/{id}      (revoke + kick)            │
└─────────────────────┬───────────────────────────────────────┘
                      │ enrollment token / claim secret
┌─────────────────────▼───────────────────────────────────────┐
│                     Device Plane                            │
│  Device ──enrollment token──▶ POST /ddi/v1/enroll           │
│           ◀── X.509 cert + key + MQTT bootstrap fields ──  │
└─────────────────────┬───────────────────────────────────────┘
                      │ mTLS with TrustMint-signed cert
┌─────────────────────▼───────────────────────────────────────┐
│                     Broker Plane                            │
│  EMQX 6.0 ── peer_certmode: verify_peer ── TrustMint CA    │
│  ACL: cert CN = ${clientid} → devices/${clientid}/#         │
│  CRL check: reads CDP extension from device cert            │
└─────────────────────────────────────────────────────────────┘

Key design rules:

  • No HTTP hop on MQTT connect. EMQX validates the TLS certificate chain statically against the TrustMint CA. No database lookup, no webhook call.

  • Certificate CN = thingId (opaque UUIDv4). No tenant, model, or serial information leaks onto the MQTT wire.

  • One enrollment URL for all provisioning methods: POST /ddi/v1/enroll. The ProvisioningValidatorRegistry dispatches by method (claim_token, none, symmetric_key, certificate, webhook, auto).

Single-Device Enrollment (SC1)

The one-time token flow for registering a single device.

Flow

  1. Integrator authenticates to ThingHub via DotID OIDC (AdminCenter or API).

  2. Integrator calls POST /api/v1/things with device metadata.

  3. ThingHub returns {thingId, enrollmentToken} — token is single-use, 24h TTL (configurable via trustmint.ddi.enrollment-token-ttl).

  4. Integrator configures the device with the token (flashed, config file, QR code).

  5. Device calls POST /ddi/v1/enroll with the enrollment token.

  6. DdiService.enroll() validates the token via ProvisioningValidatorRegistry, creates the Thing entity via ThingRepository.save(), issues an X.509 certificate via ThingCertificateService, and returns the enrollment response.

Response fields (D-16 bootstrap):

{
  "certificate": "<PEM-encoded device cert>",
  "privateKey": "<PEM-encoded private key>",
  "caCertificate": "<PEM-encoded TrustMint CA cert>",
  "mqttBrokerUrl": "dev-mqtt.flexgalaxy.com",
  "mqttBrokerPort": 8883,
  "caBundlePem": "<full CA chain>",
  "twinTopicPrefix": "devices/{thingId}"
}

The device receives everything it needs to connect to EMQX — no separate configuration file required.

Bulk Enrollment — Claim-Based Flow (SC2)

The claim-based shared bootstrap flow for registering 100+ devices without individual token distribution.

Claim Group Lifecycle

  1. Integrator creates a claim group via POST /api/v1/things/claim-groups:

    {
      "name": "factory-batch-2026Q2",
      "maxDevices": 500,
      "ttlDays": 30
    }
    

    Returns {id, sharedSecret, maxDevices, ttlDays, createdAt}. The sharedSecret is shown only once at creation time.

  2. Integrator provisions the shared secret into device firmware or boot config.

  3. Each device at first boot calls POST /ddi/v1/enroll with:

    {
      "provisioningMethod": "claim_token",
      "claimSecret": "<shared secret>",
      "manufacturer": "acme-robotics",
      "model": "widget-v1",
      "serial": "SN-00042"
    }
    
  4. Server assigns a UUIDv4 thingId, stores the (tenant_id, manufacturer, model, serial) thingId mapping for idempotent re-claims, and issues a per-device X.509 certificate.

  5. Claim group constraints are enforced atomically:

    • max_devices: when the consumed count reaches the cap, subsequent claims return HTTP 403.

    • TTL: after expiry, subsequent claims return HTTP 403.

    • Revoke: DELETE /api/v1/things/claim-groups/{id} invalidates the shared secret for future claims but does NOT revoke certificates already issued.

Idempotent Re-Claim

If the same device (same tenant + manufacturer + model + serial) re-claims, DdiService short-circuits: returns the existing thingId and certificate without re-issuing. This handles factory reset and retry scenarios.

Database Schema

Two new tables (Flyway V24, V25):

  • claim_groups — stores group metadata, hashed shared secret, max_devices, TTL, consumed_count, revoked flag.

  • thing_serial_identity_map — maps (tenant_id, manufacturer, model, serial) to thingId with a unique constraint for idempotent lookups.

EMQX mTLS Integration (SC3)

Certificate Trust Chain

EMQX authenticates device MQTT connections using static CA trust:

  • TrustMint CA root cert is injected into the EMQX Operator CR’s HOCON config (trustmint/terraform/emqx.tf) as the ssl.cacertfile.

  • Listener mode: peer_certmode: verify_peer — only TrustMint-signed certs are accepted. Self-signed certificates are rejected at TLS handshake.

  • The certificate Subject CN is extracted as both MQTT client_id and username via peer_cert_as_clientid: cn and peer_cert_as_username: cn.

Important

EMQX is configured via the EMQX Operator apps.emqx.io/v2beta1 custom resource with embedded HOCON, not via standard Helm values. All listener/ACL/CRL changes are edits to the HOCON blob inside trustmint/terraform/emqx.tf.

Per-Device Topic Isolation (ACL)

A single file-based authorization rule isolates all devices:

{allow, {user, "${clientid}"}, all, ["devices/${clientid}/#"]}.

Any device can only publish/subscribe to topics under its own devices/{thingId}/ prefix. No per-device ACL rows are needed. The no_match policy is set to deny — any topic not explicitly allowed is blocked.

CRL Distribution Points

Every TrustMint-issued device certificate includes an X.509 cRLDistributionPoints extension pointing to:

http://trustmint-backend.dotid.svc.cluster.local:8080/api/v1/certificates/crl

EMQX 6.0’s enable_crl_check reads this URL from the certificate itself and caches the CRL with a configurable refresh interval (default: 60 seconds in the HOCON config).

Note

The CRL infrastructure (CRLService, certificate_revocation_list table, X509v2CRLBuilder, GET /api/v1/certificates/crl endpoint, ShedLock HA gating, scheduled regeneration) was 100% pre-existing. Phase 6 added the CDP extension to newly-issued certificates and shortened the regeneration interval.

HTTP Auth Hook (Belt-and-Suspenders)

An HTTP authentication endpoint is also shipped at POST /ddi/v1/authn/emqx, compatible with EMQX’s HTTP auth backend contract:

{"clientid": "...", "username": "...", "cert_cn": "..."}

Returns {"result": "allow"} or {"result": "deny"} based on thing_certificates.revoked status.

This endpoint is unit-tested and integration-tested but not wired as primary EMQX authn in Phase 6 — CRL remains primary. Operators can flip EMQX to connect-time DB checks without a TrustMint code change if CRL latency becomes a concern. See the Operations section below.

Device Revocation and Live Kick (SC4)

When an integrator revokes a device via DELETE /api/v1/things/{thingId}, TrustMint executes a three-step revoke-and-kick:

  1. Mark revoked: thing_certificates.revoked = true in the database.

  2. Bump CRL: triggers an on-demand CRL regeneration so the next EMQX cache refresh picks up the revocation.

  3. Kick active session: synchronously calls EMQX Management API DELETE /api/v5/clients/{thingId} (returns 204) to immediately disconnect the device’s active MQTT session.

The device experiences immediate disconnection. Subsequent reconnect attempts are denied within the CRL cache refresh interval (default 60 seconds).

Integrator                TrustMint               EMQX
    │                         │                      │
    │  DELETE /things/{id}     │                      │
    │ ─────────────────────▶  │                      │
    │                         │  mark cert revoked    │
    │                         │  regenerate CRL       │
    │                         │  DELETE /clients/{id} │
    │                         │ ────────────────────▶ │
    │                         │          204          │
    │                         │ ◀──────────────────── │
    │        200 OK           │                      │
    │ ◀─────────────────────  │                      │
    │                         │                      │
    │                    Device reconnect attempt     │
    │                         │      TLS handshake    │
    │                         │ ◀──────────── ─────── │
    │                         │  CRL check → DENY     │

The EmqxMgmtClient handles EMQX Management API authentication (HTTP Basic with API key configured via trustmint.emqx.api-key and trustmint.emqx.api-secret in application.yml). It returns true on HTTP 404 (device already disconnected) so the revoke flow is idempotent.

Important

Revoking a claim group (DELETE /api/v1/things/claim-groups/{id}) invalidates the shared secret for future claims but does not revoke certificates already issued to devices that enrolled under it. To revoke an individual device, use DELETE /api/v1/things/{thingId}.

Security Hardening

DDI Endpoint Authentication

Prior to Phase 6, all /ddi/v1/** endpoints were permitAll() in SecurityConfig. Phase 6 tightened this:

  • POST /ddi/v1/enroll — remains permitAll() (device has no credential yet).

  • GET /api/v1/certificates/crl — remains public (EMQX fetches it).

  • GET /api/v1/certificates/ca — remains public (devices need the CA bundle).

  • POST /ddi/v1/authn/emqx — restricted to internal network (EMQX callback).

  • All other /ddi/v1/{thingId}/** endpoints — require authentication (enrollment token or mTLS).

Certificate Properties

TrustMint-issued device certificates comply with the PROT-01 specification:

  • Subject CN = {thingId} (opaque UUID, no tenant/serial leak)

  • Key usage: Digital Signature, Key Encipherment

  • Extended key usage: TLS Client Authentication

  • CRL Distribution Points: embedded URL to TrustMint CRL endpoint

  • Signed by: TrustMint device CA (RSA-2048, stored AES-256-GCM encrypted in PostgreSQL)

Provisioning Validators

The ProvisioningValidatorRegistry supports six methods, dispatched by ThingModel.properties.provisioning_method:

Method

Description

Added In

none

No validation (development/testing)

Pre-existing

auto

Auto-approve based on model rules

Pre-existing

symmetric_key

Pre-shared symmetric key validation

Pre-existing

certificate

Manufacturer certificate validation

Pre-existing

webhook

External webhook validation

Pre-existing

claim_token

Claim-group shared secret with cap enforcement

Phase 6

API Reference

Tenant-Facing Endpoints (DotID OIDC auth required)

Endpoint

Method

Description

/api/v1/things

POST

Register a single device. Returns {thingId, enrollmentToken}.

/api/v1/things/{thingId}

DELETE

Revoke device: marks cert revoked, bumps CRL, kicks MQTT session.

/api/v1/things/claim-groups

POST

Create a bulk claim group. Returns {id, sharedSecret, maxDevices}.

/api/v1/things/claim-groups/{id}

GET

Get claim group status (consumed count, active/expired/revoked).

/api/v1/things/claim-groups/{id}

DELETE

Revoke claim group’s shared secret (does not revoke issued certs).

Device-Facing Endpoints (enrollment token or mTLS)

Endpoint

Method

Description

/ddi/v1/enroll

POST

Enroll a device. Dispatches by provisioning method. Returns cert + bootstrap fields.

/ddi/v1/{thingId}/device/status

GET

Device status query (requires authenticated device).

/ddi/v1/{thingId}/license/request

POST

Request a license token (requires authenticated device).

/ddi/v1/{thingId}/license/renew

POST

Renew an existing license token (requires authenticated device).

Internal Endpoints

Endpoint

Method

Description

/ddi/v1/authn/emqx

POST

EMQX HTTP auth hook (allow/deny based on cert revocation status).

/api/v1/certificates/crl

GET

X.509 CRL distribution point (public, fetched by EMQX).

/api/v1/certificates/ca

GET

TrustMint CA certificate (public, needed by devices for TLS verification).

Linux C SDK Integration

The trustmint/terminals/sdks/linux/ C SDK’s tm_enroll() function calls POST /ddi/v1/enroll and now parses the D-16 bootstrap fields:

tm_enrollment_result_t result;
int rc = tm_enroll(&device_info, &config, &result);
if (rc == 0) {
    printf("thingId: %s\n", result.thing_id);
    printf("broker:  %s:%d\n", result.mqtt_broker_url,
                                result.mqtt_broker_port);
    // result.cert_pem, result.key_pem, result.ca_bundle_pem
    // are ready for the MQTT TLS connection
}

The tm_enrollment_result_t struct was extended with four new fields at the end (ABI-safe, backward-compatible):

  • mqtt_broker_url — EMQX broker hostname

  • mqtt_broker_port — EMQX broker port (default 8883)

  • ca_bundle_pem — full CA chain for TLS server verification

  • twin_topic_prefix — device’s MQTT topic prefix (devices/{thingId})

Android SDK Integration

The trustmint/terminals/sdks/android/ Kotlin SDK (ai.flexgalaxy.trustmint.sdk) provides a high-level API for Android devices (minSdk = 29). Certificates are stored in the Android Keystore with hardware-backed security when available.

Token-Based Enrollment (SC1)

val sdk = TrustMintSDK.initialize(context, SdkConfig(
    apiBaseUrl = "https://dev-ddi.flexgalaxy.com"
))

val result = sdk.enroll(
    enrollmentToken = "one-time-token-from-integrator",
    deviceInfo = DeviceInfo(
        manufacturerId = "acme-robotics",
        model = "widget-v1",
        serialNumber = "SN-00042",
    ),
)

// result.thingId, result.certificatePem, result.privateKeyPem
// D-16 bootstrap fields:
// result.mqttBrokerUrl, result.mqttBrokerPort,
// result.caBundlePem, result.twinTopicPrefix

Claim-Based Bulk Enrollment (SC2)

val result = sdk.enrollWithClaim(
    claimSecret = "shared-secret-from-claim-group",
    deviceInfo = DeviceInfo(
        manufacturerId = "acme-robotics",
        model = "widget-v1",
        serialNumber = "SN-00042",
    ),
)
// Same result shape — thingId assigned by server from serial mapping

Enrollment to MQTT in One Step

The buildMqttConfig() convenience method wires the enrollment result’s D-16 fields directly into an MqttConfig ready for PROT-01 communication:

val result = sdk.enroll(token, deviceInfo)

// Build MQTT config from D-16 bootstrap fields
val mqttConfig = sdk.buildMqttConfig(result)
val mqtt = sdk.configureMqtt(mqttConfig)

// Connect and start publishing
mqtt.connect()
mqtt.publishTelemetry(mapOf("temperature" to 22.5))
mqtt.publishHeartbeat(uptimeSeconds = 3600, firmwareVersion = "1.2.0")

The FgaiMqttClient implements the full PROT-01 device protocol: lifecycle events, telemetry, heartbeat, twin state, and command/response over MQTT 5.0 with mTLS.

D-16 Bootstrap Fields

EnrollmentResult includes four bootstrap fields returned by the server:

Field

Type

Description

mqttBrokerUrl

String?

EMQX broker hostname (e.g. dev-mqtt.flexgalaxy.com)

mqttBrokerPort

Int?

EMQX broker port (default 8883 for mTLS)

caBundlePem

String?

PEM-encoded CA chain for TLS server verification

twinTopicPrefix

String?

Device MQTT topic prefix (devices/{thingId})

All fields are nullable for backward compatibility with servers that have not been updated to Phase 6. When present, they eliminate the need for a separate configuration file or getTelemetryConfig() server call.

Certificate Storage

  • X.509 client certificate — stored in Android Keystore (hardware-backed when available)

  • CA bundle — stored in encrypted SharedPreferences

  • JWT license token — stored in encrypted SharedPreferences with automatic background renewal via WorkManager (12-hour interval, configurable)

  • thingId — persisted across app restarts

Operations

EMQX HOCON Configuration Swap

The TrustMint CA root cert must be swapped into EMQX when deploying Phase 6. See trustmint/docs/runbooks/phase-6-emqx-hocon-swap.md for the step-by-step procedure.

Flipping EMQX to HTTP Auth Hook

If CRL cache latency is unacceptable in production, operators can switch EMQX to connect-time database checks:

  1. In trustmint/terraform/emqx.tf, add the HTTP auth backend to the HOCON authentication block:

    authentication = [
      {
        mechanism = password_based
        backend   = http
        method    = post
        url       = "http://trustmint-backend.dotid.svc.cluster.local:8080/ddi/v1/authn/emqx"
        body      = {clientid = "${clientid}", username = "${username}", cert_cn = "${cert_cn}"}
        headers   = {"Content-Type" = "application/json"}
      }
    ]
    
  2. Apply with tf_docker apply. EMQX will query TrustMint on every connection attempt. The CRL mechanism continues as a secondary defense.

CA Certificate Rotation

Manual CA rotation procedure:

  1. Generate a new CA keypair and store encrypted in TrustMint’s PostgreSQL (CertificateAuthorityService handles encryption via CA_MASTER_KEY).

  2. Update EMQX’s cacertfile to include both old and new CA certs (overlap period).

  3. New devices receive certs signed by the new CA; existing devices continue with old CA certs until they re-enroll.

  4. After all old certs expire, remove the old CA from EMQX’s trust chain.

Warning

Rotating the CA does NOT invalidate existing device certificates. Plan an overlap period based on the maximum certificate lifetime in your fleet.

CRL Troubleshooting

Check if a device’s cert is revoked:

# Fetch the CRL
curl -s http://trustmint-backend:8080/api/v1/certificates/crl -o crl.der

# Inspect
openssl crl -in crl.der -inform DER -text -noout

# Check the EMQX auth hook directly
curl -X POST http://trustmint-backend:8080/ddi/v1/authn/emqx \
  -H "Content-Type: application/json" \
  -d '{"clientid":"<thingId>","username":"<thingId>","cert_cn":"<thingId>"}'

Verify EMQX is loading the CRL:

kubectl exec -it emqx-0 -u emqx -- emqx ctl listeners

Integration Test Rig

The Phase 6 integration tests run against a Docker Compose rig at trustmint/terminals/tests/docker-compose.yml (emulator-test profile):

# Build backend JAR
cd trustmint/services/trustmint && mvn -DskipTests -q package

# Start rig
cd trustmint/terminals/tests
docker compose --profile emulator-test build trustmint-backend
docker compose --profile emulator-test up -d

# Run SC1-SC4
docker compose --profile emulator-test run --rm emulator-test \
    python -m pytest tests/test_phase6_sc1.py tests/test_phase6_sc2.py \
    tests/test_phase6_sc3.py tests/test_phase6_sc4.py -v --timeout=300

# Tear down
docker compose --profile emulator-test down -v

Test suite (8 tests):

ID

Test

Status

SC1

Single device enrollment via claim flow — verifies D-16 fields

PASS

SC2a

Bulk 100 devices without collisions

PASS

SC2b

Idempotent re-claim returns same thingId

PASS

SC2c

max_devices cap enforcement returns 403

PASS

SC3a

Valid TrustMint cert connects to EMQX

DEFERRED

SC3b

Revoked cert rejected by EMQX

PASS

SC3c

Self-signed cert rejected by EMQX

PASS

SC4

Revoke kicks live session, blocks reconnect

DEFERRED

Note

SC3a and SC4 are deferred due to a test-rig CA trust bridge timing issue: EMQX’s listener restart via the HTTP API does not reliably reload a dynamically appended CA bundle. The production path (static CA in HOCON) is unaffected.

Known Limitations

  1. UUIDv4 for thingId — the plan called for UUIDv7 (time-sortable) but the library was not on the classpath. UUIDv4 works correctly; UUIDv7 is a future optimization.

  2. SC3/SC4 test gap — two integration tests (valid-cert connect and revoke-kick) cannot pass in the automated rig due to EMQX listener reload timing when dynamically appending a second CA. Production EMQX loads the TrustMint CA statically at startup, so this is a test-rig issue only.

  3. Pre-existing DdiServiceTest failures — 8 tests in RequestLicenseTests and GetDeviceStatusTests fail due to issues predating Phase 6. Not caused or worsened by Phase 6 work.

Key Source Files

File

Purpose

services/trustmint/src/main/java/.../ddi/DdiService.java

Enrollment orchestration (token validation, thing creation, cert issuance)

services/trustmint/src/main/java/.../ddi/DdiController.java

Device-facing REST endpoints

services/trustmint/src/main/java/.../pki/ThingCertificateService.java

X.509 certificate issuance with CDP extension

services/trustmint/src/main/java/.../ddi/provisioning/ClaimTokenValidator.java

Claim-group shared secret validator

services/trustmint/src/main/java/.../ddi/ClaimGroupService.java

Claim group lifecycle management

services/trustmint/src/main/java/.../controller/ClaimGroupController.java

Tenant REST API for claim groups

services/trustmint/src/main/java/.../integration/EmqxMgmtClient.java

EMQX Management API client (session kick)

services/trustmint/src/main/java/.../security/SecurityConfig.java

Endpoint authentication rules

terraform/emqx.tf

EMQX Operator CR with HOCON (mTLS, ACL, CRL)

terminals/sdks/linux/include/trustmint/types.h

C SDK enrollment result struct (D-16 fields)

terminals/sdks/android/trustmint-sdk/src/main/kotlin/.../TrustMintSDK.kt

Android SDK entry point (enroll, enrollWithClaim, buildMqttConfig)

terminals/sdks/android/trustmint-sdk/src/main/kotlin/.../provisioning/EnrollmentResult.kt

Android enrollment result with D-16 fields

terminals/sdks/android/trustmint-sdk/src/main/kotlin/.../provisioning/EnrollmentClient.kt

Android enrollment client (token + claim-based flows)

terminals/sdks/android/trustmint-sdk/src/main/kotlin/.../mqtt/FgaiMqttClient.kt

Android PROT-01 MQTT 5.0 client (mTLS, telemetry, twin, commands)

terminals/tests/docker-compose.yml

Integration test rig (emulator-test profile)