1. Today's topic

We design the remote-command path:

text
transport
→ bounded parser
→ canonical command hash
→ signature verification
→ audience check
→ anti-replay
→ capability authorization
→ runtime guards
→ persistent reservation
→ owner-task execution
→ audit trail

The 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:

c
MODEM_RESET;
SET_CONFIG;
OTA_INSTALL;
DEVICE_REBOOT;
FACTORY_RESET;
OUTPUT_CONTROL.

Risks:

text
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:

c
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:

text
"traffic-command-v1"

Do not mix it with an OTA manifest:

text
"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:

text
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:

text
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:

text
STATUS_READ
MODEM_RESET
CONFIG_STAGE
CONFIG_COMMIT
OTA_INSTALL
DEVICE_REBOOT
FACTORY_RESET
OUTPUT_CONTROL

Runtime guards

Even a signed command must check the current state:

text
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:

text
PREPARE -> token/deadline/local confirm -> COMMIT

4. 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:

markdown
# 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:

text
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.
An unauthenticated command contains a very large sequence number. Which ordering prevents it from poisoning replay state?

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.