1. Today's topic
We design the remote-command path:
transport
→ bounded parser
→ canonical command hash
→ signature verification
→ audience check
→ anti-replay
→ capability authorization
→ runtime guards
→ persistent reservation
→ owner-task execution
→ audit trailThe main idea: mTLS/MQTT authenticates the channel, but the device must still verify the command's author, recipient, freshness, permissions and whether the operation is permitted now.
2. Why this matters
Commands can be dangerous:
MODEM_RESET;
SET_CONFIG;
OTA_INSTALL;
DEVICE_REBOOT;
FACTORY_RESET;
OUTPUT_CONTROL.Risks:
a retained MQTT command;
a QoS duplicate;
a broker ACL error;
a compromised backend;
an old signed message;
a command for the wrong device;
an expired command.3. Theory
Command envelope
Fields:
protocol_context;
schema_version;
issuer_id;
key_id;
audience_type;
device_id/group/product;
command_id;
sequence;
issued_at/not_before/expires_at;
capability;
operation;
payload;
signature_algorithm;
signature.The signature is calculated over the canonical bytes of all fields except the signature itself.
Domain separation
Include context in the signing input:
"traffic-command-v1"Do not mix it with an OTA manifest:
"traffic-ota-manifest-v1"Canonical encoding
Do not sign arbitrary JSON. Use a fixed binary format or deterministic CBOR.
Signature or HMAC
Preferred for server commands:
the backend holds the private command-signing key;
the device holds only the public verification key.HMAC is faster, but the device knows the secret. If HMAC is used, its key must be unique to the device.
PSA Crypto
For ESP-IDF 6.x, it is better to write new code using PSA Crypto. For ECDSA P-256, the signature format in PSA is usually 64-byte r || s, rather than DER; agree this with the backend.
Anti-replay
The following are needed together:
command_id;
sequence;
expires_at;
replay cache;
persistent journal for side effects.The order is: verify the signature first, then change replay state. Otherwise an attacker can send a large sequence with an invalid signature and block commands.
Capability model
Use specific capabilities rather than one ADMIN role:
STATUS_READ
MODEM_RESET
CONFIG_STAGE
CONFIG_COMMIT
OTA_INSTALL
DEVICE_REBOOT
FACTORY_RESET
OUTPUT_CONTROLRuntime guards
Even a signed command must check the current state:
power stable;
no Flash commit in progress;
hardware revision compatible;
service mode active;
local confirmation present;
OTA image verified.Dangerous operations
FACTORY_RESET and similar operations require prepare/commit:
PREPARE -> token/deadline/local confirm -> COMMIT4. Common mistakes
- Treating mTLS as complete authorisation.
- Using an MQTT topic as the only permission rule.
- No replay protection.
- Executing retained dangerous commands.
- Treating an MQTT msg_id as a command_id.
- Signing non-canonical JSON.
- One HMAC key for the fleet.
- Storing the command-signing private key on the device.
- Checking the operation before verifying the signature.
- Keeping sequence only in RAM for persistent operations.
- Performing a side effect before journaling it.
- Using the same key for OTA/TLS/commands/Secure Boot.
- Executing a command in the MQTT callback.
5. A practical task for 30–60 minutes
Create REMOTE_COMMAND_POLICY.md:
# Remote command policy
1. Transport security does not replace command authorization.
2. Every command has a globally unique command ID.
3. Dangerous commands are never accepted as retained MQTT messages.
4. Every signed command contains issuer, key ID and audience.
5. Commands use deterministic canonical encoding.
6. Signatures are checked before semantic execution.
7. Replay protection survives reset for persistent operations.
8. Every issuer has an explicit capability set.
9. Hardware operations run only in subsystem owner tasks.
10. Dangerous operations use prepare/commit.
11. Every decision produces an audit event.
12. Command signing keys are separate from TLS and Secure Boot keys.Implement a host-compatible command_auth_core with a mock verifier. Unit tests:
1. Valid GET_STATUS -> ACCEPTED.
2. Bad signature -> BAD_SIGNATURE.
3. Wrong device_id -> WRONG_AUDIENCE.
4. Unknown issuer -> UNKNOWN_ISSUER.
5. Missing capability -> NOT_AUTHORIZED.
6. Expired -> EXPIRED.
7. Same command_id -> DUPLICATE, side effect not called.
8. Old sequence -> REPLAY.
9. Payload too large -> BAD_FORMAT before crypto.
10. RETAIN flag for REBOOT -> REJECTED.
11. FACTORY_RESET without PREPARE -> LOCAL_CONFIRM_REQUIRED.6. What to read or try next
- ESP-IDF 6.x PSA Crypto migration.
- MQTT QoS, retained messages, the DUP flag and fragmented events.
- Capability-based authorisation.
- A persistent command journal.
Criteria: Separate channel authentication from command authenticity and freshness.
Exercise
Explain why a signed FACTORY_RESET command still needs checks beyond its signature. Describe the design checks, without executing the operation.
Self-check criteria: Cover author, recipient, freshness, permission and current admissibility. A valid signature is not a blanket authorisation.
Show the supplied answer
Verify the recipient, command_id/sequence/expiry and replay state, required capability, current runtime guards and the prepare/commit policy for the dangerous action. Journal persistent side effects before performing them and execute through the owning service rather than the MQTT callback.