Skip to content

feat: secure remote command execution over MQTT (slot-based) - #37

Draft
agessaman wants to merge 6 commits into
observer-firmware-devfrom
feat/remote-control
Draft

feat: secure remote command execution over MQTT (slot-based)#37
agessaman wants to merge 6 commits into
observer-firmware-devfrom
feat/remote-control

Conversation

@agessaman

Copy link
Copy Markdown
Owner

Summary

Re-implements the JWT-authenticated remote serial command execution over MQTT
feature (originally a single commit on the long-stale mqtt-bridge-implementation-remote,
~1223 commits / 6 months behind) onto the current slot-based observer MQTT
architecture. This is a re-implementation, not a cherry-pick: the old feature was built
around a fixed two-analyzer (US/EU) client model and NodePrefs-resident MQTT config,
both of which no longer exist.

An admin holding an authorized Ed25519 key can publish a signed command to
meshcore/{IATA}/{DEVICE}/serial/commands; the device verifies it, runs it through the
existing CLI, and publishes a signed reply to .../serial/responses.

Design decisions (from the reviewed plan)

  • Testable policy engine. All security-critical logic (replay/nonce, per-key rate
    limit, command blacklist, target filtering, authorization ordering) lives in a new pure
    RemoteControl helper with the JSON/base64/Ed25519/clock behind injected seams, so it is
    fully host-tested without MQTT or crypto. The bridge implements the seams (JWTHelper +
    LocalIdentity, MQTTPrefs + ACL callback, CLI callback, millis/time).
  • Per-slot enable + global kill switch. set mqtt.remote on|off is the master
    (default off); set mqttN.remote on|off gates each slot (default on). Effective =
    master AND slot. Subscriptions are reconciled live by the bridge task, so the kill switch
    unsubscribes every slot and drops any in-flight command without a WSS restart.
  • Dropped from the old feature: the memory-pressure degraded-mode (built on the removed
    two-analyzer model; slots already bound memory via PSRAM active-slot caps + circuit
    breakers), and the -native-observer-{hash} version-string change (collides with the
    observer release-channel versioning).
  • letsmesh contract unchanged: topic namespace and JWT/Ed25519 request/response format
    are preserved.

Security posture

  • Remote commands run with a non-zero sentinel sender_timestamp, so the serial-only
    CLI gates (get prv.key, set freq, erase, …) treat them as not local console and
    refuse — a remote admin gets mesh-admin-equivalent access, never console-only access.
    (The old feature passed 0, i.e. full serial access.)
  • Authorization via the ACL admin list (mqtt.useacl on, default) or an explicit
    mqtt.admin key; command blacklist (get wifi.pwd, set mqtt.admin); reboot refused;
    nonce replay + per-key rate limiting run before the expensive signature verify.
  • get mqtt.admin reveals the key over serial only.

Preferences / fleet migration

New fields are appended to the MQTTPrefs tail (mqtt_remote_enabled, mqtt_use_acl,
mqtt_slot_remote_enabled[], mqtt_admin_public_key); payload version stays 1. A new
kV1PreRemotePayloadSize (2864) decode checkpoint means a /mqtt_prefs written by current
field firmware still loads as Current, with the remote fields defaulting (master off, ACL
on, per-slot on). A forward (2940-byte) file is held, not discarded by older firmware.
static_asserts lock the offsets.

CLI

set/get mqtt.remote, mqtt.useacl, mqtt.admin, and per-slot set/get mqttN.remote.

Testing

  • pio test -e native: 238/238 pass, including a new 23-case test_remote_control
    suite (replay, rate-limit, blacklist, target filtering, ACL-vs-key authz, malformed JWT,
    timeout, response construction) and 2 new codec migration tests (pre-remote load + full
    round-trip).
  • ESP32 builds green: Heltec_v3_repeater_observer_mqtt and
    Heltec_v3_room_server_observer_mqtt.

Not included (possible follow-ups)

  • Webconfig UI exposure of the new remote fields (CLI-only for now).
  • On-target hardware test (end-to-end command round-trip against a broker).
  • Confirmation that the letsmesh.net server side still targets this topic/JWT contract now
    that brokers are preset-based.

Commits

jwt verify → RemoteControl engine + tests → MQTTPrefs fields + codec → CLI → bridge
glue → example wiring.

Adds JWTHelper::verifyToken (signature check + claim extraction, hex or
base64url signatures) and JWTHelper::base64UrlDecode, complementing the
existing token-creation path. Firmware-only; used by remote command auth.
Pure, host-testable engine for JWT-authenticated remote serial commands:
replay protection, per-key rate limiting, command blacklist, target
filtering, and authorization ordering. All JSON/base64/crypto/clock I/O is
behind injected seams so the full pipeline is unit-tested under env:native
(23 googletest cases) without MQTT or Ed25519.
Adds mqtt_remote_enabled (global master), mqtt_use_acl, per-slot
mqtt_slot_remote_enabled[], and mqtt_admin_public_key to the /mqtt_prefs
tail. Introduces the kV1PreRemotePayloadSize (2864) decode checkpoint so a
pre-remote payload still loads as Current with the remote fields defaulting
(master off, ACL on, per-slot on). Payload version stays 1; a forward file
(2940) is held, not discarded, by older firmware. New host codec tests cover
the pre-remote migration and the full round-trip.
Global: set/get mqtt.remote (master kill switch), mqtt.useacl, mqtt.admin
(admin key readable over serial only). Per-slot: set/get mqttN.remote,
following the existing mqttN.* slot-command convention. Setters only persist;
the bridge reconciles command subscriptions live, so no WSS restart is needed.
Binds the RemoteControl policy engine to the slot clients: each slot registers
an onMessage callback that claims a single lock-free pending slot; the bridge
task verifies + executes it and publishes the signed response to the
originating slot. Subscriptions are reconciled live against the global master
(mqtt.remote) and per-slot (mqttN.remote) flags, so the kill switch
unsubscribes every slot and drops any in-flight command without a WSS restart.

The bridge implements the RemoteControl crypto/authorizer/executor/clock seams
privately (JWTHelper + LocalIdentity, MQTTPrefs + ACL callback, CLI callback,
millis/time). Remote commands run with a non-zero sentinel sender_timestamp so
serial-only CLI gates (prv.key, freq, erase) still refuse them.
Add a shared MQTTRemoteCallbacks.h (ACL admin authorizer + CLI executor
adapters) and register them on the bridge in the repeater and room-server
examples right after construction, at every bridge-creation site.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant