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.
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.
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.
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).
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).
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.
| 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.
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.
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.
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.
reticulum-routing-analysis.md §3). 64-byte signature is acceptable
because adverts are infrequent and rate-capped (ANNOUNCE_CAP, routing-options.md §5) — the
64 B is paid per advert-cycle, not per message, and it is what makes the whole discovery
plane trustworthy.RNS/Cryptography; Meshtastic: RadioLib/AES.h + Curve25519; MeshCore:
Ed25519/Identity). On the embedded targets (ESP32/nRF52), AES has hardware acceleration
and the RadioLib/Arduino crypto stack is already present — favour AES-CCM/EAX there to
reuse it (matches Meshtastic). On INFRA (Linux), libsodium (XSalsa20/Poly1305,
Curve25519, Ed25519) is the clean choice and interoperates via the same primitives.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.
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.
| 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 |