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. TheProvisioningValidatorRegistrydispatches 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¶
Integrator authenticates to ThingHub via DotID OIDC (AdminCenter or API).
Integrator calls
POST /api/v1/thingswith device metadata.ThingHub returns
{thingId, enrollmentToken}— token is single-use, 24h TTL (configurable viatrustmint.ddi.enrollment-token-ttl).Integrator configures the device with the token (flashed, config file, QR code).
Device calls
POST /ddi/v1/enrollwith the enrollment token.DdiService.enroll()validates the token viaProvisioningValidatorRegistry, creates theThingentity viaThingRepository.save(), issues an X.509 certificate viaThingCertificateService, 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¶
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}. ThesharedSecretis shown only once at creation time.Integrator provisions the shared secret into device firmware or boot config.
Each device at first boot calls
POST /ddi/v1/enrollwith:{ "provisioningMethod": "claim_token", "claimSecret": "<shared secret>", "manufacturer": "acme-robotics", "model": "widget-v1", "serial": "SN-00042" }
Server assigns a UUIDv4
thingId, stores the(tenant_id, manufacturer, model, serial) → thingIdmapping for idempotent re-claims, and issues a per-device X.509 certificate.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)tothingIdwith 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 thessl.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_idandusernameviapeer_cert_as_clientid: cnandpeer_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:
Mark revoked:
thing_certificates.revoked = truein the database.Bump CRL: triggers an on-demand CRL regeneration so the next EMQX cache refresh picks up the revocation.
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— remainspermitAll()(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 |
|---|---|---|
|
No validation (development/testing) |
Pre-existing |
|
Auto-approve based on model rules |
Pre-existing |
|
Pre-shared symmetric key validation |
Pre-existing |
|
Manufacturer certificate validation |
Pre-existing |
|
External webhook validation |
Pre-existing |
|
Claim-group shared secret with cap enforcement |
Phase 6 |
API Reference¶
Tenant-Facing Endpoints (DotID OIDC auth required)¶
Endpoint |
Method |
Description |
|---|---|---|
|
POST |
Register a single device. Returns |
|
DELETE |
Revoke device: marks cert revoked, bumps CRL, kicks MQTT session. |
|
POST |
Create a bulk claim group. Returns |
|
GET |
Get claim group status (consumed count, active/expired/revoked). |
|
DELETE |
Revoke claim group’s shared secret (does not revoke issued certs). |
Device-Facing Endpoints (enrollment token or mTLS)¶
Endpoint |
Method |
Description |
|---|---|---|
|
POST |
Enroll a device. Dispatches by provisioning method. Returns cert + bootstrap fields. |
|
GET |
Device status query (requires authenticated device). |
|
POST |
Request a license token (requires authenticated device). |
|
POST |
Renew an existing license token (requires authenticated device). |
Internal Endpoints¶
Endpoint |
Method |
Description |
|---|---|---|
|
POST |
EMQX HTTP auth hook (allow/deny based on cert revocation status). |
|
GET |
X.509 CRL distribution point (public, fetched by EMQX). |
|
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 hostnamemqtt_broker_port— EMQX broker port (default 8883)ca_bundle_pem— full CA chain for TLS server verificationtwin_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 |
|---|---|---|
|
|
EMQX broker hostname (e.g. |
|
|
EMQX broker port (default 8883 for mTLS) |
|
|
PEM-encoded CA chain for TLS server verification |
|
|
Device MQTT topic prefix ( |
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:
In
trustmint/terraform/emqx.tf, add the HTTP auth backend to the HOCONauthenticationblock: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"} } ]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:
Generate a new CA keypair and store encrypted in TrustMint’s PostgreSQL (
CertificateAuthorityServicehandles encryption viaCA_MASTER_KEY).Update EMQX’s
cacertfileto include both old and new CA certs (overlap period).New devices receive certs signed by the new CA; existing devices continue with old CA certs until they re-enroll.
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¶
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.
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.
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 |
|---|---|
|
Enrollment orchestration (token validation, thing creation, cert issuance) |
|
Device-facing REST endpoints |
|
X.509 certificate issuance with CDP extension |
|
Claim-group shared secret validator |
|
Claim group lifecycle management |
|
Tenant REST API for claim groups |
|
EMQX Management API client (session kick) |
|
Endpoint authentication rules |
|
EMQX Operator CR with HOCON (mTLS, ACL, CRL) |
|
C SDK enrollment result struct (D-16 fields) |
|
Android SDK entry point (enroll, enrollWithClaim, buildMqttConfig) |
|
Android enrollment result with D-16 fields |
|
Android enrollment client (token + claim-based flows) |
|
Android PROT-01 MQTT 5.0 client (mTLS, telemetry, twin, commands) |
|
Integration test rig (emulator-test profile) |