Skip to content

cdc_msg message-transfer protocol

cdc_msg (components/cdc_msg/) is the generic badge-to-badge transfer framework. It sends a typed payload (a MIME type plus raw bytes) to a nearby badge over a lightweight GATT profile, asks the receiving user for consent, encrypts the link with an ephemeral pairing, and delivers the bytes to whatever module or plugin registered a handler for that MIME type. The core (MessageTransfer) is headless; the OS UI layer renders the prompts.

  • Sender (central) connects out to a peer, discovers the service, offers a typed payload, and after consent streams the bytes.
  • Receiver (peripheral) advertises the service via the beacon, accepts an offer after user consent, and reassembles the payload.

Both roles run from one MessageTransfer singleton, driven by tick() on the main task. NimBLE host-task callbacks only set flags / copy small data under a short mutex try-lock; tick() advances both state machines and does the heavy work (delivery, NVS, teardown).

A 128-bit random base UUID is used, with the discriminator at byte index 12. The layout mirrors the Nordic UART Service pattern.

RoleUUIDPropertiesDirection
ServiceCDC50001-B0A7-4E3D-9C82-5A6F1B3E9D2Cadvertised so scanners find a badge
ControlCDC50002-...WRITE (plaintext)sender -> receiver
StatusCDC50003-...NOTIFY (plaintext)receiver -> sender
DataCDC50004-...WRITE / WRITE_NO_RSP (encrypted)sender -> receiver

The Data characteristic permission is WRITE_ENC, so chunks can only be written after the link is encrypted. Control and Status are plaintext: the offer and the accept/decline/progress/error signalling happen before encryption.

The receiver advertises the service UUID plus its Complete Local Name; there is no proprietary manufacturer data. Discovery filters scan results to advertisers carrying the service UUID.

All multi-byte fields are little-endian. Every field is bounds-checked against fixed limits before use; wire data is never trusted. The wire protocol version is 1; a mismatch is rejected.

Control (sender -> receiver), byte 0 = opcode

Section titled “Control (sender -> receiver), byte 0 = opcode”
OpcodeValueLayout
Offer0x01[op][ver][u32 totalLen][mimeLen][nameLen][mime...][name...] (8-byte fixed header)
Abort0x02sender aborts the in-flight transfer

The receiver validates the OFFER bounds-first: header length, version, a non-zero mimeLen <= 63, nameLen <= 31, that 8 + mimeLen + nameLen fits the frame, and that 0 < totalLen <= 4096. A bad frame is declined with BadFrame; an over-size payload with TooLarge.

Data (sender -> receiver), byte 0 = opcode

Section titled “Data (sender -> receiver), byte 0 = opcode”
OpcodeValueLayout
Chunk0x10[op][reserved][bytes...] (2-byte header)
Complete0x11[op][reserved][u32 crc32] (6 bytes)

Chunk bounds use a subtraction form that cannot wrap: a chunk is rejected if it would push received past totalLen. Complete is rejected unless exactly totalLen bytes were received; the CRC is verified in tick() before delivery.

Status (receiver -> sender), byte 0 = opcode

Section titled “Status (receiver -> sender), byte 0 = opcode”
OpcodeValueMeaning
Accept0x01receiver accepted; sender may start encryption
Decline0x02[op][Reason] receiver declined
Progress0x03[op][u32 received] receive progress
Done0x04payload received and delivered
Error0x05[op][Reason] transfer aborted
Busy0x06receiver saturated; try again later
Queued0x07offer queued behind an active transfer

Progress notifications are throttled to at most every 512 received bytes (and always on the final byte).

None(0), NoHandler(1), UserDeclined(2), TooLarge(3), Busy(4), Timeout(5), BadFrame(6), CrcMismatch(7), Disconnected(8), PairFailed(9).

  1. Connect & discover. The sender connects to the peer and discovers the service, locating the Control, Status and Data value handles. (The Status CCCD is taken as statusHandle + 1.)
  2. Subscribe & offer. The sender enables Status notifications, then writes an OFFER carrying the MIME type, the sender’s beacon name and totalLen. The OFFER is bounded to both the negotiated MTU and the frame buffer; if it does not fit, the sender name is truncated, and if even the MIME does not fit the send fails with BadFrame.
  3. Handler check & rate limit. The receiver declines with NoHandler if no local module/plugin (live or deferred) handles the MIME type, replies Busy on a per-connection rate-limit hit, otherwise enqueues the offer.
  4. Consent. When the offer reaches the head of the queue and the prompt budget allows, the receiver moves to AwaitingConsent and publishes BLE_CONSENT_REQUEST; the UI renders the prompt. On accept the receiver notifies Accept; on decline it notifies Decline(UserDeclined) and starts a quiet cooldown.
  5. Encrypt (ephemeral pairing). On Accept the sender calls initiateSecurity(), triggering numeric-comparison pairing. When the link is encrypted, the receiver allocates the reassembly buffer (PSRAM) and moves to Receiving; the sender moves to Streaming.
  6. Stream. The sender writes Chunk frames sized to the negotiated MTU (clamped to the stack frame buffer), then a Complete frame with the CRC32. The receiver notifies Progress as bytes arrive.
  7. Deliver. On Complete, tick() verifies the CRC and the exact length, then dispatches to the registered handler (or the deferred plugin resolver). On success the receiver notifies Done and publishes BLE_EXCHANGE_COMPLETE; a short grace period lets the Done notification flush before the link is dropped.

State machines:

  • Sender: Idle -> PickingPeer -> Connecting -> Discovering -> Offering -> AwaitingEncrypt -> Streaming -> Done | Failed.
  • Receiver: Idle -> AwaitingConsent -> AwaitingEncrypt -> Receiving -> Delivering -> Done | Failed.

The link is encrypted with a numeric-comparison pairing, the same mechanism host pairing uses. The bond created for the transfer is ephemeral: each side records the peer’s identity address while encrypting and forgets the bond (forgetBond) when the connection drops, so a transfer does not leave a persistent bond behind. The bond-tracking table is sized to the controller’s connection capacity; a full table is logged as a warning since an un-forgotten bond is security-relevant.

LimitValue
Max payload per transfer4096 bytes
MIME type length (excl. NUL)63 bytes
Peer name length (excl. NUL)31 bytes
Registered MIME handlers8
Pending offer queue depth4
PhaseTimeout
Connect10 s
Discovery5 s
Consent30 s
Encryption (numeric comparison)30 s
Transfer idle8 s
  • Global prompt budget (address-independent, the authoritative control, because BLE addresses rotate): at most 5 consent prompts per 30 s window.
  • Quiet cooldown: a 20 s global quiet period after the user declines an offer.
  • Per-connection rate table (best-effort, 8 entries): at most 3 offers per 10 s window per connection.
  • A full offer queue replies Busy (graceful saturation) rather than dropping silently.

A module registers a delivery handler for a MIME type:

cdc::msg::MessageTransfer::instance().registerHandler(
"text/vcard", // MIME type, 1..63 bytes
"mod_vcard.received", // i18n key (or literal) shown in the consent prompt
deliverVcard); // DeliverFn callback

The DeliverFn signature is:

bool deliver(const uint8_t* data, uint32_t len,
const char* mime, const char* peerName);

data is untrusted, attacker-controlled and not NUL-terminated; the handler must validate it and copy into a bounded buffer before parsing. Return true only if the payload was accepted/stored. The registry holds up to 8 handlers. unregisterHandler(mime) removes one. See the vCard handler for a concrete example.

  • beginInteractiveSend(mime, data, len) buffers the payload and requests the interactive peer picker (the UI confirms a target, then the framework connects).
  • sendTo(addr, addrType, mime, data, len) sends directly to a known peer with no picker.

Both copy the payload into PSRAM and refuse if a send is already in progress or arguments are invalid.

Plugins may register handlers for MIME types even when the plugin is not loaded. setDeferredHandler(canHandle, deliver) installs a resolver: when an OFFER arrives for a MIME type no live handler covers, the framework asks canHandle whether some installed plugin handles it (host-task safe), and on completion hands the payload to deferredDeliver, which defers the actual plugin load and dispatch to the plugin task. This resolver is installed once by the plugin manager at init.

Plugins access the framework through the host API (host_msg_register_handler / host_msg_send_interactive / host_msg_send / host_msg_consume), with HOST_MSG_PAYLOAD_MAX (4096) and HOST_MSG_MIME_MAX (64) matching the framework limits.

The framework publishes two EventBus events the UI subscribes to:

  • BLE_CONSENT_REQUEST — an inbound offer reached AwaitingConsent; show the prompt.
  • BLE_EXCHANGE_COMPLETE — a transfer finished (success or failure); read the result via consumeResult() and show a toast.