← RFC

Security Options — Bandwidth Budget Analysis

Scope: Security schemes viable within LoRa's bandwidth, each with the byte/airtime budget the brief requires. Covers key exchange, per-message auth vs per-session encryption, replay prevention without synchronised clocks, group vs unicast handling, and library choice. The brief's worked example — "a 255-byte LoRa payload with a 32-byte MAC and 24-byte nonce leaves 199 bytes" — is reproduced and generalised below. Citations are to the three reference crypto implementations.


1. The governing constraint: every overhead byte is airtime, and airtime is quantised

LoRa airtime steps in symbols, not bytes: payloadSym = 8 + ceil((8·PL − 4·SF + 28 + 16·CRC)/(4·SF))·(CR+4). At LongFast (SF11/BW250) one symbol carries 4·SF/8 = 5.5 payload bytes, so overhead crosses symbol boundaries in lumps. Marginal airtime cost of security overhead added to a small (20 B) telemetry payload:

Overhead added Extra airtime % of the 20 B packet's airtime
+2 B 0 ms 0.0%
+8 B 82 ms 20.7%
+12 B 82 ms 20.7%
+16 B 123 ms 31.1%
+24 B 164 ms 41.5%
+32 B 246 ms 62.2%
+48 B 369 ms 93.3%
+64 B 492 ms 124.4%

The headline: on small messages — Loomwave's dominant traffic (telemetry, short text) — crypto overhead is not a rounding error, it is a multiplier on airtime. A 48-byte token (Reticulum) more than doubles a telemetry packet's airtime; a 64-byte signature more than doubles it again. A 2-byte MAC is free (below the symbol step). Since airtime is the scarce resource that bounds every MAC and routing choice (mac-layer-options.md §8), the security scheme is an airtime decision first and a cryptography decision second.


2. How the three reference systems spend their bytes

2.1 Meshtastic — derived nonce, asymmetric MAC policy

CryptoEngine (CryptoEngine.cpp, CryptoEngine.h): - Channel (broadcast) messages: AES-256 (or AES-128) in CTR mode with the channel PSK (encryptAESCtr, CryptoEngine.cpp:269; AESSmall256, CryptoEngine.h:56). The 128-bit nonce is derived, not transmitted — built from packetId(8 B) | fromNode | extraNonce in initNonce() (CryptoEngine.cpp:288) and reconstructed at the receiver from header fields. Wire overhead: 0 bytes. But CTR provides confidentiality only — there is no authentication tag on channel messages. This is a real, known Meshtastic weakness: anyone with the (often shared/default) channel key can forge messages, and bit-flips are undetectable. - Direct messages (PKI): AES-CCM with a key from Curve25519 ECDH (encryptCurve25519, CryptoEngine.h:46). aes_ccm_ae(shared_key, 32, nonce, 8, …) (CryptoEngine.cpp:137) produces an 8-byte CCM auth tag, and a 4-byte extraNonce is appended (numBytes − 12 on decrypt, :178). Wire overhead: 12 bytes, authenticated.

So Meshtastic spends 0 bytes to secure (but not authenticate) broadcasts and 12 bytes to authenticate-and-encrypt DMs. The derived-nonce trick is the single best idea here for LoRa.

2.2 MeshCore — 2-byte MAC, signed adverts

MeshCore.h: CIPHER_BLOCK_SIZE = 16 (AES-128), CIPHER_MAC_SIZE = 2. Data packets carry a 2-byte authentication tag — an extremely aggressive truncation (forgery probability 2⁻¹⁶ ≈ 1/65536 per attempt). Identity uses 32-byte public keys (PUB_KEY_SIZE = 32) with Ed25519 signatures (64 B) on adverts (Identity.cpp:sign/verify:17, meshcore-routing-analysis.md §5) and ECDH shared secrets for message encryption (Identity.h:calcSharedSecret). Wire overhead: 2 bytes per data packet; 64 bytes per advert (paid once per advert, not per message).

This is the most airtime-frugal data path of the three — and the 2-byte MAC is a deliberate bet that online forgery against a slow LoRa channel is impractical (an attacker gets few guesses/second, and each guess costs airtime).

2.3 Reticulum — strong but heavy

Identity.py / Cryptography/Token.py: X25519 ECDH + HKDF session key (Link.py:handshake:353); bulk crypto is a Fernet-like AES-CBC + HMAC-SHA256 token with TOKEN_OVERHEAD = 48 B (16-byte IV + 32-byte HMAC, Token.py:50) plus 1–16 B PKCS7 padding; Ed25519 signatures SIGLENGTH = 64 B; ratchets 32 B. Wire overhead: 48+ bytes per message — designed for ≥500-byte MTU links, far too heavy for LoRa telemetry (§1).


3. Byte budget within a 255-byte LoRa frame

Reproducing and generalising the brief's example (overhead = routing/header + nonce + MAC + signature; payload = frame − overhead):

Scheme hdr nonce MAC sig total overhead payload left % of frame
Meshtastic channel (CTR, no MAC) 16 0 0 0 16 B 239 B 6.3%
Meshtastic DM (CCM, PKI) 16 4 8 0 28 B 227 B 11.0%
MeshCore data (AES-128, 2 B MAC) 2 0 2 0 4 B 251 B 1.6%
MeshCore advert (Ed25519) 2 0 0 64 66 B 189 B 25.9%
Reticulum token (CBC+HMAC) 35 16 32 0 83 B 172 B 32.5%
Brief's example (32 B MAC + 24 B nonce) 0 24 32 0 56 B 199 B 22.0%
Loomwave proposed data (8 B tag, derived nonce, short hdr) 8 0 8 0 16 B 239 B 6.3%

The brief's "199 bytes left" line is confirmed exactly (255 − 56 = 199). But that 56-byte configuration is near the worst end of the design space — it spends 22% of the frame and (§4) doubles airtime on small packets. Loomwave can do far better by (a) deriving the nonce instead of transmitting it and (b) truncating the MAC.


4. Airtime cost of a typical secured packet (20 B payload)

Scheme airtime (LongFast) vs unsecured 20 B
MeshCore data (2 B MAC) 436 ms 1.10×
Meshtastic channel (0 B) 518 ms 1.31× (header-dominated)
Loomwave proposed (8 B tag, derived nonce) 518 ms 1.31×
Meshtastic DM (12 B) 600 ms 1.52×
Brief example (56 B) 805 ms 2.04×
MeshCore advert (Ed25519) 887 ms 2.24×
Reticulum token (83 B) 1010 ms 2.55×

At 2.08 data slots/s on LongFast (mac-layer-options.md §3), the difference between a 2.55× scheme and a 1.1× scheme is a >2× difference in network message capacity. Security overhead trades directly against the per-node message rate computed in the MAC analysis.


5. Design decisions for Loomwave (with rationale)

5.1 Key exchange — no handshake on the wire

Full TLS-style handshakes are out (the brief says so, and §1 shows why). Use static Curve25519/X25519 identity keys (as all three references do) with implicit ECDH: because addresses are the hash of the public key (reticulum-routing-analysis.md §2), a sender can derive the shared secret from the recipient's address-certified public key with zero handshake packets — exactly Meshtastic's encryptCurve25519 model (CryptoEngine.cpp:encryptCurve25519). The public key reaches peers once, in a signed advert (amortised, §5.4), not per message. - Optional session upgrade: for long-lived high-volume links, a Reticulum-style ephemeral X25519 + ratchet handshake (Link.py:353) buys forward secrecy, paid once at link setup. Reserve for INFRA↔INFRA or sustained DM sessions, not telemetry.

5.2 Per-message authentication vs per-session encryption

LoRa traffic is mostly small, independent datagrams, not streams — so per-message AEAD (authenticated encryption) is the right default, not session state that breaks under packet loss. Recommendation: AEAD per packet (AES-CCM/EAX with the SX-friendly AES core already in the radio ecosystem, or ChaCha20-Poly1305 where a software core is acceptable), with a truncated 8-byte tag: - 8-byte tag ⇒ forgery 2⁻⁶⁴ per attempt — overkill-safe for a channel that admits maybe a few guesses/second; and it is airtime-free relative to a 12-byte scheme while far safer than MeshCore's 2-byte tag (2⁻¹⁶, which is uncomfortably forgeable for authorization- bearing messages even if impractical online). - This authenticates every message — closing Meshtastic's unauthenticated-broadcast gap (§2.1) for only 8 bytes.

5.3 Replay prevention without synchronised clocks

The brief explicitly requires this. Use a monotonic per-sender sequence number folded into the derived nonce (Meshtastic's initNonce pattern: nonce = f(sender, seq), CryptoEngine.cpp:288), and a per-sender sliding replay window at the receiver (accept seq > high-water, or within a window and not previously seen — the IPsec/anti-replay technique). Properties: - No clocks needed — ordering is by sequence, not time. - Zero extra wire bytes if the sequence is already part of the packet id used to derive the nonce (it must be transmitted somewhere; reuse the existing packet-id/seq field rather than adding a 24-byte random nonce as in the brief's heavy example — that is the single biggest saving, −24 B). - Survives reboots by persisting the high-water mark (INFRA has storage; CLIENT persists its own counter). - A coarse timestamp/epoch may still be included in adverts (MeshCore does, Mesh.cpp:392) for freshness ordering of identity data, but data-plane replay does not depend on it.

5.4 Group (broadcast/channel) vs unicast

5.5 Library choice


Per-packet overhead, Loomwave proposed:

Element Bytes Note
AEAD tag (truncated) 8 authenticates every packet
Nonce 0 derived from sender+seq (Meshtastic pattern)
Routing/security header ~8 short address + flags + seq (see routing-options.md)
Signature (data) 0 data uses symmetric AEAD; sigs only on adverts
Total per data packet ~16 B 239 B payload remains in a 255 B frame (6.3%)
Advert signature (per advert) 64 Ed25519, rate-capped, amortised

Airtime impact: a 20 B telemetry message secured this way is ~518 ms (1.31× the unsecured floor), vs 805 ms for the brief's 56-byte example (2.04×) and 1010 ms for Reticulum (2.55×). Against the LongFast budget of 2.08 data slots/s, the proposed scheme preserves ~1.5–2× more network message capacity than a naïve heavy scheme while providing authenticated encryption on every packet (which Meshtastic broadcasts lack) and unforgeable signed identity on adverts.

Security-overhead fraction of channel at dense-event (N=100, 1 msg/node/min, scheduled): - data crypto adds 8 B tag → the 100 msgs already counted in mac-layer-options.md §6 carry it inside the 32 B figure, so it is within the ~79.5% data budget, not on top. - advert signatures live inside the ≤2% ANNOUNCE_CAP discovery budget. - Net additional security overhead beyond what the MAC budget already accounts: ~0% — the scheme is designed so its cost is the payload bytes themselves, kept minimal by derived nonces and tag truncation.


7. Open questions for the security spec (Stage 3)

  1. Tag length final call: 8 B (2⁻⁶⁴) vs an even shorter 6 B (2⁻⁴⁸) to save a symbol on the smallest packets — quantify against the realistic online-forgery rate on LoRa.
  2. Replay window size per role (CLIENT memory vs INFRA storage) and behaviour across reboot/clock-loss.
  3. Channel-key management: rotation, compromise recovery, and whether to add optional per-sender signatures on all channel admin traffic.
  4. Forward secrecy scope: do we adopt Reticulum-style ratchets for INFRA↔INFRA and long DM sessions, and what is the ratchet-advertise cost within ANNOUNCE_CAP?
  5. Key-compromise / revocation in an infrastructure-free network (no CRL): rely on advert supersession + out-of-band trust, or add a lightweight revocation advert?
  6. Quantum-resistance posture: out of scope for v1, but note key sizes if a future PQ hybrid is desired.

All overhead figures are model-derived from the verified LoRa airtime model and the cited reference implementations; the final tag length and replay design must be validated for both security margin and airtime in Stage 2/3 before the security spec is frozen.


Source index (files cited)

Topic Reference
Meshtastic channel CTR / derived nonce CryptoEngine.cpp:encryptAESCtr:269, initNonce:288, CryptoEngine.h:56
Meshtastic PKI DM / CCM 8-byte tag CryptoEngine.cpp:137, :178, encryptCurve25519 CryptoEngine.h:46
MeshCore 2-byte MAC / AES-128 MeshCore.h:CIPHER_MAC_SIZE, CIPHER_BLOCK_SIZE
MeshCore Ed25519 adverts Identity.cpp:sign/verify:17, Mesh.cpp:createAdvert:392
Reticulum X25519 + token Link.py:handshake:353, Cryptography/Token.py:50, Identity.py:SIGLENGTH:81, RATCHETSIZE:64
Self-certifying address ↔ key Destination.py:hash:116, Identity.py:validate_announce:532