Module Documentation Index » Control Module

Control Module

src/control is the management interface core: a transport-agnostic command tree, a request/response envelope, an auth layer that handles COSE-signed writes against a rotating freshness nonce, and the explicit bootstrap mode for first-key onboarding. Everything PacketRF exposes for configuration, observability and remote management goes through here.

The page below describes how the pieces fit together and the design choices that should not be re-litigated by accident. For the practical how do I add a new endpoint walkthrough see Control Developer Guide. The normative wire schema is in Control Wire Protocol CDDL.

What the module is for

The control module sits between the transport (CoAP or local serial framing) and the actual feature handlers (NPR config, interface status, crypto management, system telemetry). What it provides is a single, stable model of "one management exchange":

  • a CBOR-encoded request envelope with an integer-keyed shape,
  • optional COSE_Sign1 wrapping for authenticated writes,
  • command registration and dispatch via static descriptors,
  • shared response writer with strict size and count limits,
  • authorization policy enforcement before any handler runs,
  • a small set of generic shared commands that every node has (/schema, /system/*, /interface/list).

It is not the place where transport bytes are parsed — that belongs in src/coap for CoAP and in the serial transport for local USB. It is also not the place where individual feature commands live: those belong with the module that owns the underlying state. A /interface/np2/status handler lives in src/npr, not here. That separation is what keeps src/control small instead of becoming a mixed transport/config/crypto blob with everyone's commands inside.

The outer/inner split

PacketRF exposes one outer CoAP endpoint:

POST /mgmt

Everything goes through it. The actual command — /schema, /system/status, /crypto/bootstrap/install, /interface/np2/set, and so on — lives inside the request payload as an inner path. The serial transport carries the same inner request, framed differently on the wire. Command handlers do not know whether the request arrived over UDP or over a USB CDC ACM connection, and that is the whole point.

A handful of representative inner paths:

  • /schema
  • /system/healthcheck
  • /system/nonce
  • /system/status
  • /system/mem
  • /system/reboot
  • /crypto/device
  • /crypto/bootstrap/status
  • /crypto/bootstrap/install
  • /interface/list
  • /interface/<iface>/status
  • /interface/<iface>/config
  • /interface/<iface>/set

Request and response flow

For each request, the runtime walks the same pipeline:

  1. The outer transport receives bytes and hands them to ExchangeService together with the negotiated content-format.
  2. ExchangeService optionally unwraps a COSE_Sign1 envelope, which is where the rotating nonce, the signer key id, and the bootstrap-specific auth metadata live.
  3. The inner CBOR payload is decoded into a Request object — path, args, derived auth booleans like signature_verified, signer_is_admin, nonce_valid, bootstrap_verified.
  4. The dispatcher matches the path against the registered CommandDescriptor table and selects exactly one.
  5. The handler writes its response through ResponseWriter, which enforces the size budget and the well-known wire keys.
  6. ExchangeService optionally signs the response as COSE_Sign1 using the device key.
  7. The transport maps the logical result code into its own status space (a 4.04 for not-found, a 4.01 for unauthorized, etc.) and returns bytes.

The architectural rule that this layering enforces is short: transport parses transport, control parses control, crypto verifies crypto, handlers execute business logic. Nothing in the chain has to know anything outside its layer.

Who owns which commands

The commands the control module owns directly are the ones that have no feature-specific state behind them:

  • /schema
  • /system/healthcheck
  • /system/nonce
  • /system/status
  • /system/tasks
  • /system/mem
  • /system/reboot
  • /interface/list

Everything else lives with the module that owns the underlying state. /crypto/* is in src/crypto, /interface/np2/* is in src/npr, /interface/us0/* and the pool/DHCP commands are in src/net. New commands follow the same rule, and they should not drift back into generic core just because they are reachable through the same dispatcher.

/system/mem deserves a note: it deliberately reports allocator-level memory separately rather than collapsing everything into one number. FreeRTOS heap (pvPortMalloc), newlib heap (malloc/new via _sbrk), and lwIP memory (lwip_stats.mem) are three independently managed pools, and a problem in any one of them is hard to diagnose when an aggregated "free heap" hides which pool is actually under pressure.

Bootstrap mode

A freshly flashed PacketRF node does not enter normal runtime. It sits in a deliberately reduced state — bootstrap-required — until an admin key has been installed.

The state machine is binary:

  • bootstrap-required — no stored key has both trusted=true and admin=true. The router/radio runtime does not start; only a small management surface is reachable.
  • owned — at least one admin key is installed; normal runtime is active.

While in bootstrap mode the device exposes only the generic shared commands plus the bootstrap-specific subset of /crypto/*:

  • /crypto/schema
  • /crypto/device
  • /crypto/bootstrap/status
  • /crypto/bootstrap/install

The full interface, radio, pool, DHCP, and similar trees are simply not registered. This is the target security model, not a transitional hack — a fresh device is not remotely manageable in any meaningful way until somebody has proved physical access by reading the displayed bootstrap code and signed an install request with their own key.

Why nonce sits in COSE, not in the payload

The rotating freshness nonce is auth metadata. Same goes for the bootstrap-specific code and bootstrap key during first-key install. Putting that material in the inner CBOR payload would mix authorized business data with the auth scaffolding around it; both are easier to reason about when they live in different places.

So PacketRF carries auth-layer metadata in COSE protected headers:

  • signer kid,
  • inner content-format,
  • PacketRF nonce,
  • PacketRF bootstrap_code (only in bootstrap install),
  • PacketRF bootstrap_key (only in bootstrap install).

src/cose decodes the protected headers, ExchangeService validates nonce and bootstrap constraints, and the handler receives a Request with derived booleans (signature_verified, signer_is_admin, nonce_valid, bootstrap_verified) so it does not have to parse any of that itself.

<tt>/system/nonce</tt>

/system/nonce is a public read endpoint that publishes the currently active rotating freshness token used for signed write authorization. It returns at least:

  • path
  • status
  • nonce
  • valid_for_s
  • rotation_period_s
  • boot_id

The nonce is currently device-generated, 96 bits, rotated on a fixed uptime interval, and required for every signed write. The host-side tool fetches it before signing — the client does not have to track expiration manually.

Signed write freshness

PacketRF does not implement a full replay cache. The current model is a deliberately bounded compromise:

  1. the device publishes the current nonce through /system/nonce,
  2. a client caches that nonce until local expiry,
  3. a signed write request carries the cached nonce in COSE protected headers,
  4. the firmware accepts the request only if the carried nonce matches the current device nonce.

That protects against replay of a captured request after the nonce has rotated. It does not prevent duplicate replay within the active nonce window, which is a smaller attack surface than full replay across windows. A real per-key replay cache can be added later as a firmware-internal change without affecting the wire protocol.

Bootstrap install in detail

/crypto/bootstrap/install is a special write path that only does anything while the device is in bootstrap-required mode. The first implementation does not accept any business payload fields; the material that matters is auth-layer and is therefore carried in the COSE protected headers:

  • nonce
  • bootstrap_code
  • bootstrap_key

The request is signed by the private key matching bootstrap_key. Firmware validation walks through:

  1. the inner path is /crypto/bootstrap/install,
  2. the device is still in bootstrap-required,
  3. the bootstrap code matches the current displayed code,
  4. the nonce matches the current device nonce,
  5. the COSE signature verifies against bootstrap_key,
  6. the signer kid matches the key id derived from bootstrap_key.

If all six pass, the firmware stores the bootstrap public key as the first trusted admin key and schedules a reboot. If any of them fails, the request is rejected with the matching error code and the device remains unprovisioned.

Response signing

When a device has identity and the signing service is available, ExchangeService signs responses as COSE_Sign1 if policy allows. That means the same response path supports unsigned public responses on devices that have not yet been provisioned, signed responses on fully owned devices, and verification on the host side against a local trusted-device store. During the very first contact with a fresh device the host typically has no trust anchor for it yet, so the response will not verify on first contact — that is expected, and it is the same shape of trust-on-first-use that SSH uses.

<tt>ControlServices</tt>

ControlServices is the bundle passed into handlers. The point is not to hide every dependency behind one struct; the point is the opposite — to make the small set of process-level services that generic handlers need explicit instead of letting them reach for globals. Today that bundle includes the clock, the device identity, the system status provider, the reboot controller, the command registry, and the nonce source. Feature-specific state stays in the owning module's adapters.

Error codes

The control layer returns numeric error_code values on the wire. The current set:

CodeMeaning
1001invalid_request
1002unauthorized
1101bootstrap_required
1102invalid_bootstrap_code
1103bootstrap_already_completed
1104bootstrap_key_flags_invalid
1105invalid_bootstrap_request
1201nonce_required
1202nonce_invalid
1203nonce_stale
1404not_found
1429busy
1503service_unavailable

The outer transport maps those into transport-specific status codes. For CoAP, src/coap decides whether the reply becomes 4.00, 4.01, 4.04, 5.03 and so on.

Wire model in one paragraph

The inner request/response wire model is integer-key envelope based. Request keys: 0=path, 1=args (optional). Response keys: 0=status, 1=kind, 2=path, 3=error_code, 4=error_detail. Payload keys vary by kind: 10/11 for schema catalog, 20/21 for schema target, 30 for a data values map, 40 for a list of items. /system/status is a kind=data response and includes schema_id (uint64), schema_profile, and serial_number. The canonical reference is the CDDL file in src/control/protocol.

Limits and fail-closed behavior

The control layer enforces strict bounds and fails closed on overflow:

  • max inner payload: 1400 B,
  • max request args: 16,
  • max request path: 96 B,
  • max domain key: 64 B,
  • max array-like count in a schema or list response: 64.

When any limit is exceeded, the writer or decoder returns an error instead of producing a half-built payload — a partial valid-looking response is much harder to debug than an honest error.

Schema and discovery

/schema returns the active command tree as built by the running scenario. In bootstrap mode the published tree only contains bootstrap paths; in owned mode it contains the normal runtime command set. Tools should prefer schema-driven discovery over hard-coding a fixed command tree. The schema includes a schema_id that changes when the published surface changes, which lets host automation detect when its cached view of the device has drifted.

Reading the implementation

If you are reading the source for the first time, the quickest path to understanding the layering is:

  1. exchange_service.cpp
  2. default_commands.cpp
  3. schema.cpp
  4. src/cose/cose.cpp
  5. src/crypto/control_commands.cpp

That order matches the actual runtime flow and keeps the auth layer in front of the handlers, which is the part that is otherwise easy to miss when dropping into a feature command first.