Skip to content

Conversation

@weebl2000
Copy link
Contributor

@weebl2000 weebl2000 commented Feb 12, 2026

Summary

Adds ChaCha20-Poly1305 (AEAD-4) encryption alongside the existing AES-128-ECB + HMAC-2 scheme. Updated nodes send AEAD-4 to peers that advertise support and fall back to ECB for legacy peers. All nodes can decode both formats. Old nodes continue to work unchanged.

Relates to #259.

What This Means in Practical Terms

The current encryption has a few weaknesses that this PR begins to address:

  • Message tampering is too easy to attempt. The existing 2-byte authentication code means an attacker only needs about 65,000 guesses to forge a valid-looking message. At LoRa speeds that's roughly 9 hours of continuous attempts. The new 4-byte tag raises this to over 4 billion guesses — at LoRa rates, that would take over a century.

  • Identical messages look identical on the air. The current block cipher (ECB mode) produces the same ciphertext for the same plaintext, which can reveal patterns — for example, you could tell when someone sends the same message twice. The new scheme produces completely different ciphertext every time, even for identical messages.

  • Addressing fields are now protected. Currently, only the message body is authenticated. With AEAD, the payload type and addressing hashes (which identify sender and recipient) are included in the authentication check, so an attacker cannot swap or modify them without detection. Outer routing fields like TTL and hop path are intentionally left unauthenticated so repeaters can still forward packets through the mesh.

  • Messages get slightly smaller. ECB pads every message up to a 16-byte boundary, wasting airtime. The new scheme has no padding, so most messages shrink by a few bytes on the wire.

  • Nothing breaks. Updated nodes send AEAD-4 to peers that advertise support, and fall back to ECB for legacy peers. Old nodes are completely unaffected — they never receive AEAD-4 messages because the sender checks their capability first.

  • Nodes advertise their capabilities. Updated nodes include a flag in their advertisements saying "I understand the new encryption." When two updated nodes discover each other, they automatically start using AEAD-4 for their communication.

Wire Format

Current ECB:

[HMAC:2] [ECB_ciphertext:N×16]     (padded to block boundary)

New AEAD-4 (same position in payload):

[nonce:2] [ciphertext:M] [tag:4]    (exact plaintext length, no padding)

Average overhead: ~6 bytes (AEAD) vs ~9.5 bytes (ECB). Most messages get smaller.

Cryptographic Design

Per-message key derivation (eliminates nonce-reuse catastrophe):

msg_key[32] = HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash)

Including dest_hash || src_hash makes keys direction-dependent — Alice→Bob and Bob→Alice derive different keys even with the same nonce value (for 255/256 peer pairs; the 1/256 where dest_hash == src_hash is a residual limitation of 1-byte hashes).

IV construction (12 bytes, from on-wire fields):

iv[12] = { nonce_hi, nonce_lo, dest_hash, src_hash, 0, 0, 0, 0, 0, 0, 0, 0 }

Associated data (authenticated but not encrypted):

  • Peer messages: header || dest_hash || src_hash
  • Anonymous requests: header || dest_hash
  • Group messages: header || channel_hash

Nonce management: 16-bit counter per peer, seeded from hardware RNG on boot and on contact load. Not persisted to flash — always fresh on each boot cycle.

Security Comparison

Property ECB + HMAC-2 (current) AEAD-4 (new)
Confidentiality Identical blocks → identical ciphertext Unique keystream per message
Forgery resistance 1/65K (~9 hours at LoRa rates) 1/4.3B (~136 years)
Key usage 16 of 32 bytes (AES-128) Full 32 bytes (ChaCha20-256)
Addressing authentication None Payload type & address hashes authenticated via AAD
MAC timing memcmp (timing side-channel) secure_compare (constant-time)
Padding waste 0-15 bytes per message None

Scope

Payload type AEAD-4 decode AEAD-4 send Notes
TXT_MSG, REQ, RESPONSE, PATH Yes Yes (if peer advertises AEAD) Per-peer ECDH secret, no collision risk
ANON_REQ Yes No (no prior capability exchange) Ephemeral ECDH secret
GRP_TXT, GRP_DATA Yes No (see group considerations) Shared channel key

Group Message Considerations

Group channels share a single key among all members. With a 2-byte nonce and multiple senders, cross-sender nonce collisions follow the birthday bound (~300 messages for 50% probability on an active channel). A collision leaks P1 ⊕ P2 for that specific message pair via crib-dragging, but:

  • No key recovery — per-message key derivation via HMAC-SHA256 is one-way
  • No cascade — each collision is isolated, doesn't affect other messages
  • Bounded threat model — the attacker must not have the channel PSK (if they do, they can already read everything)

This is mainly beneficial for public/hashtag channels where the PSK is already widely known and the ECB pattern leakage and weak MAC are a greater concern than the bounded nonce collision risk.

Potential future mitigations explored and deferred:

  • Per-sender derived keys (HMAC(channel_secret, sender_pub_key)) — eliminates cross-sender collisions but requires receivers to know all senders' public keys, changing the group security model from "know the PSK = full access" to "know the PSK + sender discovery = access." Ruled out as a usability regression.
  • Expanded nonce (4 bytes instead of 2) — pushes birthday bound to ~65,000 messages (~2 years at 100 msgs/day). Costs 2 extra bytes of airtime and creates a different wire format for groups vs peers.
  • Sender hash byte on wire — differentiates senders for key derivation at 1 byte cost, but leaks sender identity metadata (traffic correlation, identification via adverts) that is currently hidden inside the encrypted payload.

Decode Order

Adaptive per-peer: for peers with CONTACT_FLAG_AEAD set, try AEAD-4 first then ECB fallback. For unknown/legacy peers, try ECB first then AEAD-4 fallback. This avoids the 1/65536 ECB false-positive rate on AEAD packets (nonce bytes matching truncated HMAC) for known AEAD peers, while minimizing wasted CPU for legacy peers.

Capability Advertisement

  • feat1 bit 0 (FEAT1_AEAD_SUPPORT) is set in adverts for all node types (chat, repeater, room, sensor)
  • Receivers record peer capability in ContactInfo.flags bit 1 (CONTACT_FLAG_AEAD)
  • Old nodes parse feat1 but ignore the value (forward-compatible via existing AdvertDataParser)

Files Changed

  • src/MeshCore.h — AEAD constants (AEAD_TAG_SIZE, AEAD_NONCE_SIZE, CONTACT_FLAG_AEAD, FEAT1_AEAD_SUPPORT)
  • src/Utils.h / src/Utils.cppaeadEncrypt() and aeadDecrypt() using ChaChaPoly
  • src/Mesh.hgetPeerFlags(), getPeerNextAeadNonce() virtuals; aead_nonce param on createDatagram/createPathReturn
  • src/Mesh.cpp — AEAD send path in createDatagram/createPathReturn; adaptive try-both decode order per peer
  • src/helpers/ContactInfo.huint16_t aead_nonce field, nextAeadNonce() helper
  • src/helpers/BaseChatMesh.h / BaseChatMesh.cpp — Advertise AEAD, track peer capability, seed nonce, AEAD send for all peer message types
  • src/helpers/CommonCLI.cpp — Advertise AEAD for repeaters/rooms/sensors

Build Verification

  • ESP32 (Heltec_v3_companion_radio_ble): builds successfully
  • NRF52 (Xiao_nrf52_companion_radio_ble): builds successfully

Future Work

  • Group messages: send AEAD-4 (all updated nodes can already decode it)
  • Repeater/room server/sensor firmware: add AEAD send support (currently decode-only, examples/ callers still use ECB)
  • ANON_REQ: remain ECB (no prior capability exchange possible)

@weebl2000 weebl2000 changed the base branch from main to dev February 12, 2026 00:08
@weebl2000 weebl2000 force-pushed the feature/aead-4-encryption branch from 06320d0 to 7f3da6a Compare February 12, 2026 00:19
Add ChaCha20-Poly1305 AEAD decryption with 4-byte auth tag for peer
messages and group channels, falling back to ECB for backward
compatibility. Sending remains ECB-only in this phase.

- Per-message key derivation: HMAC-SHA256(secret, nonce||dest||src)
- Direction-dependent keys prevent bidirectional keystream reuse
- 12-byte IV from nonce + dest_hash + src_hash
- Advertise AEAD capability via feat1 bit 0 in adverts
- Track peer AEAD support in ContactInfo.flags
- Seed aead_nonce from HW RNG on contact creation and load
@weebl2000 weebl2000 force-pushed the feature/aead-4-encryption branch from 7f3da6a to 26bdb41 Compare February 12, 2026 00:20
Send ChaChaPoly-encrypted messages to peers with CONTACT_FLAG_AEAD set,
and try AEAD decode first for those peers (avoiding 1/65536 ECB
false-positive). Legacy peers continue to use ECB in both directions.

- Add aead_nonce parameter to createDatagram/createPathReturn (default 0 = ECB)
- Add getPeerFlags/getPeerNextAeadNonce virtual methods for decode-order selection
- Add ContactInfo::nextAeadNonce() helper (returns nonce++ if AEAD, 0 otherwise)
- Update all BaseChatMesh send paths to pass nonce for AEAD-capable peers
- Adaptive decode order: AEAD-first for known AEAD peers, ECB-first for others
@weebl2000 weebl2000 force-pushed the feature/aead-4-encryption branch from eee6fd5 to 6526793 Compare February 12, 2026 01:04
The header's route type bits (PH_ROUTE_MASK) are zero when
createDatagram/createPathReturn encrypt with AEAD, but get changed to
ROUTE_TYPE_FLOOD (1) or ROUTE_TYPE_DIRECT (2) by sendFlood/sendDirect
afterwards. The receiver builds assoc from the received header (with
route bits set), so the tag check always fails and every AEAD packet
is silently dropped.

Mask out route type bits in assoc data on all 5 encrypt/decrypt sites.
Also track AEAD decode success to enable peer capability auto-detection.
@weebl2000 weebl2000 force-pushed the feature/aead-4-encryption branch from 881d18d to 7637e64 Compare February 12, 2026 01:19
@jimdigriz
Copy link

jimdigriz commented Feb 12, 2026

Per-message key derivation (eliminates nonce-reuse catastrophe):

msg_key[32] = HMAC-SHA256(shared_secret, nonce || dest_hash || src_hash)

I do not understand how this prevents nonce re-use. After 65k messages from A->B the nonce looks like it will be reused.

I do not understand why concatenation with src/dst would change this.

The concatenation means you are partitioning the nonce value per (uni-directional) flow, in effect running different counters for A->B, B->A and C->A. Right?

Nonce management: 16-bit counter per peer, seeded from hardware RNG on boot and on contact load. Not persisted to flash — always fresh on each boot cycle.

What happens for devices without access to a good early boot entropy source?

What if two different reboots generate the same nonce?

What happens for A->B if:

  • reboot initialises nonce=20
  • 3 messages are sent from A->B
  • reboot initialises nonce=15
  • 10 messages are sent from A->B

What does this method improve over a plain incremental counter?

Why not persist the nonce once every 100 messages, and on reboot increment by 200 (rounded down to nearest 100)? When the nonce wraps, regenerate the key.

@weebl2000
Copy link
Contributor Author

Yeah, it doesn't stop nonce re-use. I think in the end we might need more bytes for nonces.

@jimdigriz
Copy link

jimdigriz commented Feb 12, 2026

But in the end maybe we need more bytes for nonces.

You do not, you can also change the key.

Just negotiate a dedicated key for this. It is a lot easier to understand and make safe.

It would require a round trip but then only need to be done every 65k messages; you could then also share that key for both directions (ie. A->B and B->A).

Then when nonce=0 negotiate a new key, which allows you to pick if you want to persist the nonce or reset to zero on boot.

@weebl2000
Copy link
Contributor Author

weebl2000 commented Feb 12, 2026

It would require a round trip but then only need to be done every 65k messages; you could then also share that key for both directions (ie. A->B and B->A).

Might be a good option. But the protocol will become a bit more complex and brittle. Then again, we can always fallback to ECB if nothing was negotiated.

Copy link

@jcjones jcjones left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a casual review, but I like the design, and the directionality of the KDF. Good doc comments, too.

bool valid = cipher.checkTag(src + AEAD_NONCE_SIZE + ct_len, AEAD_TAG_SIZE);
cipher.clear();
memset(msg_key, 0, 32);
if (!valid) memset(dest, 0, ct_len);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ECB path doesn't do this. Maybe it should?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need it. ECB path currently:

  1. computes HMAC
  2. compares it.
  3. if it's a match -> decrypt is called which writes to dest. On mismatch dest isn't touched, return 0 immediately.

AEAD path is decrypt-then-verify, we write plaintext to dest, then check the tag. On failure we zero dest because it contains unauthenticated plaintext.

(I'm not a die-hard crypto person please correct me if this doesn't make sense)

Copy link
Contributor Author

@weebl2000 weebl2000 Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added small comment to the code to clarify.
image

@weebl2000
Copy link
Contributor Author

weebl2000 commented Feb 12, 2026

Thanks for all the comments so far. I will look into them. Just tested this branch with a Heltec v4 repeater and Heltec v4 companion client, and I can confirm communicating between them works using AEAD-4.

It's a request for status from the repeater and the repeater response is understood correctly by the client.

AEAD-4 Packet Decode Verification

Wire Format

[header:1] [path_len:1] [path:N] [dest_hash:1] [src_hash:1] [nonce:2] [ciphertext:M] [tag:4]

Sent Packet — REQ (23 bytes)

Raw: 0200DD130E659F0B0C02D86AC2508DF6B7B3B671F6638A

Field Hex Value
Header 02 Route=DIRECT(2), Type=REQ(0), Ver=0
Path length 00 0 (no path)
dest_hash DD Destination peer
src_hash 13 Source
AEAD nonce 0E 65 3685
Ciphertext 9F 0B 0C 02 D8 6A C2 50 8D F6 B7 B3 B6 13 bytes plaintext
Tag 71 F6 63 8A Poly1305 (truncated to 4 bytes)

Format confirmed AEAD-4: 17 bytes after hashes is not a multiple of 16, ruling out legacy ECB.

Received Packet — RESPONSE (70 bytes)

Raw: 060013DD830B84757DB841545969BA39A62BDD0D6AD9E2CD70B25208219F964F51E8AFB0E800130BBAFC23C9C0712B7E28CE72DE17508E30A3359222A2A7DD4B2375E5AE33AC

Field Hex Value
Header 06 Route=DIRECT(2), Type=RESPONSE(1), Ver=0
Path length 00 0 (no path)
dest_hash 13 Receiver
src_hash DD Responding peer
AEAD nonce 83 0B 33547
Ciphertext 84 75 ... 4B 23 75 60 bytes plaintext
Tag E5 AE 33 AC Poly1305 (truncated to 4 bytes)

Note: legacy ECB is structurally possible here (64 bytes is a multiple of 16), but context confirms AEAD-4.

Associated Data

Per the route-mask fix, assoc data masks out route type bits:

Packet assoc bytes
REQ {0x00, 0xDD, 0x13}(0x02 & ~0x03)=0x00, dest, src
RESPONSE {0x04, 0x13, 0xDD}(0x06 & ~0x03)=0x04, dest, src

Observations

  • Both packets use AEAD-4 wire format: [nonce:2] [ciphertext:N] [tag:4]
  • dest/src hashes (0xDD, 0x13) correctly swapped between REQ and RESPONSE
  • Both routed DIRECT with empty path (single hop, no relaying)
  • Nonce values (3685, 33547) are non-zero, consistent with independent per-peer counters seeded from HW RNG

- Fix potential unsigned overflow in createDatagram size check by
subtracting constants from MAX_PACKET_PAYLOAD instead of adding to
data_len
- Add upper-bound validation on src_len and assoc_len in aeadEncrypt and
aeadDecrypt
- Log peer name on AEAD nonce wraparound for debug builds
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants