Skip to content

Add AEAD (AES-CCM) authenticated encryption for PSK channels#9749

Open
matutetandil wants to merge 6 commits intomeshtastic:developfrom
matutetandil:feature/aead-psk-channels
Open

Add AEAD (AES-CCM) authenticated encryption for PSK channels#9749
matutetandil wants to merge 6 commits intomeshtastic:developfrom
matutetandil:feature/aead-psk-channels

Conversation

@matutetandil
Copy link

Summary

  • Add optional AES-CCM authenticated encryption for PSK channel traffic (use_aead flag in ChannelSettings)
  • When enabled, messages include a 12-byte authentication tag preventing forgery, bit-flipping, and injection attacks
  • Fix portduino bug where -s flag prevented config YAML from being loaded

Addresses #4030. Design validated by @pqcfox (applied cryptographer).

Changes

AEAD encryption (commit 1)

CryptoEngine (CryptoEngine.h/.cpp):

  • Add encryptPacketCCM() / decryptPacketCCM() with 12-byte auth tag
  • Key promotion: 16-byte PSK keys zero-padded to 32 bytes (AESSmall256 compatibility)
  • Move AES-CCM primitives (aes-ccm.h/.cpp, aesSetKey, aesEncrypt) outside #if !MESHTASTIC_EXCLUDE_PKI guard

Channels (Channels.h/.cpp):

  • Add isAEADEnabled(chIndex) helper
  • Make getKey() public (needed by Router for CCM path)
  • Mix 0xAE into channel hash for AEAD channels (so AEAD and non-AEAD channels with same PSK have different routing hashes)

Router (Router.cpp):

  • Add AEAD encrypt/decrypt branches in perhapsEncode() and perhapsDecode()
  • No CTR fallback on AEAD channels — authentication failure rejects the packet
  • Size check accounts for MESHTASTIC_AEAD_OVERHEAD (12 bytes)

RadioInterface (RadioInterface.h):

  • Add MESHTASTIC_AEAD_OVERHEAD = 12 constant

Protobuf (channel.pb.h):

Portduino fix (commit 2)

PortduinoGlue (PortduinoGlue.cpp):

  • The -s (simradio) flag was the first branch in an if/else-if chain that also handled config file loading (-c). Using both flags together (meshtasticd -s -c config.yaml) caused the YAML to never be parsed, silently ignoring EnableUDP, DisplayMode, StatusMessage, and all other Config: section settings.
  • Fix: load config YAML independently of the simradio flag, then apply -s override afterwards.

Test plan

  • Unit tests: 6/6 pass (10 AEAD sub-tests: AES-128/256 round-trip, tampered ciphertext, tampered tag, tampered tag sweep, wrong PSK, wrong sender, packet too small, deterministic output)
  • pio run -e native builds successfully
  • Simulator starts and responds to API queries (info, nodes, channel list, send)
  • Multi-node simulator: 2 AEAD nodes discover each other via UDP multicast and exchange messages using default CTR encryption
  • Backward compatibility: AEAD firmware with use_aead=false (default) behaves identically to existing CTR path
  • End-to-end AEAD path test (requires protobuf PR merge + client support to set use_aead=true)
  • Hardware test on ESP32/nRF52

What this does NOT change

  • PKI encryption (unchanged — still uses its own Curve25519 + CCM path)
  • Default channel behavior (use_aead defaults to false — existing AES-CTR)
  • Packet header format (no changes)
  • Channel URL/QR sharing (use_aead serializes automatically via ChannelSet)

Extend PSK channel encryption with optional AES-CCM authenticated
encryption (use_aead flag in ChannelSettings). When enabled, messages
include a 12-byte authentication tag that prevents forgery, bit-flipping,
and injection attacks by anyone with the channel PSK.

Changes:
- Add encryptPacketCCM/decryptPacketCCM to CryptoEngine with key
  promotion (16-byte keys zero-padded to 32 for AESSmall256 compat)
- Move AES-CCM primitives (aes-ccm.h/cpp, aesSetKey, aesEncrypt)
  outside PKI guard so they're available unconditionally
- Add isAEADEnabled() to Channels with hash differentiation (XOR 0xAE)
- Add AEAD encrypt/decrypt branches in Router perhapsEncode/perhapsDecode
  with no CTR fallback on AEAD channels
- Add use_aead field to channel.pb.h (bool, tag 8)
- Add MESHTASTIC_AEAD_OVERHEAD constant to RadioInterface.h
- Add comprehensive test suite: round-trip (AES-128/256), tamper
  detection (ciphertext, tag, sweep), wrong PSK, wrong sender,
  packet-too-small, deterministic output verification

Addresses firmware#4030.
The simradio flag (-s) was the first branch in an if/else-if chain
that also handled config file loading (-c). When both flags were used
together (meshtasticd -s -c config.yaml), the config YAML was never
parsed because -s short-circuited into the first branch.

This meant YAML settings like EnableUDP, DisplayMode, StatusMessage,
and all other Config section options were silently ignored in sim mode.

Fix: load the config YAML independently of the simradio flag, then
apply the -s override afterwards. This preserves all non-radio settings
from the YAML while still forcing simradio mode as intended.
@CLAassistant
Copy link

CLAassistant commented Feb 25, 2026

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions bot added the enhancement New feature or request label Feb 25, 2026
@github-actions
Copy link
Contributor

@matutetandil, Welcome to Meshtastic!

Thanks for opening your first pull request. We really appreciate it.

We discuss work as a team in discord, please join us in the #firmware channel.
There's a big backlog of patches at the moment. If you have time,
please help us with some code review and testing of other PRs!

Welcome to the team 😄

aes_ccm_encr() wrote a full 16-byte AES block directly to the output
buffer before XOR-ing with input, even when the last block was smaller
than AES_BLOCK_SIZE. This caused a stack buffer overflow when the
plaintext/ciphertext buffer was exactly sized to the data length
(detected by AddressSanitizer in the coverage test environment).

Use a temporary buffer for the AES block output on the last partial
block, matching the pattern already used in aes_ccm_encr_auth() and
aes_ccm_decr_auth().
@robekl
Copy link

robekl commented Feb 26, 2026

  1. AEAD path can crash on zero-length PSK (null dereference in crypto backend).

    • In AEAD encode/decode, the key is used without validating k.length > 0:
      • src/mesh/Router.cpp:678
      • src/mesh/Router.cpp:707
      • src/mesh/Router.cpp:484
    • encryptPacketCCM/decryptPacketCCM pass keyLen=0 when PSK is unset:
      • src/mesh/CryptoEngine.cpp:208
      • src/mesh/CryptoEngine.cpp:220
    • aes_ccm_* then calls aesSetKey(..., 0) and later aesEncrypt(...), which dereferences a null aes object:
      • src/mesh/aes-ccm.cpp:149
      • src/mesh/aes-ccm.cpp:62
    • Impact: misconfigured channel (use_aead=true + empty/disabled PSK) can cause runtime crash.
  2. AEAD encryption return value is ignored, so failures can be silently transmitted as if successful.

    • encryptPacketCCM(...) returns bool, but caller ignores it and still sets encrypted payload size/type:
      • src/mesh/Router.cpp:679
      • src/mesh/Router.cpp:708
      • src/mesh/Router.cpp:723
    • Impact: if AEAD encryption fails, packet state can become inconsistent/corrupt instead of returning a routing error.

- Add early return in encryptPacketCCM/decryptPacketCCM when
  psk.length == 0, preventing null dereference in aesSetKey
- Check encryptPacketCCM return value in Router::perhapsEncode
  (both PKI and non-PKI paths), returning BAD_REQUEST on failure
  instead of silently transmitting corrupt packets
- Add unit test for empty PSK (encrypt and decrypt must return
  false without crashing)
@matutetandil
Copy link
Author

@robekl Good catches, both fixed in 4cab9b3:

1. Empty PSK crash → guarded with early return

encryptPacketCCM and decryptPacketCCM now check psk.length == 0 up front and return false before touching aesSetKey. A misconfigured channel (use_aead=true + no PSK) will log an error and reject the packet instead of crashing.

2. Ignored return value → checked in both encrypt paths

perhapsEncode() now checks the bool returned by encryptPacketCCM in both the PKI-enabled and non-PKI build paths. On failure it logs the error and returns BAD_REQUEST, preventing corrupt packets from being transmitted.

Unit test added (test 11 in test_AES_CCM_AEAD): verifies that encrypt and decrypt with an empty PSK return false without crashing.

All 6 test cases (11 sub-tests) pass, pio run -e native builds clean.

@matutetandil
Copy link
Author

The 3 failed jobs (t-echo build, t-echo check, heltec-mesh-solar-eink build) are all transient HTTPClientError failures — PlatformIO couldn't download a dependency. Not related to our changes (14 other nrf52840 targets passed fine, including t-echo-plus, t-echo-inkhud, rak4631, etc.).

Could a maintainer re-run the failed jobs? We don't have admin access to trigger it. Thanks!

@fifieldt
Copy link
Member

fifieldt commented Feb 26, 2026

Optional, but removes ifdef MESHTASTIC_EXCLUDE_PKI guards?

@matutetandil
Copy link
Author

@fifieldt The removal is actually necessary, not optional. The new encryptPacketCCM/decryptPacketCCM functions live outside the #if !(MESHTASTIC_EXCLUDE_PKI) guard in CryptoEngine.cpp (lines 201-232), because AEAD for PSK channels is independent of PKI. They call aes_ccm_ae/aes_ccm_ad directly.

If we kept the #if !MESHTASTIC_EXCLUDE_PKI guard on aes-ccm.cpp/h, any build with MESHTASTIC_EXCLUDE_PKI=1 would fail to link — the AEAD functions would reference aes_ccm_ae/aes_ccm_ad but they'd be compiled out.

The existing PKI functions (encrypt/decrypt, lines 80-197) are still guarded by their own #if !(MESHTASTIC_EXCLUDE_PKI) block, so excluding PKI still removes those code paths as before. The only change is that the low-level AES-CCM primitives are now always available since both PKI and AEAD PSK need them.

@Jorropo
Copy link
Member

Jorropo commented Feb 27, 2026

We shouldn't be promoting 128 keys to 256 bits.
This us to two extra AES rounds which uses more energy for no real benefit.

I guess you did that since the existing CCM code is hardcoded to run aes256 since it is only ever used with DH which generate a 32bits secret.

I was working on a new AES implementation which would use the coprocessor rather software to save on flash space (and energy),
if you don't want to wait too long for me to finish that, the easiest for me would be if you made a new pair of aes128-ccm functions.

@Jorropo
Copy link
Member

Jorropo commented Feb 27, 2026

I am a bit unclear about:

Channel URL/QR sharing (use_aead serializes automatically via ChannelSet)

if I scan a QR code of an AEAD channel does it automatically enable the AEAD setting in the newly created channel ?
(we want it to do so, afait you are saying you didn't changed the code but ¿it works? since the QR code constaints a protobuf blob of the existing channel settings message where you added the aead flag).

@matutetandil
Copy link
Author

@Jorropo

On key promotion (128→256): Agreed, the zero-padding adds two extra AES rounds for no real security benefit since the entropy stays at 128 bits. Happy to create separate aes128-ccm functions.

Before I do — a couple of questions so we align with your coprocessor work:

  1. How far along is your hardware AES implementation? If it's close, it might make more sense to wait and avoid throwaway code.
  2. If we go ahead now, would you prefer standalone aes128_ccm_ae/aes128_ccm_ad functions that mirror the existing 256-bit ones but use AESSmall128? That way the CCM logic stays the same and you can swap the AES backend (software → coprocessor) without touching the CCM wrapper layer.

Either way the current functions only call aesSetKey + aesEncrypt, so the surface area for your migration should be small.

On QR codes: Yes, it works automatically. The QR/URL encodes the ChannelSet protobuf blob which now includes use_aead (field 8 in ChannelSettings). When a device scans/imports the URL, it deserializes the full ChannelSettings including the flag — no additional code needed on the import side.

@Jorropo
Copy link
Member

Jorropo commented Feb 27, 2026

How far along is your hardware AES implementation? If it's close, it might make more sense to wait and avoid throwaway code.

Its like 10% done ?


It has very little overlap (only src/mesh/CryptoEngine.*) with your PR.
Basically I have new (stateless) functions for AES CTR & CCM.

@Jorropo
Copy link
Member

Jorropo commented Feb 27, 2026

If we go ahead now, would you prefer standalone aes128_ccm_ae/aes128_ccm_ad functions that mirror the existing 256-bit ones but use AESSmall128? That way the CCM logic stays the same and you can swap the AES backend (software → coprocessor) without touching the CCM wrapper layer.

Swapping the backend would work on ESP32 where the coprocessor only implements the AES function.
On nRF52840 the coprocessor also implements chaining, so rather than encrypting / decrypting exactly 16 bytes using the AES function and using software chaining, you can encrypt / decrypt a whole message.

I havn't yet worked on the NRF52 beyond reading a bit of documention to know it would be worthwhile to implement.

@matutetandil
Copy link
Author

@Jorropo Good to know about the nRF52840 — that makes sense, full hardware CCM chaining is a different beast from just accelerating the block cipher.

I went with a polymorphic aesSetKey approach instead of separate functions:

  • aesSetKey now dispatches based on key_len: 16 bytes → AESSmall128, 32 bytes → AESSmall256
  • aes member type changed from std::unique_ptr<AESSmall256> to std::unique_ptr<BlockCipher>
  • Removed the key promotion in encryptPacketCCM/decryptPacketCCM — they now pass psk.length directly
  • No code duplication in aes-ccm.cpp — the CCM functions already pass key_len through to aesSetKey

This should work well for both hardware paths:

  • ESP32: override aesSetKey/aesEncrypt to dispatch to the hardware AES block cipher — the software CCM chaining stays as-is
  • nRF52840: override encryptPacketCCM/decryptPacketCCM entirely to use full hardware CCM — the block-level methods become irrelevant for that platform

New tests:

  • test_ECB_AES128 with NIST test vectors
  • Test 12 in AEAD: verifies AES-128 and AES-256 with the same 16 bytes of key material produce different ciphertexts, both round-trip correctly, and cross-key decryption fails

All 7 test cases (13 sub-tests) pass, pio run -e native builds clean. Pushing shortly.

aesSetKey now dispatches based on key length: 16 bytes creates
AESSmall128, 32 bytes creates AESSmall256. The aes member type
changes from AESSmall256 to BlockCipher (polymorphic base class).

This removes the unnecessary key promotion that added two extra
AES rounds (14 vs 12) with no security benefit since the entropy
stays at 128 bits for 16-byte keys.

encryptPacketCCM/decryptPacketCCM now pass psk.length directly
to aes_ccm_ae/aes_ccm_ad instead of promoting to 32.

New tests: ECB AES-128 with NIST vectors, AEAD test verifying
AES-128 and AES-256 produce different ciphertexts with same key
material and cross-key decryption fails.
@Jorropo
Copy link
Member

Jorropo commented Feb 27, 2026

I didn't realised the ccm file called into the crypto engine, this is really cursed code but unrelated to what you are doing now.

Anyway dynamic dispatch look good thank you, I'll take a look later.

@matutetandil
Copy link
Author

Same transient HTTPClientError on t-echo check — could someone re-run that job? Thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request first-contribution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants