Skip to content

Security and Encryption

Muhammad Nuzaihan edited this page Aug 22, 2026 · 2 revisions

Security and Encryption

This firmware layers optional end-to-end encryption on top of stock CDP, designed around the different trust boundaries in a deployment rather than one blanket scheme. Encryption is off by default and opt-in per call site — a build/device with no encryption configured behaves exactly like stock CDP.

For the full step-by-step setup, see DEVELOPER_GUIDE.md's "End-to-End Encryption Setup (DuckCrypto)" section and docs/end-to-end-encryption-setup.md in the repository. This page summarizes the design.

Identity

src/security/DuckIdentity.h/.cpp — each device generates its own X25519 keypair on first boot (from the platform's hardware RNG) and persists it to flash. There is deliberately no backup mechanism: losing the key just means re-announcing a new identity.

  • begin() — generate or load the keypair.
  • getPublicKey() / getPrivateKey() — 32-byte keys (the private key is never logged or transmitted).
  • getDuid(duid) — derives this device's DUID as a truncated hash of its own public key (self-certifying for the device's own identity — see Known Limitations below).
  • reset() — erase the stored identity so a new one is generated on next begin().

Cipher

ChaCha20-Poly1305 (IETF, 96-bit nonce, 128-bit tag) via src/security/DuckCrypto.h/.cpp, chosen for safe constant-time behavior in software across boards without requiring hardware AES acceleration everywhere.

  • encryptWithPeer() / decryptFromPeer() — session mode: static-static X25519 ECDH between two long-term identities (Duck↔Duck, or Duck↔MeshBeacon Ops for downlink commands).
  • sealToStatic() — sealed mode: fresh, one-time ephemeral X25519 keypair per call, ECDH against a pinned destination static public key (Duck→MeshBeacon Ops uplink).
  • encryptWithGroupKey() / decryptWithGroupKey() — group mode: a single pre-shared symmetric key, for broadcast traffic readable by any Duck in the same deployment.

Encryption modes and topics

Mode Topic Direction Key material
Sealed uplink sealed_uplink (0x1C) Duck → MeshBeacon Ops, one-way Ephemeral (sender) + MeshBeacon Ops's static public key
Encrypted command encrypted_cmd (0x1B) MeshBeacon Ops → Duck MeshBeacon Ops's static keypair + the Duck's identity
Peer session encrypted_data (0x1E) Duck ↔ Duck Both Ducks' long-term identities (needs prior key exchange)
Group broadcast group_broadcast (0x1F) Any Duck → any Duck with the same group key Pre-shared symmetric key
Identity announce identity_announce (0x1D) Broadcast or directed N/A — the public key itself is the payload

In every mode, the CDP header (SDUID/DDUID/topic/hop count) stays in cleartext — routing and dedup never need to see the payload — but header fields are bound into the AEAD's additional authenticated data, so a relay can't splice a captured ciphertext onto a different sender, recipient, or topic.

Fixed per-message overhead (MAX_DATA_LENGTH = 229 bytes budget): plain sendData() adds 0 bytes; sendEncryptedData() adds 29 bytes; sendSealedData() adds 61 bytes (the extra 32 bytes is the ephemeral public key, since there's no prior session to reuse).

MeshBeacon Ops public key and mesh group key (deployment configuration)

src/security/OpenDmsConfig.h/.cpp and src/security/MeshGroupConfig.h/.cpp pin, respectively, the deployment's MeshBeacon Ops static public key (not secret — only the matching private key on the server must stay confidential) and a pre-shared mesh group symmetric key (secret). Both:

  • Default to an all-zero placeholder (isConfigured() returns false until a real value is provisioned).
  • Are stored in flash (LittleFS on nRF52, EEPROM on ESP32/other) with a CRC-8 integrity check so corrupted storage fails closed instead of trusting garbage.
  • Are field-provisioned over USB serial rather than requiring a firmware reflash per deployment:
    • AT+OPENDMSKEY=<64 hex chars> / AT+OPENDMSKEY+RESET / AT+OPENDMSKEY?
    • AT+MESHKEY=<64 hex chars> / AT+MESHKEY+RESET / AT+MESHKEY?

These commands are USB-serial only today — there is no BLE stack in this firmware, so provisioning requires physical possession of the device (a deliberate safety property; see Known Limitations for why remote/BLE provisioning is riskier).

Enabling encryption

Duck::setUplinkEncryptionEnabled(bool) / isUplinkEncryptionEnabled() is a runtime preference flag, seeded at build time by the DUCK_CRYPTO_DEFAULT_ENABLED macro (0/off unless a local_*_encrypted environment sets it to 1). Sketches check this flag themselves and branch between sendData() and sendSealedData()/sendEncryptedData() — the flag does not change sendData()'s own behavior. Encryption can only ever be turned on at runtime if the firmware was built with crypto support enabled (fail closed: no build-time support means no encryption, ever).

Rejection telemetry

src/security/SecurityEventCounters.h maintains lightweight, RAM-only counters (encryptedCmdRejected, broadcastRejected, beaconRejected) incremented whenever an authentication/decrypt check fails. These have no effect on accept/reject decisions — they exist purely so a summary() string (safe to send in plaintext) can be uplinked periodically for operator visibility into rejected traffic.

Known limitations (documented, not yet fixed)

  • TOFU impersonation window: identity_announce is first-seen-wins with no signature. An attacker who broadcasts a forged announcement for a target DUID before the real device's genuine announcement is heard becomes permanently trusted for that DUID (later legitimate announcements are ignored by design). Requires physical radio proximity around a device's first boot/pairing — not a remote attack.
  • DUID self-certification is one-sided: a DUID is derived from its own device's public key, but neither learnPeerIdentity() (firmware) nor MeshBeacon Ops's TOFU logic re-derives and checks that hash when trusting an announcement claiming to be a given peer — so the "self-certifying" property only protects a device's own identity, not the validity of a peer's claimed identity.
  • No signing key: only an X25519 (ECDH) keypair exists; X25519 cannot sign. There is currently no key material capable of authenticating an identity_announce beyond the TOFU race described above.
  • No forward secrecy: session and sealed-uplink-receive modes use long-term static-static ECDH (aside from the sender-side ephemeral key in sealed uplinks). A future compromise of a long-term private key would let an attacker who recorded past ciphertext retroactively decrypt historical traffic under that key.

Clone this wiki locally