Skip to content

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

Open
matutetandil wants to merge 8 commits into
meshtastic:developfrom
matutetandil:feature/aead-psk-channels
Open

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

Conversation

@matutetandil

@matutetandil matutetandil commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

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)

Summary by CodeRabbit

  • New Features

    • Added optional AES-CCM authenticated encryption for mesh channels.
    • Channel identifiers now distinguish AEAD-enabled channels from standard channels.
    • Added support for AES-128 and AES-256 keys.
    • Packets with invalid authentication or insufficient size are rejected instead of falling back to standard encryption.
  • Bug Fixes

    • Improved handling of partial encryption blocks and invalid cryptographic inputs.
  • Tests

    • Added coverage for encryption, decryption, authentication failures, nonce mismatches, key sizes, and known AES test vectors.

@CLAassistant

CLAassistant commented Feb 25, 2026

Copy link
Copy Markdown

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
Copy Markdown
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 😄

@robekl

robekl commented Feb 26, 2026

Copy link
Copy Markdown
  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.

@matutetandil

Copy link
Copy Markdown
Contributor 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
Copy Markdown
Contributor 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

fifieldt commented Feb 26, 2026

Copy link
Copy Markdown
Member

Optional, but removes ifdef MESHTASTIC_EXCLUDE_PKI guards?

@matutetandil

Copy link
Copy Markdown
Contributor 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

Jorropo commented Feb 27, 2026

Copy link
Copy Markdown
Member

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

Jorropo commented Feb 27, 2026

Copy link
Copy Markdown
Member

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
Copy Markdown
Contributor 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

Jorropo commented Feb 27, 2026

Copy link
Copy Markdown
Member

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

Jorropo commented Feb 27, 2026

Copy link
Copy Markdown
Member

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
Copy Markdown
Contributor 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.

@Jorropo

Jorropo commented Feb 27, 2026

Copy link
Copy Markdown
Member

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
Copy Markdown
Contributor Author

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

@github-actions github-actions Bot added the Stale Issues that will be closed if not triaged. label Apr 14, 2026
@github-actions github-actions Bot closed this Apr 21, 2026
@Jorropo Jorropo reopened this Jul 21, 2026
@github-actions github-actions Bot removed the Stale Issues that will be closed if not triaged. label Aug 1, 2026

@caveman99 caveman99 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@matutetandil Could you address the merge conflicts and also please don't check in generated files. These needs to come as a separate PR against the protobuf repo

@matutetandil
matutetandil force-pushed the feature/aead-psk-channels branch from aa66aba to e634ffe Compare August 3, 2026 15:30
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

AEAD-enabled channels now use AES-CCM with 12-byte authenticated overhead. Channel hashes distinguish AEAD settings. Non-AEAD channels retain AES-CTR processing. Crypto tests cover key sizes, authentication, nonce handling, bounds, and invalid inputs.

Changes

AEAD channel encryption

Layer / File(s) Summary
AES-CCM crypto contracts and implementation
src/mesh/CryptoEngine.*, src/mesh/aes-ccm.*
CryptoEngine adds CCM packet encryption and decryption. AES-128 and AES-256 selection follows key length. AES-CCM is available without PKI guards. Partial-block encryption uses bounded temporary storage.
Channel AEAD routing
src/mesh/Channels.*, src/mesh/RadioInterface.h, src/mesh/Router.cpp
Channels expose AEAD status and effective keys. Channel hashes include an AEAD marker. Router selects CCM for AEAD channels and retains AES-CTR for other channels.
AES-CCM validation coverage
test/test_crypto/test_main.cpp
Tests cover AES-128 vectors, CCM round trips, authentication failures, bounds, invalid inputs, nonce mismatches, deterministic output, and key-size behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Router
  participant Channels
  participant CryptoEngine
  Router->>Channels: Check isAEADEnabled and getKey
  Router->>CryptoEngine: encryptPacketCCM or decryptPacketCCM
  CryptoEngine-->>Router: Return authenticated ciphertext or decryption result
  Router-->>Router: Add or remove AEAD overhead
Loading

Possibly related PRs

Suggested labels: bugfix

Suggested reviewers: jp-bennett, jorropo, guvwaf

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the primary change: adding AES-CCM authenticated encryption for PSK channels.
Description check ✅ Passed The description explains the implementation, scope, issue addressed, test results, compatibility, and remaining hardware and end-to-end testing limits.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@matutetandil

Copy link
Copy Markdown
Contributor Author

@caveman99 Thanks — both addressed, and I split the PR up while I was at it.

Rebased onto latest develop. Three conflicts, all resolved: the new XEdDSA tests in test_crypto (kept alongside the AEAD one), the LOG_ERRORLOG_DEBUG change in Router.cpp, and the configCheck/WASM restructure in PortduinoGlue.cpp. test_crypto passes 13/13 on both native and coverage (ASan), and clang-format is clean against .trunk/configs/.clang-format.

Scope. This PR had picked up two unrelated bug fixes along the way. Both are now standalone and reviewable on their own:

#11348 is gone from this branch entirely. #11347 is still here, because the AEAD tests are what trip the overflow in the first place, so dropping it would put coverage red on this PR — it will disappear on rebase the moment #11347 lands. This PR is now just src/mesh/ crypto changes plus tests.

On the generated file — agreed, and I want it gone too. The reason channel.pb.h is hand-edited here is that use_aead does not exist upstream yet: the protobuf change is meshtastic/protobufs#868, opened Feb 25 and still awaiting review. Without the field this branch does not compile, so I generated the header by hand to keep it testable. ChannelSettings field 8 is still free upstream, so there is no collision.

Could someone take a look at #868? Once it merges and update_protobufs.yml runs, I will rebase and drop the hand-edited header, leaving only the src/mesh/ changes. Happy to strip it out right now instead if you would rather review this knowing it will not build until the protobuf lands — just say which you prefer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
test/test_crypto/test_main.cpp (1)

352-378: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a fixed expected ciphertext and tag to Test 1.

The comment names this a known-answer test, but the assertions only check that the ciphertext differs from the plaintext and that the tag is not all zeros. Those checks pass for almost any implementation change, including a wrong nonce layout or a wrong tag length. Test 9 already covers determinism.

Capture the current output once, then assert it as a constant vector. That pins the nonce construction and the tag position, so a future change to initNonce or to the output layout fails here.

♻️ Suggested shape for the assertion
         TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, packetId, 10, plaintext, ciphertextWithTag));
 
-        // Ciphertext should differ from plaintext
-        TEST_ASSERT_FALSE(memcmp(plaintext, ciphertextWithTag, 10) == 0);
-
-        // Tag bytes (last 12) should not all be zero
-        bool tagAllZero = true;
-        for (size_t i = 0; i < CryptoEngine::AEAD_TAG_SIZE; i++) {
-            if (ciphertextWithTag[10 + i] != 0) {
-                tagAllZero = false;
-                break;
-            }
-        }
-        TEST_ASSERT_FALSE(tagAllZero);
+        // Pins nonce layout and tag placement; regenerate only on an intentional format change
+        uint8_t expected[10 + CryptoEngine::AEAD_TAG_SIZE] = {0};
+        HexToBytes(expected, "<capture 22 bytes of ciphertext||tag here>");
+        TEST_ASSERT_EQUAL_MEMORY(expected, ciphertextWithTag, sizeof(expected));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_crypto/test_main.cpp` around lines 352 - 378, Replace the weak
ciphertext-difference and nonzero-tag checks in Test 1 with a fixed expected
ciphertext-and-tag byte vector captured from the current correct output, then
assert the complete encryptPacketCCM result matches it byte-for-byte. Keep the
existing plaintext, key, node, packetId, and length inputs unchanged so the
assertion covers nonce construction and output/tag placement.
src/mesh/RadioInterface.h (1)

23-23: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Single-source the AEAD overhead constant.

MESHTASTIC_AEAD_OVERHEAD duplicates CryptoEngine::AEAD_TAG_SIZE (both equal 12). Router.cpp uses this macro for packet-size boundary checks, while the actual tag length written during encryption/decryption comes from AEAD_TAG_SIZE. If one value changes without the other, size checks in Router.cpp would no longer match the real tag length, causing oversized packets or truncated payloads.

Reference CryptoEngine::AEAD_TAG_SIZE from Router.cpp instead of keeping a second constant here, or derive this macro from it.

♻️ Proposed direction
-#define MESHTASTIC_AEAD_OVERHEAD 12
+#define MESHTASTIC_AEAD_OVERHEAD CryptoEngine::AEAD_TAG_SIZE

(Requires RadioInterface.h to see CryptoEngine's declaration, or move the size checks to use crypto/CryptoEngine::AEAD_TAG_SIZE directly at the call sites in Router.cpp.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mesh/RadioInterface.h` at line 23, Remove the duplicated
MESHTASTIC_AEAD_OVERHEAD definition in RadioInterface.h and update Router.cpp
size-boundary checks to use CryptoEngine::AEAD_TAG_SIZE directly, ensuring
packet limits remain synchronized with the encryption/decryption tag length.
src/mesh/Router.cpp (1)

1204-1261: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the duplicated AEAD/CTR encode branch into a shared helper.

The AEAD/CTR channel-encryption logic (size check, setActiveByIndex, key lookup, encryptPacketCCM/encryptPacket, overhead accounting) is duplicated between the PKI-enabled else branch and the entire #else (PKI-excluded) branch. Any future fix to this logic, such as a boundary-check correction, must be applied in both places or the branches will diverge in security-relevant code.

Extract this block into a private helper function called from both branches.

static meshtastic_Routing_Error encryptChannelPacket(ChannelIndex chIndex, uint32_t fromNode, uint64_t packetId,
                                                     size_t &numbytes, uint8_t *bytes, meshtastic_MeshPacket *p)
{
    if (channels.isAEADEnabled(chIndex)) {
        if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_AEAD_OVERHEAD > MAX_LORA_PAYLOAD_LEN)
            return meshtastic_Routing_Error_TOO_LARGE;
        int16_t hash = channels.setActiveByIndex(chIndex);
        p->channel = hash;
        if (hash < 0)
            return meshtastic_Routing_Error_NO_CHANNEL;
        CryptoKey k = channels.getKey(chIndex);
        if (!crypto->encryptPacketCCM(k, fromNode, packetId, numbytes, bytes, p->encrypted.bytes)) {
            LOG_ERROR("AEAD encryption failed for ch 0x%x", chIndex);
            return meshtastic_Routing_Error_BAD_REQUEST;
        }
        numbytes += MESHTASTIC_AEAD_OVERHEAD;
    } else {
        int16_t hash = channels.setActiveByIndex(chIndex);
        p->channel = hash;
        if (hash < 0)
            return meshtastic_Routing_Error_NO_CHANNEL;
        crypto->encryptPacket(fromNode, packetId, numbytes, bytes);
        memcpy(p->encrypted.bytes, bytes, numbytes);
    }
    return meshtastic_Routing_Error_NONE;
}

Call return encryptChannelPacket(chIndex, getFrom(p), p->id, numbytes, bytes, p); from both the PKI-enabled else branch and the #else branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mesh/Router.cpp` around lines 1204 - 1261, Extract the duplicated
AEAD/CTR channel-encryption logic into a private helper named
encryptChannelPacket, preserving size validation, channel activation, key
lookup, encryption, overhead accounting, and existing error returns. Replace
both the PKI-enabled else branch and the PKI-excluded `#else` branch with calls to
encryptChannelPacket(chIndex, getFrom(p), p->id, numbytes, bytes, p), while
keeping the PKI-specific checks outside the helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/mesh/CryptoEngine.cpp`:
- Around line 378-406: Update the PSK validation in
CryptoEngine::encryptPacketCCM and CryptoEngine::decryptPacketCCM to reject both
zero and the documented -1 invalid-key sentinel by checking psk.length <= 0
before calling AES-CCM. Adjust both error messages to describe an invalid or
missing PSK rather than only an empty one, while preserving the existing failure
returns.

In `@src/mesh/Router.cpp`:
- Around line 932-937: Update the AEAD authentication warning and both AEAD
encryption error logs around decrypt/encrypt handling to format the one-byte
chIndex with 0x%x instead of %d. Preserve the existing messages and arguments,
changing only the channel-index format specifier in the identified LOG_WARN and
LOG_ERROR calls.

---

Nitpick comments:
In `@src/mesh/RadioInterface.h`:
- Line 23: Remove the duplicated MESHTASTIC_AEAD_OVERHEAD definition in
RadioInterface.h and update Router.cpp size-boundary checks to use
CryptoEngine::AEAD_TAG_SIZE directly, ensuring packet limits remain synchronized
with the encryption/decryption tag length.

In `@src/mesh/Router.cpp`:
- Around line 1204-1261: Extract the duplicated AEAD/CTR channel-encryption
logic into a private helper named encryptChannelPacket, preserving size
validation, channel activation, key lookup, encryption, overhead accounting, and
existing error returns. Replace both the PKI-enabled else branch and the
PKI-excluded `#else` branch with calls to encryptChannelPacket(chIndex,
getFrom(p), p->id, numbytes, bytes, p), while keeping the PKI-specific checks
outside the helper.

In `@test/test_crypto/test_main.cpp`:
- Around line 352-378: Replace the weak ciphertext-difference and nonzero-tag
checks in Test 1 with a fixed expected ciphertext-and-tag byte vector captured
from the current correct output, then assert the complete encryptPacketCCM
result matches it byte-for-byte. Keep the existing plaintext, key, node,
packetId, and length inputs unchanged so the assertion covers nonce construction
and output/tag placement.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 49d6c377-2a3b-4f5e-b6ba-f5275d6febd8

📥 Commits

Reviewing files that changed from the base of the PR and between 9e60b23 and e634ffe.

⛔ Files ignored due to path filters (1)
  • src/mesh/generated/meshtastic/channel.pb.h is excluded by !**/generated/**, !src/mesh/generated/**
📒 Files selected for processing (9)
  • src/mesh/Channels.cpp
  • src/mesh/Channels.h
  • src/mesh/CryptoEngine.cpp
  • src/mesh/CryptoEngine.h
  • src/mesh/RadioInterface.h
  • src/mesh/Router.cpp
  • src/mesh/aes-ccm.cpp
  • src/mesh/aes-ccm.h
  • test/test_crypto/test_main.cpp

Comment thread src/mesh/CryptoEngine.cpp
Comment thread src/mesh/Router.cpp
Comment on lines +932 to +937
LOG_ERROR("Packet too small for AEAD (size=%d)", rawSize);
continue;
}
CryptoKey k = channels.getKey(chIndex);
if (!crypto->decryptPacketCCM(k, p->from, p->id, rawSize, p->encrypted.bytes, bytes)) {
LOG_WARN("AEAD authentication failed for ch %d", chIndex);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use hex formatting for the one-byte channel index in log messages.

LOG_WARN("AEAD authentication failed for ch %d", chIndex); and the two LOG_ERROR("AEAD encryption failed for ch %d", chIndex); calls format chIndex, a one-byte value, with %d. The coding guideline requires one-byte values, flags, and reason codes to use 0x%x.

📝 Proposed fix
-                        LOG_WARN("AEAD authentication failed for ch %d", chIndex);
+                        LOG_WARN("AEAD authentication failed for ch 0x%x", chIndex);
-                LOG_ERROR("AEAD encryption failed for ch %d", chIndex);
+                LOG_ERROR("AEAD encryption failed for ch 0x%x", chIndex);

(apply to both LOG_ERROR occurrences)

Based on coding guidelines: "format one-byte values, flags, and reason codes as 0x%x instead."

Also applies to: 1216-1216, 1248-1248

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mesh/Router.cpp` around lines 932 - 937, Update the AEAD authentication
warning and both AEAD encryption error logs around decrypt/encrypt handling to
format the one-byte chIndex with 0x%x instead of %d. Preserve the existing
messages and arguments, changing only the channel-index format specifier in the
identified LOG_WARN and LOG_ERROR calls.

Source: Coding guidelines

@matutetandil

Copy link
Copy Markdown
Contributor Author

Thanks @coderabbitai — one of the two is a real gap, the other I am going to push back on.

Invalid-key sentinel — fixed in 40ec148.

You are right that == 0 was the wrong test. CryptoKey::length is int8_t and documents -1 as "invalid key - do not use", while the aes_ccm_* key length parameter is size_t, so -1 would have widened to SIZE_MAX and been passed straight through rather than rejected. AESSmall256::setKey() would then reject that length and leave the cipher with no key set, and aes_ccm_ae still reports success — so it would have encrypted with an unkeyed cipher and returned true, which is the worst possible shape for this bug.

For the record on reachability: it cannot happen today. Both call sites in Router.cpp are gated on a non-negative channel hash (setActiveByIndex on encrypt, decryptForHash on decrypt), and Channels::generateHash() returns -1 exactly when getKey() yields length < 0. So the sentinel is filtered out one layer up on both paths. Guarding here anyway — the function should not depend on callers maintaining that invariant, and it is a one-character check.

Both guards are now psk.length <= 0, the messages say "valid, non-empty PSK", and test_AES_CCM_AEAD asserts that both directions refuse length = -1. 13/13 on native and coverage (ASan).

0x%x for chIndex — not applying this one.

The guideline you cited says the opposite of what the comment claims. .github/copilot-instructions.md line 335 reads:

Do not zero-pad one-byte values to 8.channel is a one-byte hash/index - log these as 0x%x (or %d).

The rule is about not padding a byte out to 0x%08x and falsely implying a node number. %d is explicitly permitted, and the review comment dropped the "(or %d)" when quoting it. The surrounding code agrees — Channels.cpp:532 logs LOG_DEBUG("Use channel %d (hash 0x%x)", chIndex, channelHash), using %d for the index and hex for the hash, which is exactly what these AEAD log lines already do.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@matutetandil Thanks for the detailed verification.

The psk.length <= 0 guard correctly rejects both empty PSKs and the -1 invalid-key sentinel before conversion to size_t. The new negative-length tests cover the boundary correctly.

You are also correct about chIndex formatting. %d is permitted for a channel index, and the existing Channels.cpp log uses the same convention. I withdraw that finding.


✏️ Learnings added
Learnt from: matutetandil
URL: https://github.com/meshtastic/firmware/pull/9749

Timestamp: 2026-08-03T15:55:42.205Z
Learning: In the Meshtastic firmware repository, `.github/copilot-instructions.md` permits either `%d` or `0x%x` for one-byte channel hashes and channel indexes. Do not use eight-digit zero padding such as `0x%08x`, because it can make a one-byte value look like a node number.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
test/test_crypto/test_main.cpp (2)

526-533: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test every AES-256 ciphertext byte.

The loop checks only indices 0 through 3. A tamper-handling defect in bytes 4 through 31 can pass unnoticed.

Proposed fix
-        for (int i = 0; i < 4; i++)
+        for (size_t i = 0; i < sizeof(plaintext); i++)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_crypto/test_main.cpp` around lines 526 - 533, Update the tampering
loop in the decryptPacketCCM test to iterate across all 32 ciphertext bytes,
while keeping the AEAD tag copied intact and preserving the existing rejection
assertion for each modified byte.

364-377: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Compare the complete AES-CCM output with an independent vector.

The assertions at Line 364 and Lines 366-377 do not validate a known answer. An incorrect nonce derivation, tag computation, or output layout can still produce a different ciphertext and a nonzero tag. Add independently generated expected ciphertext and tag bytes, then compare the full ciphertextWithTag buffer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_crypto/test_main.cpp` around lines 364 - 377, Update the AES-CCM
test around encryptPacketCCM to use an independently generated known-answer
vector for the existing psk, fromNode, packetId, plaintext, and length inputs.
Replace the difference and nonzero-tag assertions with a comparison of the
complete ciphertextWithTag buffer, including ciphertext and
CryptoEngine::AEAD_TAG_SIZE tag bytes, against the expected bytes.
🧹 Nitpick comments (2)
test/test_crypto/test_main.cpp (2)

347-347: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use camelCase for the new AES-CCM test.

Rename test_AES_CCM_AEAD to testAesCcmAead and update the RUN_TEST registration at Line 672.

As per coding guidelines, functions and members use camelCase.

Also applies to: 672-672

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_crypto/test_main.cpp` at line 347, Rename the test function
test_AES_CCM_AEAD to testAesCcmAead and update its RUN_TEST registration to use
the new camelCase name.

Source: Coding guidelines


349-351: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Shorten the repeated test banners.

The three-line banner repeats the test title and adds no non-obvious rationale. Use one concise comment or remove the banner. Apply the same change to the repeated banners in test_AES_CCM_AEAD.

As per coding guidelines, comments must be minimal, normally one or two lines, and must document only non-obvious rationale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_crypto/test_main.cpp` around lines 349 - 351, Shorten the
three-line test banners around the AES-CCM cases in test_AES_CCM_AEAD, including
the banner beginning with “Test 1: Known-answer AES-CCM encrypt + verify tag,”
to a single concise comment or remove them entirely. Preserve the test logic and
retain comments only where they document non-obvious rationale.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@test/test_crypto/test_main.cpp`:
- Around line 526-533: Update the tampering loop in the decryptPacketCCM test to
iterate across all 32 ciphertext bytes, while keeping the AEAD tag copied intact
and preserving the existing rejection assertion for each modified byte.
- Around line 364-377: Update the AES-CCM test around encryptPacketCCM to use an
independently generated known-answer vector for the existing psk, fromNode,
packetId, plaintext, and length inputs. Replace the difference and nonzero-tag
assertions with a comparison of the complete ciphertextWithTag buffer, including
ciphertext and CryptoEngine::AEAD_TAG_SIZE tag bytes, against the expected
bytes.

---

Nitpick comments:
In `@test/test_crypto/test_main.cpp`:
- Line 347: Rename the test function test_AES_CCM_AEAD to testAesCcmAead and
update its RUN_TEST registration to use the new camelCase name.
- Around line 349-351: Shorten the three-line test banners around the AES-CCM
cases in test_AES_CCM_AEAD, including the banner beginning with “Test 1:
Known-answer AES-CCM encrypt + verify tag,” to a single concise comment or
remove them entirely. Preserve the test logic and retain comments only where
they document non-obvious rationale.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f38e47a1-ce5c-415b-9c7c-b53c24b4746f

📥 Commits

Reviewing files that changed from the base of the PR and between e634ffe and 40ec148.

📒 Files selected for processing (2)
  • src/mesh/CryptoEngine.cpp
  • test/test_crypto/test_main.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/mesh/CryptoEngine.cpp

@matutetandil

Copy link
Copy Markdown
Contributor Author

On the three nitpicks — one applied, two I want to push back on.

Single-source the AEAD overhead constant — applied in 753adfe.

Good catch. MESHTASTIC_AEAD_OVERHEAD is what the size checks budget for, AEAD_TAG_SIZE is what actually gets written, and nothing tied them together.

I did not take the suggested #define MESHTASTIC_AEAD_OVERHEAD CryptoEngine::AEAD_TAG_SIZE though — RadioInterface.h is a low-level header included very widely, and making it depend on CryptoEngine to resolve a packet-size constant is a heavier coupling than the problem warrants. It would also break the symmetry with MESHTASTIC_PKC_OVERHEAD sitting directly above it. Instead there is now a static_assert in Router.cpp, which already includes both headers:

static_assert(MESHTASTIC_AEAD_OVERHEAD == CryptoEngine::AEAD_TAG_SIZE,
              "MESHTASTIC_AEAD_OVERHEAD must match CryptoEngine::AEAD_TAG_SIZE");

Divergence is now a compile error at the exact place the two meet, with no new header dependency. Verified it actually compiles (Router.cpp.o builds under pio run -e native).

Extract the duplicated AEAD/CTR encode branch — not in this PR.

The duplication is pre-existing. On develop, perhapsEncode already calls setActiveByIndex + encryptPacket twice, once inside #if !(MESHTASTIC_EXCLUDE_PKI) and once in the #else (Router.cpp:1187/1195 and 1203/1211). This PR adds the AEAD branch symmetrically to both, which keeps the diff minimal and reviewable.

Factoring both branches into a shared helper means restructuring the non-AEAD path as well, in a security-relevant function, inside a PR that is already waiting on a protobuf change. That is a worthwhile cleanup but it belongs in its own PR where it can be reviewed on its merits rather than buried in a crypto change.

Fixed expected ciphertext for Test 1 — I would rather not, and the label is the real problem.

You are right that the assertions are weak for something the comment calls a known-answer test. But capturing the current output and asserting it back is not a known-answer test either — it is a change detector, and it would pin whatever the implementation does today, including any mistake in it. That is a real risk for the value it adds here: nonce construction is initNonce(fromNode, packetId), shared with the existing AES-CTR path, so it is already exercised by every node on the network rather than only by this test.

Two options that would genuinely strengthen it, happy to do either:

  1. Fix the misleading comment — call it what it is (a round-trip and tag-presence smoke test) and let Tests 2-9 carry the real assertions.
  2. Add RFC 3610 CCM test vectors against aes_ccm_ae/aes_ccm_ad directly. That validates the CCM implementation against published data instead of against itself, which is the stronger version of what you are asking for.

My preference is (2) if reviewers want more coverage here, (1) otherwise. Let me know.

@jp-bennett

Copy link
Copy Markdown
Collaborator

The one thing that would be really nice to add here is to calculate the authentication hash over the sender and destination IDs, like the XEdDSA code does.

@matutetandil

Copy link
Copy Markdown
Contributor Author

Good idea, and I would like to do it — right now both calls pass nullptr, 0 for AAD:

// CryptoEngine.cpp
aes_ccm_ae(psk.bytes, psk.length, nonce, AEAD_TAG_SIZE, plaintext, numBytes, nullptr, 0, ciphertextWithTag, ...);
aes_ccm_ad(psk.bytes, psk.length, nonce, AEAD_TAG_SIZE, ciphertextWithTag, crypt_len, nullptr, 0, auth, plaintext);

from is already bound implicitly through initNonce(fromNode, packetId) — flip it and decryption fails. to is bound to nothing at all, so today an attacker can rewrite the destination in the header and the tag still verifies. Feeding both through AAD closes that and costs 8 bytes of AAD with no packet overhead.

Worth doing now rather than later: this changes the wire format for AEAD channels, and it is only cheap while use_aead is unmerged and nobody is running it.

Before I write it, I audited what touches from/to between encrypt and decrypt, and it looks safe:

  • Relays only rewrite relay_node, next_hop, hop_limit / hop_startFloodingRouter.cpp:22, NextHopRouter.cpp:45/66/69. Router.cpp:474 even documents that relays keep p->from as the original sender.
  • Router.cpp:433 p->from = getFrom(p) only substitutes the phone's 0, and it runs well before perhapsEncode() at line 492, so encryption already sees the final value.
  • MQTT downlink copies from / to verbatim from the payload (MQTT.cpp:148-149).
  • The traceroute MQTT re-encrypt at Router.cpp:1466-1468 works on an allocCopy(*p), so both fields carry over.
  • The receiver's view comes straight from the header (RadioLibInterface.cpp:672-673), which is what the sender transmitted.

So no path forwards an already-encrypted payload under a rewritten header. Two things I would rather have your read on before committing to it:

1. Store & Forward replay. StoreForwardModule::preparePayload() rebuilds the packet decoded and lets Router::send() re-encrypt it, so AAD stays self-consistent and the client can still decrypt. But it preserves from and id while rewriting to (StoreForwardModule.cpp:258-260):

p->to = local ? this->packetHistory[i].to : dest; // PhoneAPI can handle original `to`
p->from = this->packetHistory[i].from;
p->id = this->packetHistory[i].id;

Since initNonce() builds the nonce out of exactly id and from, the original broadcast and the S&F unicast replay are encrypted under the same key and the same nonce. Today that is harmless: same nonce, same key, same plaintext, so the output is byte-identical and nothing new leaks. With to in the AAD the ciphertext stays identical but the two tags differ, which hands an observer the XOR of two CBC-MACs under one keystream block. Minor, but it is a real weakening that only appears once AAD is in play.

If that bothers you, the clean fix is for the S&F replay to draw a fresh packet id rather than reusing the stored one — which is arguably right regardless, since the reuse is what pins the nonce. Happy to do that here or leave it out of scope.

2. AAD contents and layout. Is from || to (8 bytes, both little-endian) what you have in mind, or would you also want channel and portnum in there? aes_ccm_ae() caps aad_len at 30, so there is room either way. I would rather match whatever convention you would want the hardware-accelerated path to use later, given @Jorropo is working in the same area.

Is there any forwarding path I have missed where the header from / to can differ between the encrypting node and the decrypting one?

@matutetandil
matutetandil force-pushed the feature/aead-psk-channels branch from 753adfe to 6c73adf Compare August 4, 2026 22:06
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@matutetandil

Copy link
Copy Markdown
Contributor Author

Working through the latest review — three of four applied in 6c73adf, on top of a rebase onto current develop.

Known-answer coverage — done, the stronger way. I still did not want to capture the current output and assert it back, since that pins whatever the implementation does today rather than what it should do. So I took the other option I offered and added test_AES_CCM_rfc3610, driving aes_ccm_ae() / aes_ccm_ad() against RFC 3610 §8 Packet Vectors #1, #2 and #7:

That validates the primitive against an external source instead of against itself, which is what the weak assertions in Test 1 were failing to do. Test 1 is now labelled as the smoke test it actually is, with a pointer to where the real coverage lives.

Tamper sweep — applied, and widened past what you asked for: the loop now walks all 32 ciphertext bytes and the 12 tag bytes rather than stopping at index 3.

Banners — applied. The // ====... rules are gone; each block keeps its one-line // Test N: ... description.

camelCase rename — declining. The guideline is real, but test_main.cpp is uniformly test_* and always has been, including the six XEdDSA tests upstream added to this file:

test_SHA256   test_ECB_AES128   test_ECB_AES256   test_DH25519   test_AES_CTR   test_PKC
test_XEdDSA   test_XEdDSA_cross_key_reject   test_XEdDSA_empty_key_sign_fails
test_XEdDSA_curve_to_ed_cache   test_XEdDSA_max_payload   test_XEdDSA_repeated_sign_is_randomized

Renaming only the AEAD test would make it the single outlier in the file. If the project wants these camelCased it should be one sweep over the whole file, not a rename smuggled into a crypto PR.

Verification: test_crypto 15/15 on coverage (ASan), pio run -e native SUCCESS, clang-format clean.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (4)
test/test_crypto/test_main.cpp (4)

671-673: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Shorten this comment to two lines.

The comment spans three lines. Keep the sentinel rationale, and drop the restatement of the widening mechanics.
As per coding guidelines: "Keep comments minimal—normally one or two lines—and document only non-obvious rationale."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_crypto/test_main.cpp` around lines 671 - 673, Shorten the comment
above the CryptoKey invalid-key check to exactly two lines, retaining only that
-1 is the invalid “do not use” sentinel and that both directions must reject it;
remove the explanation of int8_t-to-size_t widening.

Source: Coding guidelines


429-437: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Guard the CryptoKey::bytes capacity in makePsk.

makePsk writes hex.length() / 2 bytes into k.bytes without a capacity check. Every current caller passes 32 or 64 hex characters, so the helper is safe today. If a later test passes a longer hex string, HexToBytes overflows k.bytes silently. Add an assert() for the invariant.

♻️ Proposed hardening
 static CryptoKey makePsk(const std::string &hex)
 {
     CryptoKey k;
+    assert(hex.length() / 2 <= sizeof(k.bytes));
     memset(k.bytes, 0, sizeof(k.bytes));
     k.length = hex.length() / 2;
     HexToBytes(k.bytes, hex);
     return k;
 }

As per coding guidelines: "use assert() for invariants".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_crypto/test_main.cpp` around lines 429 - 437, Add an assert in
makePsk before assigning length or calling HexToBytes to verify hex.length() / 2
does not exceed the capacity of CryptoKey::bytes. Keep the existing
zero-initialization and conversion behavior unchanged.

Source: Coding guidelines


630-647: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a packetId mismatch case.

Test 10 covers only a changed fromNode. The nonce derives from both fromNode and packetId. Add a decrypt attempt with the same fromNode and a different packetId to cover the second nonce input.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_crypto/test_main.cpp` around lines 630 - 647, Extend the Test 10
block around encryptPacketCCM/decryptPacketCCM with a second rejection assertion
that keeps fromNodeA unchanged but uses a different packetId for decryption.
Preserve the existing fromNodeB mismatch assertion so both nonce inputs are
independently covered.

535-544: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Size the output buffer for the undersized-packet test.

out holds one byte. The test passes only because decryptPacketCCM returns before it writes plaintext. If that size guard regresses, this test corrupts the stack instead of failing cleanly. Size out to the input length so a regression produces a readable assertion failure.

♻️ Proposed change
         uint8_t dummy[CryptoEngine::AEAD_TAG_SIZE] = {0};
-        uint8_t out[1];
+        uint8_t out[CryptoEngine::AEAD_TAG_SIZE] = {0};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test_crypto/test_main.cpp` around lines 535 - 544, Update the
undersized-packet test block around decryptPacketCCM so the out buffer is sized
to the input length, using the existing AEAD_TAG_SIZE value, rather than a
one-byte buffer. Keep both assertions and test inputs unchanged so any guard
regression fails cleanly without risking stack corruption.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/test_crypto/test_main.cpp`:
- Around line 671-673: Shorten the comment above the CryptoKey invalid-key check
to exactly two lines, retaining only that -1 is the invalid “do not use”
sentinel and that both directions must reject it; remove the explanation of
int8_t-to-size_t widening.
- Around line 429-437: Add an assert in makePsk before assigning length or
calling HexToBytes to verify hex.length() / 2 does not exceed the capacity of
CryptoKey::bytes. Keep the existing zero-initialization and conversion behavior
unchanged.
- Around line 630-647: Extend the Test 10 block around
encryptPacketCCM/decryptPacketCCM with a second rejection assertion that keeps
fromNodeA unchanged but uses a different packetId for decryption. Preserve the
existing fromNodeB mismatch assertion so both nonce inputs are independently
covered.
- Around line 535-544: Update the undersized-packet test block around
decryptPacketCCM so the out buffer is sized to the input length, using the
existing AEAD_TAG_SIZE value, rather than a one-byte buffer. Keep both
assertions and test inputs unchanged so any guard regression fails cleanly
without risking stack corruption.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9d9491d2-852e-4a89-aca1-933d316d8d32

📥 Commits

Reviewing files that changed from the base of the PR and between 39e7aa6 and 6c73adf.

⛔ Files ignored due to path filters (1)
  • src/mesh/generated/meshtastic/channel.pb.h is excluded by !**/generated/**, !src/mesh/generated/**
📒 Files selected for processing (9)
  • src/mesh/Channels.cpp
  • src/mesh/Channels.h
  • src/mesh/CryptoEngine.cpp
  • src/mesh/CryptoEngine.h
  • src/mesh/RadioInterface.h
  • src/mesh/Router.cpp
  • src/mesh/aes-ccm.cpp
  • src/mesh/aes-ccm.h
  • test/test_crypto/test_main.cpp
🚧 Files skipped from review as they are similar to previous changes (8)
  • src/mesh/aes-ccm.h
  • src/mesh/aes-ccm.cpp
  • src/mesh/Channels.h
  • src/mesh/Channels.cpp
  • src/mesh/RadioInterface.h
  • src/mesh/CryptoEngine.h
  • src/mesh/Router.cpp
  • src/mesh/CryptoEngine.cpp

@matutetandil

Copy link
Copy Markdown
Contributor Author

All four applied in 796c480. Two were worth more than their "low value" label.

packetId mismatch case — the best of the four. You were right that Test 10 only exercised half the nonce. It now covers each input wrong on its own, both wrong, and — the part that actually matters — both right:

TEST_ASSERT_FALSE(... fromNodeB, packetIdA ...);   // wrong sender
TEST_ASSERT_FALSE(... fromNodeA, packetIdB ...);   // wrong packet id
TEST_ASSERT_FALSE(... fromNodeB, packetIdB ...);   // both wrong
TEST_ASSERT_TRUE (... fromNodeA, packetIdA ...);   // and the happy path still decrypts
TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 6);

Without that last pair the three negatives would pass just as happily against a decrypt that rejected everything.

Undersized-packet buffer — applied. Agreed, and the reasoning is the part I want to keep visible, so it is in the comment: out was one byte and only survived because decryptPacketCCM() returns before writing. A regressed length guard would have corrupted the stack rather than failed the assertion right below it. Now sized for the full input.

makePsk capacity assert — applied. Safe with every current caller, but a longer hex string would have overflowed k.bytes silently. Needed #include <cassert>, which the file was not pulling in.

Comment at 671 — trimmed to two lines, keeping the sentinel rationale and dropping the int8_t-to-size_t mechanics.

test_crypto 15/15 on coverage (ASan), clang-format clean.

@matutetandil
matutetandil force-pushed the feature/aead-psk-channels branch from 796c480 to 9502a2b Compare August 5, 2026 00:08
@matutetandil

Copy link
Copy Markdown
Contributor Author

Thanks for merging #11347, @caveman99.

Rebased onto develop — both stacked aes_ccm_encr commits dropped themselves ("patch contents already upstream"), so this PR is back to being only the AEAD work:

src/mesh/Channels.cpp                      |  12 +
src/mesh/Channels.h                        |  15 +-
src/mesh/CryptoEngine.cpp                  |  40 ++-
src/mesh/CryptoEngine.h                    |  11 +-
src/mesh/RadioInterface.h                  |   1 +
src/mesh/Router.cpp                        |  94 +++++--
src/mesh/aes-ccm.cpp                       |   4 +-
src/mesh/aes-ccm.h                         |   4 +-
src/mesh/generated/meshtastic/channel.pb.h |  18 +-
test/test_crypto/test_main.cpp             | 388 +++++++++++++++++++++++++++++

test_crypto 15/15 on coverage (ASan) and pio run -e native SUCCESS against the new base. The aes-ccm.cpp / aes-ccm.h lines that remain are just the #if !MESHTASTIC_EXCLUDE_PKI guard removal, which the AEAD path needs in order to link.

That leaves src/mesh/generated/meshtastic/channel.pb.h as the only file here you asked me to drop, and I still cannot: it is generated from protobufs#868, which adds use_aead = 8 to ChannelSettings and has had no review since 25 February. Without the field the firmware does not compile, and update_protobufs.yml is workflow_dispatch, so regenerating it is a maintainer action. The moment that PR merges and the workflow runs, this file comes straight out.

Also still open from @jp-bennett: authenticating the sender and destination IDs through AAD. I posted an audit of the paths that touch from / to between encrypt and decrypt, plus a question about Store & Forward replays reusing (from, id) and therefore the nonce — happy to implement as soon as there is a read on the AAD layout.

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.
- 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)
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.
CryptoKey documents length == -1 as "invalid key - do not use", but the
AEAD guards only tested for 0. Since length is int8_t and the aes_ccm_*
key length parameter is size_t, a -1 would widen into a huge unsigned
length and be handed to the cipher instead of being rejected.

Both callers in Router.cpp are gated on a non-negative channel hash, and
generateHash() already returns -1 exactly when getKey() yields an invalid
key, so the sentinel cannot reach these functions today. Guard against it
anyway rather than relying on callers to keep that invariant.
The packet-size boundary checks in perhapsEncode/perhapsDecode budget for
MESHTASTIC_AEAD_OVERHEAD, but the tag actually written is AEAD_TAG_SIZE.
Nothing tied the two together, so changing one would have silently produced
oversized packets or truncated payloads. Assert they match instead of
coupling RadioInterface.h to CryptoEngine.

Also trims the sentinel comment to the two-line limit in AGENTS.md.
Packet Vectors meshtastic#1, meshtastic#2 and meshtastic#7 pin aes_ccm_ae()/aes_ccm_ad() to published data
rather than to their own output, covering M=8 and M=10, a trailing partial block
in every case, and rejection of a modified AAD. Test 1 in test_AES_CCM_AEAD is
relabelled as the smoke test it actually is.

The per-byte tamper loop now walks the whole buffer including the tag, instead of
only the first four ciphertext bytes.
Test 10 only ever varied fromNode, leaving packetId — the other half of the
nonce — unexercised. It now checks each one wrong on its own, both wrong, and
both right, so the negative assertions cannot pass vacuously.

The undersized-packet test wrote into a one-byte buffer and only survived
because decryptPacketCCM() returns before touching it; size it for the whole
input so a regressed length guard fails an assertion instead of the stack.
Also assert makePsk() cannot overrun CryptoKey::bytes.
@matutetandil
matutetandil force-pushed the feature/aead-psk-channels branch from 9502a2b to 1512b25 Compare August 7, 2026 03:31
@matutetandil

Copy link
Copy Markdown
Contributor Author

Rebased onto develop @ de6b23190 — conflict resolved, back to MERGEABLE.

The collision was #11359 (fix(crypto): hash full size_t inputs) landing test_SHA256_large_input in the same RUN_TEST block this PR adds to. Pure adjacency in the registration list, no overlapping logic — both sides kept:

RUN_TEST(test_SHA256);
RUN_TEST(test_SHA256_large_input);   // #11359
RUN_TEST(test_ECB_AES128);           // this PR
RUN_TEST(test_ECB_AES256);

CryptoEngine.cpp auto-merged: #11359 touches hash(), this PR touches the AEAD block well below it.

Also checked this against the new test infrastructure from #11322, since that reworked how suites are validated:

  • native-suite-count — still 44, matching the 44 test_* directories. This PR adds tests inside test_crypto rather than a new suite, so the reconciliation step in test_native.yml stays green.
  • lint-unity-exit.sh, lint-node-id-format.sh, lint-ifdef-complexity.sh — all exit 0.
  • test-state-check.sh — exits 1 with "4 of 8 fixtures behaved unexpectedly", but it does the same on a clean develop worktree at de6b23190, so it is pre-existing and not something this branch introduces. Flagging it in case it is news to you; it does not appear to be wired into test_native.yml.

Verification on the new base: test_crypto 16/16 under coverage/ASan (15 of ours plus upstream's new test_SHA256_large_input), pio run -e native SUCCESS, clang-format clean. Still the same 10 files.

Unchanged from before: src/mesh/generated/meshtastic/channel.pb.h is the last generated file here, and it comes out the moment protobufs#868 merges and update_protobufs.yml runs. That PR still has no review since 25 February.

@jp-bennett — still happy to implement the AAD change over sender/destination IDs whenever you have a read on the layout. The audit of the paths that touch from/to between encrypt and decrypt is in this comment, including the Store & Forward replay that reuses (from, id) and therefore the nonce.

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.

7 participants