From 001cd28791626fa82102b6f3d477bee344045009 Mon Sep 17 00:00:00 2001 From: Matias Denda Date: Wed, 25 Feb 2026 12:27:24 -0300 Subject: [PATCH 1/8] Add AEAD (AES-CCM) authenticated encryption for PSK channels 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. --- src/mesh/Channels.cpp | 12 + src/mesh/Channels.h | 15 +- src/mesh/CryptoEngine.cpp | 33 ++- src/mesh/CryptoEngine.h | 11 +- src/mesh/RadioInterface.h | 1 + src/mesh/Router.cpp | 83 +++++-- 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 | 243 +++++++++++++++++++++ 10 files changed, 385 insertions(+), 39 deletions(-) diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index 2d8d4f246ce..78338335fbe 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -47,6 +47,12 @@ int16_t Channels::generateHash(ChannelIndex channelNum) h ^= xorHash(k.bytes, k.length); + // Differentiate AEAD channels in routing so AEAD and non-AEAD + // channels with the same PSK have different hashes + auto &ch = getByIndex(channelNum); + if (ch.has_settings && ch.settings.use_aead) + h ^= 0xAE; + return h; } } @@ -556,6 +562,12 @@ bool Channels::setDefaultPresetCryptoForHash(ChannelHash channelHash) return false; } +bool Channels::isAEADEnabled(ChannelIndex chIndex) +{ + auto &ch = getByIndex(chIndex); + return ch.has_settings && ch.settings.use_aead; +} + /** Given a channel index setup crypto for encoding that channel (or the primary channel if that channel is unsecured) * * This method is called before encoding outbound packets diff --git a/src/mesh/Channels.h b/src/mesh/Channels.h index 6e17a7ab618..f4ebc38312b 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -112,6 +112,15 @@ class Channels int16_t getHash(ChannelIndex i) { return hashes[i]; } + /** Return true if the channel has AEAD (authenticated encryption) enabled */ + bool isAEADEnabled(ChannelIndex chIndex); + + /** + * Return the key used for encrypting this channel (if channel is secondary and no key provided, use the primary channel's + * PSK) + */ + CryptoKey getKey(ChannelIndex chIndex); + private: /** Given a channel index, change to use the crypto key specified by that index * @@ -138,12 +147,6 @@ class Channels * Write default channels defined in UserPrefs */ void initDefaultChannel(ChannelIndex chIndex); - - /** - * Return the key used for encrypting this channel (if channel is secondary and no key provided, use the primary channel's - * PSK) - */ - CryptoKey getKey(ChannelIndex chIndex); }; /// Singleton channel table diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index 95c640d539c..20d6862a4a6 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -1,12 +1,12 @@ #include "CryptoEngine.h" // #include "NodeDB.h" +#include "aes-ccm.h" #include "architecture.h" #include #if !(MESHTASTIC_EXCLUDE_PKI) #include "HardwareRNG.h" #include "NodeDB.h" -#include "aes-ccm.h" #include "meshUtils.h" #include #include @@ -369,6 +369,37 @@ bool CryptoEngine::getPendingPublicKey(uint32_t node, meshtastic_NodeInfoLite_pu } #endif + +bool CryptoEngine::encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, + size_t numBytes, const uint8_t *plaintext, + uint8_t *ciphertextWithTag) +{ + initNonce(fromNode, packetId); + // AESSmall256::setKey() requires exactly 32 bytes. CryptoKey.bytes is always + // 32 bytes (zero-padded by Channels::getKey()), so pass the full buffer. + // This effectively promotes AES-128 PSKs to AES-256 with zero-padded key. + size_t keyLen = (psk.length > 0 && psk.length < 32) ? 32 : psk.length; + // Output layout: [ciphertext (numBytes)] [auth_tag (AEAD_TAG_SIZE bytes)] + return aes_ccm_ae(psk.bytes, keyLen, nonce, AEAD_TAG_SIZE, + plaintext, numBytes, nullptr, 0, + ciphertextWithTag, ciphertextWithTag + numBytes) == 0; +} + +bool CryptoEngine::decryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, + size_t totalBytes, const uint8_t *ciphertextWithTag, + uint8_t *plaintext) +{ + if (totalBytes <= AEAD_TAG_SIZE) + return false; + initNonce(fromNode, packetId); + size_t keyLen = (psk.length > 0 && psk.length < 32) ? 32 : psk.length; + size_t crypt_len = totalBytes - AEAD_TAG_SIZE; + const uint8_t *auth = ciphertextWithTag + crypt_len; + return aes_ccm_ad(psk.bytes, keyLen, nonce, AEAD_TAG_SIZE, + ciphertextWithTag, crypt_len, nullptr, 0, + auth, plaintext); +} + concurrency::Lock *cryptLock; void CryptoEngine::setKey(const CryptoKey &k) diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h index 95c7eb8ece3..a58dd685b11 100644 --- a/src/mesh/CryptoEngine.h +++ b/src/mesh/CryptoEngine.h @@ -58,6 +58,7 @@ class CryptoEngine size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut); virtual bool setDHPublicKey(uint8_t *publicKey); virtual void hash(uint8_t *bytes, size_t numBytes); +#endif // Temporary holder for a peer's not-yet-verified public key, learned in-band during an // in-progress key-verification handshake before it is committed to NodeDB. Lets the Router @@ -75,7 +76,15 @@ class CryptoEngine virtual void aesEncrypt(uint8_t *in, uint8_t *out); std::unique_ptr aes = nullptr; -#endif + static constexpr size_t AEAD_TAG_SIZE = 12; + + bool encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, + size_t numBytes, const uint8_t *plaintext, + uint8_t *ciphertextWithTag); + + bool decryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, + size_t totalBytes, const uint8_t *ciphertextWithTag, + uint8_t *plaintext); /** * Set the key used for encrypt, decrypt. diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index eb9315ac122..18feaa1912c 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -20,6 +20,7 @@ typedef struct _meshtastic_Config_LoRaConfig meshtastic_Config_LoRaConfig; #define MAX_LORA_PAYLOAD_LEN 255 // max length of 255 per Semtech's datasheets on SX12xx #define MESHTASTIC_HEADER_LENGTH 16 #define MESHTASTIC_PKC_OVERHEAD 12 +#define MESHTASTIC_AEAD_OVERHEAD 12 #define PACKET_FLAGS_HOP_LIMIT_MASK 0x07 #define PACKET_FLAGS_WANT_ACK_MASK 0x08 diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 4b2c426938c..f66891e1541 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -923,15 +923,32 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) // we have to copy into a scratch buffer, because these bytes are a union with the decoded protobuf. Create a // fresh copy for each decrypt attempt. memcpy(bytes, p->encrypted.bytes, rawSize); - // Try to decrypt the packet if we can - crypto->decrypt(p->from, p->id, rawSize, bytes); + + size_t decryptedSize = rawSize; + + if (channels.isAEADEnabled(chIndex)) { + // AEAD decryption — no CTR fallback + if (rawSize <= MESHTASTIC_AEAD_OVERHEAD) { + 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); + continue; // reject — no fallback to CTR + } + decryptedSize = rawSize - MESHTASTIC_AEAD_OVERHEAD; + } else { + // Standard AES-CTR decryption + crypto->decrypt(p->from, p->id, rawSize, bytes); + } // printBytes("plaintext", bytes, p->encrypted.size); // Take those raw bytes and convert them back into a well structured protobuf we can understand meshtastic_Data decodedtmp; memset(&decodedtmp, 0, sizeof(decodedtmp)); - if (!pb_decode_from_bytes(bytes, rawSize, &meshtastic_Data_msg, &decodedtmp)) { + if (!pb_decode_from_bytes(bytes, decryptedSize, &meshtastic_Data_msg, &decodedtmp)) { LOG_DEBUG("Invalid protobufs in received mesh packet id=0x%08x (bad psk?)", p->id); } else if (decodedtmp.portnum == meshtastic_PortNum_UNKNOWN_APP) { LOG_DEBUG("Invalid portnum (bad psk?)"); @@ -1184,32 +1201,58 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) // Client specifically requested PKI encryption return meshtastic_Routing_Error_PKI_FAILED; } - hash = channels.setActiveByIndex(chIndex); - - // Now that we are encrypting the packet channel should be the hash (no longer the index) - p->channel = hash; - if (hash < 0) { - // No suitable channel could be found for - return meshtastic_Routing_Error_NO_CHANNEL; + if (channels.isAEADEnabled(chIndex)) { + // AEAD (AES-CCM) authenticated encryption path + if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_AEAD_OVERHEAD > MAX_LORA_PAYLOAD_LEN) + return meshtastic_Routing_Error_TOO_LARGE; + + hash = channels.setActiveByIndex(chIndex); + p->channel = hash; + if (hash < 0) + return meshtastic_Routing_Error_NO_CHANNEL; + + CryptoKey k = channels.getKey(chIndex); + crypto->encryptPacketCCM(k, getFrom(p), p->id, numbytes, bytes, p->encrypted.bytes); + numbytes += MESHTASTIC_AEAD_OVERHEAD; + } else { + // Standard AES-CTR encryption path + hash = channels.setActiveByIndex(chIndex); + p->channel = hash; + if (hash < 0) + return meshtastic_Routing_Error_NO_CHANNEL; + + crypto->encryptPacket(getFrom(p), p->id, numbytes, bytes); + memcpy(p->encrypted.bytes, bytes, numbytes); } - crypto->encryptPacket(getFrom(p), p->id, numbytes, bytes); - memcpy(p->encrypted.bytes, bytes, numbytes); } #else if (p->pki_encrypted == true) { // Client specifically requested PKI encryption return meshtastic_Routing_Error_PKI_FAILED; } - hash = channels.setActiveByIndex(chIndex); + if (channels.isAEADEnabled(chIndex)) { + // AEAD (AES-CCM) authenticated encryption path + if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_AEAD_OVERHEAD > MAX_LORA_PAYLOAD_LEN) + return meshtastic_Routing_Error_TOO_LARGE; + + hash = channels.setActiveByIndex(chIndex); + p->channel = hash; + if (hash < 0) + return meshtastic_Routing_Error_NO_CHANNEL; + + CryptoKey k = channels.getKey(chIndex); + crypto->encryptPacketCCM(k, getFrom(p), p->id, numbytes, bytes, p->encrypted.bytes); + numbytes += MESHTASTIC_AEAD_OVERHEAD; + } else { + // Standard AES-CTR encryption path + hash = channels.setActiveByIndex(chIndex); + p->channel = hash; + if (hash < 0) + return meshtastic_Routing_Error_NO_CHANNEL; - // Now that we are encrypting the packet channel should be the hash (no longer the index) - p->channel = hash; - if (hash < 0) { - // No suitable channel could be found for - return meshtastic_Routing_Error_NO_CHANNEL; + crypto->encryptPacket(getFrom(p), p->id, numbytes, bytes); + memcpy(p->encrypted.bytes, bytes, numbytes); } - crypto->encryptPacket(getFrom(p), p->id, numbytes, bytes); - memcpy(p->encrypted.bytes, bytes, numbytes); #endif // Copy back into the packet and set the variant type diff --git a/src/mesh/aes-ccm.cpp b/src/mesh/aes-ccm.cpp index 29e96cd4be4..c534aadbedf 100644 --- a/src/mesh/aes-ccm.cpp +++ b/src/mesh/aes-ccm.cpp @@ -8,7 +8,6 @@ */ #define AES_BLOCK_SIZE 16 #include "aes-ccm.h" -#if !MESHTASTIC_EXCLUDE_PKI /** * Constant-time comparison of two byte arrays @@ -177,5 +176,4 @@ bool aes_ccm_ad(const uint8_t *key, size_t key_len, const uint8_t *nonce, size_t return false; } return true; -} -#endif \ No newline at end of file +} \ No newline at end of file diff --git a/src/mesh/aes-ccm.h b/src/mesh/aes-ccm.h index 6b8edcde490..8d160543f23 100644 --- a/src/mesh/aes-ccm.h +++ b/src/mesh/aes-ccm.h @@ -1,10 +1,8 @@ #pragma once #include "CryptoEngine.h" -#if !MESHTASTIC_EXCLUDE_PKI int aes_ccm_ae(const uint8_t *key, size_t key_len, const uint8_t *nonce, size_t M, const uint8_t *plain, size_t plain_len, const uint8_t *aad, size_t aad_len, uint8_t *crypt, uint8_t *auth); bool aes_ccm_ad(const uint8_t *key, size_t key_len, const uint8_t *nonce, size_t M, const uint8_t *crypt, size_t crypt_len, - const uint8_t *aad, size_t aad_len, const uint8_t *auth, uint8_t *plain); -#endif \ No newline at end of file + const uint8_t *aad, size_t aad_len, const uint8_t *auth, uint8_t *plain); \ No newline at end of file diff --git a/src/mesh/generated/meshtastic/channel.pb.h b/src/mesh/generated/meshtastic/channel.pb.h index 9dc757ab4a5..22c25f79659 100644 --- a/src/mesh/generated/meshtastic/channel.pb.h +++ b/src/mesh/generated/meshtastic/channel.pb.h @@ -97,6 +97,12 @@ typedef struct _meshtastic_ChannelSettings { /* Per-channel module settings. */ bool has_module_settings; meshtastic_ModuleSettings module_settings; + /* Enable authenticated encryption (AES-CCM) for this channel. + When true, messages include a 12-byte authentication tag that prevents + forgery and bit-flipping attacks. All nodes on the channel must have + this enabled — unauthenticated (AES-CTR) packets are rejected. + Experimental. Default: false (standard AES-CTR encryption). */ + bool use_aead; } meshtastic_ChannelSettings; /* A pair of a channel number, mode and the (sharable) settings for that channel */ @@ -128,10 +134,10 @@ extern "C" { /* Initializer values for message structs */ -#define meshtastic_ChannelSettings_init_default {0, {0, {0}}, "", 0, 0, 0, false, meshtastic_ModuleSettings_init_default} +#define meshtastic_ChannelSettings_init_default {0, {0, {0}}, "", 0, 0, 0, false, meshtastic_ModuleSettings_init_default, 0} #define meshtastic_ModuleSettings_init_default {0, 0} #define meshtastic_Channel_init_default {0, false, meshtastic_ChannelSettings_init_default, _meshtastic_Channel_Role_MIN} -#define meshtastic_ChannelSettings_init_zero {0, {0, {0}}, "", 0, 0, 0, false, meshtastic_ModuleSettings_init_zero} +#define meshtastic_ChannelSettings_init_zero {0, {0, {0}}, "", 0, 0, 0, false, meshtastic_ModuleSettings_init_zero, 0} #define meshtastic_ModuleSettings_init_zero {0, 0} #define meshtastic_Channel_init_zero {0, false, meshtastic_ChannelSettings_init_zero, _meshtastic_Channel_Role_MIN} @@ -145,6 +151,7 @@ extern "C" { #define meshtastic_ChannelSettings_uplink_enabled_tag 5 #define meshtastic_ChannelSettings_downlink_enabled_tag 6 #define meshtastic_ChannelSettings_module_settings_tag 7 +#define meshtastic_ChannelSettings_use_aead_tag 8 #define meshtastic_Channel_index_tag 1 #define meshtastic_Channel_settings_tag 2 #define meshtastic_Channel_role_tag 3 @@ -157,7 +164,8 @@ X(a, STATIC, SINGULAR, STRING, name, 3) \ X(a, STATIC, SINGULAR, FIXED32, id, 4) \ X(a, STATIC, SINGULAR, BOOL, uplink_enabled, 5) \ X(a, STATIC, SINGULAR, BOOL, downlink_enabled, 6) \ -X(a, STATIC, OPTIONAL, MESSAGE, module_settings, 7) +X(a, STATIC, OPTIONAL, MESSAGE, module_settings, 7) \ +X(a, STATIC, SINGULAR, BOOL, use_aead, 8) #define meshtastic_ChannelSettings_CALLBACK NULL #define meshtastic_ChannelSettings_DEFAULT NULL #define meshtastic_ChannelSettings_module_settings_MSGTYPE meshtastic_ModuleSettings @@ -187,8 +195,8 @@ extern const pb_msgdesc_t meshtastic_Channel_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_CHANNEL_PB_H_MAX_SIZE meshtastic_Channel_size -#define meshtastic_ChannelSettings_size 72 -#define meshtastic_Channel_size 87 +#define meshtastic_ChannelSettings_size 74 +#define meshtastic_Channel_size 89 #define meshtastic_ModuleSettings_size 8 #ifdef __cplusplus diff --git a/test/test_crypto/test_main.cpp b/test/test_crypto/test_main.cpp index 448942ffe62..79ccd1a060d 100644 --- a/test/test_crypto/test_main.cpp +++ b/test/test_crypto/test_main.cpp @@ -1,5 +1,6 @@ // trunk-ignore-all(gitleaks): These are dummy values. Not real secrets. #include "CryptoEngine.h" +#include "aes-ccm.h" #include "TestUtil.h" #include "aes-ccm.h" @@ -358,6 +359,247 @@ void test_AES_CCM_partial_block_bounds(void) } } +// Helper to create a zero-initialized CryptoKey (matching Channels::getKey() behavior) +static CryptoKey makePsk(const std::string &hex) +{ + CryptoKey k; + memset(k.bytes, 0, sizeof(k.bytes)); + k.length = hex.length() / 2; + HexToBytes(k.bytes, hex); + return k; +} + +void test_AES_CCM_AEAD(void) +{ + // ========================================================================= + // Test 1: Known-answer AES-CCM encrypt + verify tag + // ========================================================================= + { + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0x12345678; + uint64_t packetId = 0xAABBCCDD; + + uint8_t plaintext[10]; + HexToBytes(plaintext, "08011204746573744800"); + + uint8_t ciphertextWithTag[10 + CryptoEngine::AEAD_TAG_SIZE]; + memset(ciphertextWithTag, 0, sizeof(ciphertextWithTag)); + + 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); + } + + // ========================================================================= + // Test 2: Round-trip encrypt → decrypt → compare (AES-256) + // ========================================================================= + { + CryptoKey psk = makePsk("603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4"); + + uint32_t fromNode = 0xDEADBEEF; + uint64_t packetId = 0x0102030405060708; + + const char *msg = "Hello Meshtastic AEAD!"; + size_t msgLen = strlen(msg); + + uint8_t ciphertextWithTag[64]; + memset(ciphertextWithTag, 0, sizeof(ciphertextWithTag)); + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, packetId, msgLen, (const uint8_t *)msg, ciphertextWithTag)); + + uint8_t decrypted[64]; + memset(decrypted, 0, sizeof(decrypted)); + size_t totalBytes = msgLen + CryptoEngine::AEAD_TAG_SIZE; + TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk, fromNode, packetId, totalBytes, ciphertextWithTag, decrypted)); + + TEST_ASSERT_EQUAL_MEMORY(msg, decrypted, msgLen); + } + + // ========================================================================= + // Test 3: Tampered ciphertext — flip a bit, verify rejection + // ========================================================================= + { + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0xABCD1234; + uint64_t packetId = 0x11223344; + + uint8_t plaintext[8] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; + uint8_t ciphertextWithTag[8 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, packetId, 8, plaintext, ciphertextWithTag)); + + // Flip a bit in the ciphertext portion + ciphertextWithTag[3] ^= 0x01; + + uint8_t decrypted[8]; + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + } + + // ========================================================================= + // Test 4: Tampered auth tag — modify tag, verify rejection + // ========================================================================= + { + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0xABCD1234; + uint64_t packetId = 0x55667788; + + uint8_t plaintext[16] = {0}; + for (int i = 0; i < 16; i++) + plaintext[i] = (uint8_t)i; + + uint8_t ciphertextWithTag[16 + CryptoEngine::AEAD_TAG_SIZE]; + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, packetId, 16, plaintext, ciphertextWithTag)); + + // Corrupt the auth tag (last byte) + ciphertextWithTag[16 + CryptoEngine::AEAD_TAG_SIZE - 1] ^= 0xFF; + + uint8_t decrypted[16]; + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, packetId, 16 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + } + + // ========================================================================= + // Test 5: Packet too small for AEAD — totalBytes <= AEAD_TAG_SIZE + // ========================================================================= + { + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint8_t dummy[CryptoEngine::AEAD_TAG_SIZE] = {0}; + uint8_t out[1]; + + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, 0x1234, 0x5678, CryptoEngine::AEAD_TAG_SIZE, dummy, out)); + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, 0x1234, 0x5678, 0, dummy, out)); + } + + // ========================================================================= + // Test 6: Wrong PSK — decrypt with different key, verify rejection + // ========================================================================= + { + CryptoKey pskA = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + CryptoKey pskB = makePsk("00112233445566778899aabbccddeeff"); + + uint32_t fromNode = 0x99887766; + uint64_t packetId = 0xDEADFACE; + + uint8_t plaintext[12] = "Hello World"; + uint8_t ciphertextWithTag[12 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(pskA, fromNode, packetId, 12, plaintext, ciphertextWithTag)); + + // Attempt decryption with wrong key + uint8_t decrypted[12]; + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(pskB, fromNode, packetId, 12 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + } + + // ========================================================================= + // Test 7: Round-trip with AES-128 PSK (16-byte key, zero-padded to 256) + // Verifies that the key promotion to AES-256 works correctly. + // ========================================================================= + { + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0x42424242; + uint64_t packetId = 0xBEEF1234; + + uint8_t plaintext[20] = "AES128 round trip!"; + uint8_t ciphertextWithTag[20 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, packetId, 20, plaintext, ciphertextWithTag)); + + uint8_t decrypted[20]; + TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk, fromNode, packetId, 20 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 20); + } + + // ========================================================================= + // Test 8: AES-256-CCM round-trip + per-byte tamper detection + // ========================================================================= + { + CryptoKey psk = makePsk("603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4"); + + uint32_t fromNode = 0x01020304; + uint64_t packetId = 0x0A0B0C0D0E0F1011; + + uint8_t plaintext[32]; + for (int i = 0; i < 32; i++) + plaintext[i] = (uint8_t)(i * 7 + 3); + + uint8_t ciphertextWithTag[32 + CryptoEngine::AEAD_TAG_SIZE]; + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, packetId, 32, plaintext, ciphertextWithTag)); + + // Valid decrypt + uint8_t decrypted[32]; + TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk, fromNode, packetId, 32 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 32); + + // Tamper with each of first 4 ciphertext bytes and verify rejection + for (int i = 0; i < 4; i++) { + uint8_t tampered[32 + CryptoEngine::AEAD_TAG_SIZE]; + memcpy(tampered, ciphertextWithTag, sizeof(tampered)); + tampered[i] ^= 0x80; + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, packetId, 32 + CryptoEngine::AEAD_TAG_SIZE, + tampered, decrypted)); + } + } + + // ========================================================================= + // Test 9: Deterministic — same inputs produce same output + // ========================================================================= + { + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNode = 0xCAFEBABE; + uint64_t packetId = 0xFEEDFACE; + + uint8_t plaintext[5] = {0xDE, 0xAD, 0xBE, 0xEF, 0x42}; + uint8_t ct1[5 + CryptoEngine::AEAD_TAG_SIZE]; + uint8_t ct2[5 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, packetId, 5, plaintext, ct1)); + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, packetId, 5, plaintext, ct2)); + + TEST_ASSERT_EQUAL_MEMORY(ct1, ct2, 5 + CryptoEngine::AEAD_TAG_SIZE); + } + + // ========================================================================= + // Test 10: Wrong fromNode — nonce mismatch, verify rejection + // ========================================================================= + { + CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + + uint32_t fromNodeA = 0x11111111; + uint32_t fromNodeB = 0x22222222; + uint64_t packetId = 0xAAAABBBB; + + uint8_t plaintext[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; + uint8_t ciphertextWithTag[6 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNodeA, packetId, 6, plaintext, ciphertextWithTag)); + + // Decrypt with different fromNode (nonce will differ) + uint8_t decrypted[6]; + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNodeB, packetId, 6 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + } +} + void setup() { // NOTE!!! Wait for >2 secs @@ -380,6 +622,7 @@ void setup() RUN_TEST(test_XEdDSA_curve_to_ed_cache); RUN_TEST(test_XEdDSA_max_payload); RUN_TEST(test_XEdDSA_repeated_sign_is_randomized); + RUN_TEST(test_AES_CCM_AEAD); exit(UNITY_END()); // stop unit testing } From b623cb2336796eb864bc6c1cfbd1ebcfda59c0b5 Mon Sep 17 00:00:00 2001 From: Matias Denda Date: Wed, 25 Feb 2026 14:39:38 -0300 Subject: [PATCH 2/8] Apply clang-format to match project style --- src/mesh/CryptoEngine.cpp | 19 +++++++------------ src/mesh/CryptoEngine.h | 8 +++----- test/test_crypto/test_main.cpp | 28 ++++++++++++++-------------- 3 files changed, 24 insertions(+), 31 deletions(-) diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index 20d6862a4a6..c61348bfbbf 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -370,9 +370,8 @@ bool CryptoEngine::getPendingPublicKey(uint32_t node, meshtastic_NodeInfoLite_pu #endif -bool CryptoEngine::encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, - size_t numBytes, const uint8_t *plaintext, - uint8_t *ciphertextWithTag) +bool CryptoEngine::encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, size_t numBytes, + const uint8_t *plaintext, uint8_t *ciphertextWithTag) { initNonce(fromNode, packetId); // AESSmall256::setKey() requires exactly 32 bytes. CryptoKey.bytes is always @@ -380,14 +379,12 @@ bool CryptoEngine::encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uin // This effectively promotes AES-128 PSKs to AES-256 with zero-padded key. size_t keyLen = (psk.length > 0 && psk.length < 32) ? 32 : psk.length; // Output layout: [ciphertext (numBytes)] [auth_tag (AEAD_TAG_SIZE bytes)] - return aes_ccm_ae(psk.bytes, keyLen, nonce, AEAD_TAG_SIZE, - plaintext, numBytes, nullptr, 0, - ciphertextWithTag, ciphertextWithTag + numBytes) == 0; + return aes_ccm_ae(psk.bytes, keyLen, nonce, AEAD_TAG_SIZE, plaintext, numBytes, nullptr, 0, ciphertextWithTag, + ciphertextWithTag + numBytes) == 0; } -bool CryptoEngine::decryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, - size_t totalBytes, const uint8_t *ciphertextWithTag, - uint8_t *plaintext) +bool CryptoEngine::decryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, size_t totalBytes, + const uint8_t *ciphertextWithTag, uint8_t *plaintext) { if (totalBytes <= AEAD_TAG_SIZE) return false; @@ -395,9 +392,7 @@ bool CryptoEngine::decryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uin size_t keyLen = (psk.length > 0 && psk.length < 32) ? 32 : psk.length; size_t crypt_len = totalBytes - AEAD_TAG_SIZE; const uint8_t *auth = ciphertextWithTag + crypt_len; - return aes_ccm_ad(psk.bytes, keyLen, nonce, AEAD_TAG_SIZE, - ciphertextWithTag, crypt_len, nullptr, 0, - auth, plaintext); + return aes_ccm_ad(psk.bytes, keyLen, nonce, AEAD_TAG_SIZE, ciphertextWithTag, crypt_len, nullptr, 0, auth, plaintext); } concurrency::Lock *cryptLock; diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h index a58dd685b11..a5dcee1500a 100644 --- a/src/mesh/CryptoEngine.h +++ b/src/mesh/CryptoEngine.h @@ -78,13 +78,11 @@ class CryptoEngine static constexpr size_t AEAD_TAG_SIZE = 12; - bool encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, - size_t numBytes, const uint8_t *plaintext, + bool encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, size_t numBytes, const uint8_t *plaintext, uint8_t *ciphertextWithTag); - bool decryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, - size_t totalBytes, const uint8_t *ciphertextWithTag, - uint8_t *plaintext); + bool decryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, size_t totalBytes, + const uint8_t *ciphertextWithTag, uint8_t *plaintext); /** * Set the key used for encrypt, decrypt. diff --git a/test/test_crypto/test_main.cpp b/test/test_crypto/test_main.cpp index 79ccd1a060d..3847061573d 100644 --- a/test/test_crypto/test_main.cpp +++ b/test/test_crypto/test_main.cpp @@ -444,8 +444,8 @@ void test_AES_CCM_AEAD(void) ciphertextWithTag[3] ^= 0x01; uint8_t decrypted[8]; - TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, - ciphertextWithTag, decrypted)); + TEST_ASSERT_FALSE( + crypto->decryptPacketCCM(psk, fromNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); } // ========================================================================= @@ -468,8 +468,8 @@ void test_AES_CCM_AEAD(void) ciphertextWithTag[16 + CryptoEngine::AEAD_TAG_SIZE - 1] ^= 0xFF; uint8_t decrypted[16]; - TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, packetId, 16 + CryptoEngine::AEAD_TAG_SIZE, - ciphertextWithTag, decrypted)); + TEST_ASSERT_FALSE( + crypto->decryptPacketCCM(psk, fromNode, packetId, 16 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); } // ========================================================================= @@ -502,8 +502,8 @@ void test_AES_CCM_AEAD(void) // Attempt decryption with wrong key uint8_t decrypted[12]; - TEST_ASSERT_FALSE(crypto->decryptPacketCCM(pskB, fromNode, packetId, 12 + CryptoEngine::AEAD_TAG_SIZE, - ciphertextWithTag, decrypted)); + TEST_ASSERT_FALSE( + crypto->decryptPacketCCM(pskB, fromNode, packetId, 12 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); } // ========================================================================= @@ -522,8 +522,8 @@ void test_AES_CCM_AEAD(void) TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, packetId, 20, plaintext, ciphertextWithTag)); uint8_t decrypted[20]; - TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk, fromNode, packetId, 20 + CryptoEngine::AEAD_TAG_SIZE, - ciphertextWithTag, decrypted)); + TEST_ASSERT_TRUE( + crypto->decryptPacketCCM(psk, fromNode, packetId, 20 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 20); } @@ -545,8 +545,8 @@ void test_AES_CCM_AEAD(void) // Valid decrypt uint8_t decrypted[32]; - TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk, fromNode, packetId, 32 + CryptoEngine::AEAD_TAG_SIZE, - ciphertextWithTag, decrypted)); + TEST_ASSERT_TRUE( + crypto->decryptPacketCCM(psk, fromNode, packetId, 32 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 32); // Tamper with each of first 4 ciphertext bytes and verify rejection @@ -554,8 +554,8 @@ void test_AES_CCM_AEAD(void) uint8_t tampered[32 + CryptoEngine::AEAD_TAG_SIZE]; memcpy(tampered, ciphertextWithTag, sizeof(tampered)); tampered[i] ^= 0x80; - TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, packetId, 32 + CryptoEngine::AEAD_TAG_SIZE, - tampered, decrypted)); + TEST_ASSERT_FALSE( + crypto->decryptPacketCCM(psk, fromNode, packetId, 32 + CryptoEngine::AEAD_TAG_SIZE, tampered, decrypted)); } } @@ -595,8 +595,8 @@ void test_AES_CCM_AEAD(void) // Decrypt with different fromNode (nonce will differ) uint8_t decrypted[6]; - TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNodeB, packetId, 6 + CryptoEngine::AEAD_TAG_SIZE, - ciphertextWithTag, decrypted)); + TEST_ASSERT_FALSE( + crypto->decryptPacketCCM(psk, fromNodeB, packetId, 6 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); } } From 34e653caf4895db0452d1df7fef9cad024fe224b Mon Sep 17 00:00:00 2001 From: Matias Denda Date: Thu, 26 Feb 2026 15:43:30 -0300 Subject: [PATCH 3/8] Guard AEAD path against empty PSK and check encrypt return value - 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) --- src/mesh/CryptoEngine.cpp | 12 ++++++++++-- src/mesh/Router.cpp | 10 ++++++++-- test/test_crypto/test_main.cpp | 25 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index c61348bfbbf..5d71c7b865c 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -373,11 +373,15 @@ bool CryptoEngine::getPendingPublicKey(uint32_t node, meshtastic_NodeInfoLite_pu bool CryptoEngine::encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, size_t numBytes, const uint8_t *plaintext, uint8_t *ciphertextWithTag) { + if (psk.length == 0) { + LOG_ERROR("AEAD encryption requires a non-empty PSK"); + return false; + } initNonce(fromNode, packetId); // AESSmall256::setKey() requires exactly 32 bytes. CryptoKey.bytes is always // 32 bytes (zero-padded by Channels::getKey()), so pass the full buffer. // This effectively promotes AES-128 PSKs to AES-256 with zero-padded key. - size_t keyLen = (psk.length > 0 && psk.length < 32) ? 32 : psk.length; + size_t keyLen = (psk.length < 32) ? 32 : psk.length; // Output layout: [ciphertext (numBytes)] [auth_tag (AEAD_TAG_SIZE bytes)] return aes_ccm_ae(psk.bytes, keyLen, nonce, AEAD_TAG_SIZE, plaintext, numBytes, nullptr, 0, ciphertextWithTag, ciphertextWithTag + numBytes) == 0; @@ -386,10 +390,14 @@ bool CryptoEngine::encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uin bool CryptoEngine::decryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, size_t totalBytes, const uint8_t *ciphertextWithTag, uint8_t *plaintext) { + if (psk.length == 0) { + LOG_ERROR("AEAD decryption requires a non-empty PSK"); + return false; + } if (totalBytes <= AEAD_TAG_SIZE) return false; initNonce(fromNode, packetId); - size_t keyLen = (psk.length > 0 && psk.length < 32) ? 32 : psk.length; + size_t keyLen = (psk.length < 32) ? 32 : psk.length; size_t crypt_len = totalBytes - AEAD_TAG_SIZE; const uint8_t *auth = ciphertextWithTag + crypt_len; return aes_ccm_ad(psk.bytes, keyLen, nonce, AEAD_TAG_SIZE, ciphertextWithTag, crypt_len, nullptr, 0, auth, plaintext); diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index f66891e1541..88bfc1b61eb 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -1212,7 +1212,10 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) return meshtastic_Routing_Error_NO_CHANNEL; CryptoKey k = channels.getKey(chIndex); - crypto->encryptPacketCCM(k, getFrom(p), p->id, numbytes, bytes, p->encrypted.bytes); + if (!crypto->encryptPacketCCM(k, getFrom(p), p->id, numbytes, bytes, p->encrypted.bytes)) { + LOG_ERROR("AEAD encryption failed for ch %d", chIndex); + return meshtastic_Routing_Error_BAD_REQUEST; + } numbytes += MESHTASTIC_AEAD_OVERHEAD; } else { // Standard AES-CTR encryption path @@ -1241,7 +1244,10 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) return meshtastic_Routing_Error_NO_CHANNEL; CryptoKey k = channels.getKey(chIndex); - crypto->encryptPacketCCM(k, getFrom(p), p->id, numbytes, bytes, p->encrypted.bytes); + if (!crypto->encryptPacketCCM(k, getFrom(p), p->id, numbytes, bytes, p->encrypted.bytes)) { + LOG_ERROR("AEAD encryption failed for ch %d", chIndex); + return meshtastic_Routing_Error_BAD_REQUEST; + } numbytes += MESHTASTIC_AEAD_OVERHEAD; } else { // Standard AES-CTR encryption path diff --git a/test/test_crypto/test_main.cpp b/test/test_crypto/test_main.cpp index 3847061573d..ec5a590514a 100644 --- a/test/test_crypto/test_main.cpp +++ b/test/test_crypto/test_main.cpp @@ -598,6 +598,31 @@ void test_AES_CCM_AEAD(void) TEST_ASSERT_FALSE( crypto->decryptPacketCCM(psk, fromNodeB, packetId, 6 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); } + + // ========================================================================= + // Test 11: Empty PSK — must return false, not crash + // ========================================================================= + { + CryptoKey emptyPsk; + memset(&emptyPsk, 0, sizeof(emptyPsk)); + emptyPsk.length = 0; + + uint32_t fromNode = 0xDEADBEEF; + uint64_t packetId = 0x12345678; + + uint8_t plaintext[8] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; + uint8_t ciphertextWithTag[8 + CryptoEngine::AEAD_TAG_SIZE]; + uint8_t decrypted[8]; + + // Encrypt with empty PSK must fail gracefully + TEST_ASSERT_FALSE(crypto->encryptPacketCCM(emptyPsk, fromNode, packetId, 8, plaintext, ciphertextWithTag)); + + // Decrypt with empty PSK must fail gracefully + // (use dummy ciphertext since encrypt failed) + memset(ciphertextWithTag, 0xAA, sizeof(ciphertextWithTag)); + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(emptyPsk, fromNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); + } } void setup() From 30b708a17edb87f878aa122ddbd791f478abb2a9 Mon Sep 17 00:00:00 2001 From: Matias Denda Date: Fri, 27 Feb 2026 11:03:20 -0300 Subject: [PATCH 4/8] Use true AES-128 for 16-byte PSKs instead of promoting to AES-256 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. --- src/mesh/CryptoEngine.cpp | 16 ++++----- src/mesh/CryptoEngine.h | 2 +- test/test_crypto/test_main.cpp | 65 ++++++++++++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index 5d71c7b865c..e93c9a36086 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -317,8 +317,11 @@ void CryptoEngine::hash(uint8_t *bytes, size_t numBytes) void CryptoEngine::aesSetKey(const uint8_t *key_bytes, size_t key_len) { aes = nullptr; - if (key_len != 0) { - aes = std::unique_ptr(new AESSmall256()); + if (key_len == 16) { + aes = std::unique_ptr(new AESSmall128()); + aes->setKey(key_bytes, 16); + } else if (key_len != 0) { + aes = std::unique_ptr(new AESSmall256()); aes->setKey(key_bytes, key_len); } } @@ -378,12 +381,8 @@ bool CryptoEngine::encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uin return false; } initNonce(fromNode, packetId); - // AESSmall256::setKey() requires exactly 32 bytes. CryptoKey.bytes is always - // 32 bytes (zero-padded by Channels::getKey()), so pass the full buffer. - // This effectively promotes AES-128 PSKs to AES-256 with zero-padded key. - size_t keyLen = (psk.length < 32) ? 32 : psk.length; // Output layout: [ciphertext (numBytes)] [auth_tag (AEAD_TAG_SIZE bytes)] - return aes_ccm_ae(psk.bytes, keyLen, nonce, AEAD_TAG_SIZE, plaintext, numBytes, nullptr, 0, ciphertextWithTag, + return aes_ccm_ae(psk.bytes, psk.length, nonce, AEAD_TAG_SIZE, plaintext, numBytes, nullptr, 0, ciphertextWithTag, ciphertextWithTag + numBytes) == 0; } @@ -397,10 +396,9 @@ bool CryptoEngine::decryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uin if (totalBytes <= AEAD_TAG_SIZE) return false; initNonce(fromNode, packetId); - size_t keyLen = (psk.length < 32) ? 32 : psk.length; size_t crypt_len = totalBytes - AEAD_TAG_SIZE; const uint8_t *auth = ciphertextWithTag + crypt_len; - return aes_ccm_ad(psk.bytes, keyLen, nonce, AEAD_TAG_SIZE, ciphertextWithTag, crypt_len, nullptr, 0, auth, plaintext); + return aes_ccm_ad(psk.bytes, psk.length, nonce, AEAD_TAG_SIZE, ciphertextWithTag, crypt_len, nullptr, 0, auth, plaintext); } concurrency::Lock *cryptLock; diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h index a5dcee1500a..a8749a68a71 100644 --- a/src/mesh/CryptoEngine.h +++ b/src/mesh/CryptoEngine.h @@ -74,7 +74,7 @@ class CryptoEngine virtual void aesSetKey(const uint8_t *key, size_t key_len); virtual void aesEncrypt(uint8_t *in, uint8_t *out); - std::unique_ptr aes = nullptr; + std::unique_ptr aes = nullptr; static constexpr size_t AEAD_TAG_SIZE = 12; diff --git a/test/test_crypto/test_main.cpp b/test/test_crypto/test_main.cpp index ec5a590514a..f1dc207e7bc 100644 --- a/test/test_crypto/test_main.cpp +++ b/test/test_crypto/test_main.cpp @@ -88,6 +88,29 @@ void test_ECB_AES256(void) crypto->aesEncrypt(plain, result); // Does 16 bytes at a time TEST_ASSERT_EQUAL_MEMORY(expected, result, 16); } +void test_ECB_AES128(void) +{ + // https://csrc.nist.gov/CSRC/media/Projects/Cryptographic-Standards-and-Guidelines/documents/examples/AES_ECB.pdf + uint8_t key[16] = {0}; + uint8_t plain[16] = {0}; + uint8_t result[16] = {0}; + uint8_t expected[16] = {0}; + + HexToBytes(key, "2B7E151628AED2A6ABF7158809CF4F3C"); + + HexToBytes(plain, "6BC1BEE22E409F96E93D7E117393172A"); + HexToBytes(expected, "3AD77BB40D7A3660A89ECAF32466EF97"); + crypto->aesSetKey(key, 16); + crypto->aesEncrypt(plain, result); + TEST_ASSERT_EQUAL_MEMORY(expected, result, 16); + + HexToBytes(plain, "AE2D8A571E03AC9C9EB76FAC45AF8E51"); + HexToBytes(expected, "F5D3D58503B9699DE785895A96FDBAAF"); + crypto->aesSetKey(key, 16); + crypto->aesEncrypt(plain, result); + TEST_ASSERT_EQUAL_MEMORY(expected, result, 16); +} + void test_DH25519(void) { // test vectors from wycheproof x25519 @@ -507,8 +530,7 @@ void test_AES_CCM_AEAD(void) } // ========================================================================= - // Test 7: Round-trip with AES-128 PSK (16-byte key, zero-padded to 256) - // Verifies that the key promotion to AES-256 works correctly. + // Test 7: Round-trip with AES-128 PSK (16-byte key, true AES-128-CCM) // ========================================================================= { CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); @@ -623,6 +645,44 @@ void test_AES_CCM_AEAD(void) TEST_ASSERT_FALSE(crypto->decryptPacketCCM(emptyPsk, fromNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); } + + // ========================================================================= + // Test 12: AES-128 vs AES-256 produce different ciphertexts + // Verifies that 16-byte keys use true AES-128, not AES-256 with padding. + // ========================================================================= + { + // Same 16 bytes of key material, but one is AES-128 (16 bytes) + // and the other is AES-256 (32 bytes, zero-padded). + CryptoKey psk128 = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); + CryptoKey psk256; + memset(psk256.bytes, 0, sizeof(psk256.bytes)); + HexToBytes(psk256.bytes, "d4f1bb3a20290759f0bcffabcf4e6901"); + psk256.length = 32; // same first 16 bytes, but treated as AES-256 + + uint32_t fromNode = 0x55AA55AA; + uint64_t packetId = 0x1234ABCD; + + uint8_t plaintext[8] = {0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80}; + uint8_t ct128[8 + CryptoEngine::AEAD_TAG_SIZE]; + uint8_t ct256[8 + CryptoEngine::AEAD_TAG_SIZE]; + + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk128, fromNode, packetId, 8, plaintext, ct128)); + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk256, fromNode, packetId, 8, plaintext, ct256)); + + // AES-128 and AES-256 with the same key material must produce different output + TEST_ASSERT_FALSE(memcmp(ct128, ct256, 8 + CryptoEngine::AEAD_TAG_SIZE) == 0); + + // Both must still round-trip correctly + uint8_t dec128[8], dec256[8]; + TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk128, fromNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ct128, dec128)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, dec128, 8); + TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk256, fromNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ct256, dec256)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, dec256, 8); + + // Cross-key decryption must fail + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk256, fromNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ct128, dec128)); + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk128, fromNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ct256, dec256)); + } } void setup() @@ -636,6 +696,7 @@ void setup() UNITY_BEGIN(); // IMPORTANT LINE! RUN_TEST(test_SHA256); RUN_TEST(test_SHA256_large_input); + RUN_TEST(test_ECB_AES128); RUN_TEST(test_ECB_AES256); RUN_TEST(test_DH25519); RUN_TEST(test_AES_CTR); From d83a33a5d0e3dc8f7a1b3a64f5eb9addd9478333 Mon Sep 17 00:00:00 2001 From: Matias Denda Date: Mon, 3 Aug 2026 12:55:07 -0300 Subject: [PATCH 5/8] Reject the invalid-key sentinel in the AEAD paths 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. --- src/mesh/CryptoEngine.cpp | 11 +++++++---- test/test_crypto/test_main.cpp | 11 +++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index e93c9a36086..40983839ebd 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -376,8 +376,11 @@ bool CryptoEngine::getPendingPublicKey(uint32_t node, meshtastic_NodeInfoLite_pu bool CryptoEngine::encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, size_t numBytes, const uint8_t *plaintext, uint8_t *ciphertextWithTag) { - if (psk.length == 0) { - LOG_ERROR("AEAD encryption requires a non-empty PSK"); + // CryptoKey documents -1 as "invalid key - do not use". psk.length is int8_t and the + // aes_ccm_* key length is size_t, so a negative sentinel would widen into a huge + // unsigned length instead of being rejected. + if (psk.length <= 0) { + LOG_ERROR("AEAD encryption requires a valid, non-empty PSK"); return false; } initNonce(fromNode, packetId); @@ -389,8 +392,8 @@ bool CryptoEngine::encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uin bool CryptoEngine::decryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, size_t totalBytes, const uint8_t *ciphertextWithTag, uint8_t *plaintext) { - if (psk.length == 0) { - LOG_ERROR("AEAD decryption requires a non-empty PSK"); + if (psk.length <= 0) { + LOG_ERROR("AEAD decryption requires a valid, non-empty PSK"); return false; } if (totalBytes <= AEAD_TAG_SIZE) diff --git a/test/test_crypto/test_main.cpp b/test/test_crypto/test_main.cpp index f1dc207e7bc..c7515d02166 100644 --- a/test/test_crypto/test_main.cpp +++ b/test/test_crypto/test_main.cpp @@ -644,6 +644,17 @@ void test_AES_CCM_AEAD(void) memset(ciphertextWithTag, 0xAA, sizeof(ciphertextWithTag)); TEST_ASSERT_FALSE(crypto->decryptPacketCCM(emptyPsk, fromNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); + + // CryptoKey uses -1 as the "invalid key - do not use" sentinel. length is int8_t + // and the aes_ccm_* key length is size_t, so -1 would widen to a huge unsigned + // length rather than being rejected. Both directions must refuse it. + CryptoKey invalidPsk; + memset(&invalidPsk, 0, sizeof(invalidPsk)); + invalidPsk.length = -1; + + TEST_ASSERT_FALSE(crypto->encryptPacketCCM(invalidPsk, fromNode, packetId, 8, plaintext, ciphertextWithTag)); + TEST_ASSERT_FALSE(crypto->decryptPacketCCM(invalidPsk, fromNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, + ciphertextWithTag, decrypted)); } // ========================================================================= From b9fdf8091ce5016af2f0b992c84484affb637d16 Mon Sep 17 00:00:00 2001 From: Matias Denda Date: Mon, 3 Aug 2026 13:31:57 -0300 Subject: [PATCH 6/8] Tie MESHTASTIC_AEAD_OVERHEAD to CryptoEngine::AEAD_TAG_SIZE 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. --- src/mesh/CryptoEngine.cpp | 5 ++--- src/mesh/Router.cpp | 5 +++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index 40983839ebd..cb5d6e899d9 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -376,9 +376,8 @@ bool CryptoEngine::getPendingPublicKey(uint32_t node, meshtastic_NodeInfoLite_pu bool CryptoEngine::encryptPacketCCM(const CryptoKey &psk, uint32_t fromNode, uint64_t packetId, size_t numBytes, const uint8_t *plaintext, uint8_t *ciphertextWithTag) { - // CryptoKey documents -1 as "invalid key - do not use". psk.length is int8_t and the - // aes_ccm_* key length is size_t, so a negative sentinel would widen into a huge - // unsigned length instead of being rejected. + // length is int8_t and the aes_ccm_* key length is size_t, so the -1 "invalid key" + // sentinel would widen into a huge unsigned length rather than being rejected. if (psk.length <= 0) { LOG_ERROR("AEAD encryption requires a valid, non-empty PSK"); return false; diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 88bfc1b61eb..1f5eb9cf00d 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -31,6 +31,11 @@ #include "serialization/MeshPacketSerializer.h" #endif +// The size checks below budget for the tag that encryptPacketCCM actually appends, so the +// two constants must not drift apart. +static_assert(MESHTASTIC_AEAD_OVERHEAD == CryptoEngine::AEAD_TAG_SIZE, + "MESHTASTIC_AEAD_OVERHEAD must match CryptoEngine::AEAD_TAG_SIZE"); + #define MAX_RX_FROMRADIO \ 4 // max number of packets destined to our queue, we dispatch packets quickly so it doesn't need to be big From 72e6484f2d9df53ef8d8d03a22dac0c16a2c5e40 Mon Sep 17 00:00:00 2001 From: Matias Denda Date: Tue, 4 Aug 2026 19:03:55 -0300 Subject: [PATCH 7/8] Add RFC 3610 known-answer vectors and widen the tamper sweep Packet Vectors #1, #2 and #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/test_crypto/test_main.cpp | 89 +++++++++++++++++++++++----------- 1 file changed, 61 insertions(+), 28 deletions(-) diff --git a/test/test_crypto/test_main.cpp b/test/test_crypto/test_main.cpp index c7515d02166..b38f0326cc5 100644 --- a/test/test_crypto/test_main.cpp +++ b/test/test_crypto/test_main.cpp @@ -1,6 +1,5 @@ // trunk-ignore-all(gitleaks): These are dummy values. Not real secrets. #include "CryptoEngine.h" -#include "aes-ccm.h" #include "TestUtil.h" #include "aes-ccm.h" @@ -382,6 +381,62 @@ void test_AES_CCM_partial_block_bounds(void) } } +void test_AES_CCM_rfc3610(void) +{ + // Known-answer vectors from RFC 3610 section 8. They all use L=2, which is what + // aes_ccm_ae()/aes_ccm_ad() hardcode, and each ends in a partial block. + struct CcmVector { + const char *key; + const char *nonce; + const char *aad; + const char *plain; + const char *crypt; + const char *tag; + }; + const CcmVector vectors[] = { + // Packet Vector #1, M=8 + {"C0C1C2C3C4C5C6C7C8C9CACBCCCDCECF", "00000003020100A0A1A2A3A4A5", "0001020304050607", + "08090A0B0C0D0E0F101112131415161718191A1B1C1D1E", "588C979A61C663D2F066D0C2C0F989806D5F6B61DAC384", "17E8D12CFDF926E0"}, + // Packet Vector #2, M=8 + {"C0C1C2C3C4C5C6C7C8C9CACBCCCDCECF", "00000004030201A0A1A2A3A4A5", "0001020304050607", + "08090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F", "72C91A36E135F8CF291CA894085C87E3CC15C439C9E43A3B", + "A091D56E10400916"}, + // Packet Vector #7, M=10 + {"C0C1C2C3C4C5C6C7C8C9CACBCCCDCECF", "00000009080706A0A1A2A3A4A5", "0001020304050607", + "08090A0B0C0D0E0F101112131415161718191A1B1C1D1E", "0135D1B2C95F41D5D1D4FEC185D166B8094E999DFED96C", + "048C56602C97ACBB7490"}, + }; + + for (size_t v = 0; v < sizeof(vectors) / sizeof(vectors[0]); v++) { + const CcmVector &vec = vectors[v]; + const size_t plainLen = strlen(vec.plain) / 2; + const size_t aadLen = strlen(vec.aad) / 2; + const size_t tagLen = strlen(vec.tag) / 2; + + uint8_t key[16], nonce[13], aad[8]; + uint8_t plain[32], expectedCrypt[32], expectedTag[16]; + uint8_t crypt[32], tag[16], decrypted[32]; + + HexToBytes(key, vec.key); + HexToBytes(nonce, vec.nonce); + HexToBytes(aad, vec.aad); + HexToBytes(plain, vec.plain); + HexToBytes(expectedCrypt, vec.crypt); + HexToBytes(expectedTag, vec.tag); + + TEST_ASSERT_EQUAL(0, aes_ccm_ae(key, sizeof(key), nonce, tagLen, plain, plainLen, aad, aadLen, crypt, tag)); + TEST_ASSERT_EQUAL_MEMORY(expectedCrypt, crypt, plainLen); + TEST_ASSERT_EQUAL_MEMORY(expectedTag, tag, tagLen); + + TEST_ASSERT_TRUE(aes_ccm_ad(key, sizeof(key), nonce, tagLen, crypt, plainLen, aad, aadLen, tag, decrypted)); + TEST_ASSERT_EQUAL_MEMORY(plain, decrypted, plainLen); + + // The AAD is authenticated but not encrypted: corrupting it must fail the tag check + aad[0] ^= 0x01; + TEST_ASSERT_FALSE(aes_ccm_ad(key, sizeof(key), nonce, tagLen, crypt, plainLen, aad, aadLen, tag, decrypted)); + } +} + // Helper to create a zero-initialized CryptoKey (matching Channels::getKey() behavior) static CryptoKey makePsk(const std::string &hex) { @@ -394,9 +449,8 @@ static CryptoKey makePsk(const std::string &hex) void test_AES_CCM_AEAD(void) { - // ========================================================================= - // Test 1: Known-answer AES-CCM encrypt + verify tag - // ========================================================================= + // Test 1: Smoke test — encryption changes the payload and produces a tag + // (the known-answer coverage lives in test_AES_CCM_rfc3610) { CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); @@ -425,9 +479,7 @@ void test_AES_CCM_AEAD(void) TEST_ASSERT_FALSE(tagAllZero); } - // ========================================================================= // Test 2: Round-trip encrypt → decrypt → compare (AES-256) - // ========================================================================= { CryptoKey psk = makePsk("603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4"); @@ -449,9 +501,7 @@ void test_AES_CCM_AEAD(void) TEST_ASSERT_EQUAL_MEMORY(msg, decrypted, msgLen); } - // ========================================================================= // Test 3: Tampered ciphertext — flip a bit, verify rejection - // ========================================================================= { CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); @@ -471,9 +521,7 @@ void test_AES_CCM_AEAD(void) crypto->decryptPacketCCM(psk, fromNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); } - // ========================================================================= // Test 4: Tampered auth tag — modify tag, verify rejection - // ========================================================================= { CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); @@ -495,9 +543,7 @@ void test_AES_CCM_AEAD(void) crypto->decryptPacketCCM(psk, fromNode, packetId, 16 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); } - // ========================================================================= // Test 5: Packet too small for AEAD — totalBytes <= AEAD_TAG_SIZE - // ========================================================================= { CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); @@ -508,9 +554,7 @@ void test_AES_CCM_AEAD(void) TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, 0x1234, 0x5678, 0, dummy, out)); } - // ========================================================================= // Test 6: Wrong PSK — decrypt with different key, verify rejection - // ========================================================================= { CryptoKey pskA = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); CryptoKey pskB = makePsk("00112233445566778899aabbccddeeff"); @@ -529,9 +573,7 @@ void test_AES_CCM_AEAD(void) crypto->decryptPacketCCM(pskB, fromNode, packetId, 12 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); } - // ========================================================================= // Test 7: Round-trip with AES-128 PSK (16-byte key, true AES-128-CCM) - // ========================================================================= { CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); @@ -549,9 +591,7 @@ void test_AES_CCM_AEAD(void) TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 20); } - // ========================================================================= // Test 8: AES-256-CCM round-trip + per-byte tamper detection - // ========================================================================= { CryptoKey psk = makePsk("603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4"); @@ -571,8 +611,8 @@ void test_AES_CCM_AEAD(void) crypto->decryptPacketCCM(psk, fromNode, packetId, 32 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 32); - // Tamper with each of first 4 ciphertext bytes and verify rejection - for (int i = 0; i < 4; i++) { + // Flip a bit in every byte in turn, tag included, and verify each one is rejected + for (size_t i = 0; i < 32 + CryptoEngine::AEAD_TAG_SIZE; i++) { uint8_t tampered[32 + CryptoEngine::AEAD_TAG_SIZE]; memcpy(tampered, ciphertextWithTag, sizeof(tampered)); tampered[i] ^= 0x80; @@ -581,9 +621,7 @@ void test_AES_CCM_AEAD(void) } } - // ========================================================================= // Test 9: Deterministic — same inputs produce same output - // ========================================================================= { CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); @@ -600,9 +638,7 @@ void test_AES_CCM_AEAD(void) TEST_ASSERT_EQUAL_MEMORY(ct1, ct2, 5 + CryptoEngine::AEAD_TAG_SIZE); } - // ========================================================================= // Test 10: Wrong fromNode — nonce mismatch, verify rejection - // ========================================================================= { CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); @@ -621,9 +657,7 @@ void test_AES_CCM_AEAD(void) crypto->decryptPacketCCM(psk, fromNodeB, packetId, 6 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); } - // ========================================================================= // Test 11: Empty PSK — must return false, not crash - // ========================================================================= { CryptoKey emptyPsk; memset(&emptyPsk, 0, sizeof(emptyPsk)); @@ -657,10 +691,8 @@ void test_AES_CCM_AEAD(void) ciphertextWithTag, decrypted)); } - // ========================================================================= // Test 12: AES-128 vs AES-256 produce different ciphertexts // Verifies that 16-byte keys use true AES-128, not AES-256 with padding. - // ========================================================================= { // Same 16 bytes of key material, but one is AES-128 (16 bytes) // and the other is AES-256 (32 bytes, zero-padded). @@ -712,6 +744,7 @@ void setup() RUN_TEST(test_DH25519); RUN_TEST(test_AES_CTR); RUN_TEST(test_AES_CCM_partial_block_bounds); + RUN_TEST(test_AES_CCM_rfc3610); RUN_TEST(test_PKC); RUN_TEST(test_XEdDSA); RUN_TEST(test_XEdDSA_cross_key_reject); From 1512b2575fff11cf985c7632d24d0d6405675251 Mon Sep 17 00:00:00 2001 From: Matias Denda Date: Tue, 4 Aug 2026 20:01:38 -0300 Subject: [PATCH 8/8] Cover the second nonce input and tighten the AEAD test buffers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- test/test_crypto/test_main.cpp | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/test/test_crypto/test_main.cpp b/test/test_crypto/test_main.cpp index b38f0326cc5..1704a59b5dc 100644 --- a/test/test_crypto/test_main.cpp +++ b/test/test_crypto/test_main.cpp @@ -4,6 +4,7 @@ #include "TestUtil.h" #include "aes-ccm.h" #include +#include #include void HexToBytes(uint8_t *result, const std::string hex, size_t len = 0) @@ -441,6 +442,7 @@ void test_AES_CCM_rfc3610(void) 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); @@ -548,7 +550,9 @@ void test_AES_CCM_AEAD(void) CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); uint8_t dummy[CryptoEngine::AEAD_TAG_SIZE] = {0}; - uint8_t out[1]; + // Sized for the whole input so a regressed length guard fails the assertion below + // instead of corrupting the stack on its way out. + uint8_t out[CryptoEngine::AEAD_TAG_SIZE]; TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, 0x1234, 0x5678, CryptoEngine::AEAD_TAG_SIZE, dummy, out)); TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, 0x1234, 0x5678, 0, dummy, out)); @@ -638,23 +642,35 @@ void test_AES_CCM_AEAD(void) TEST_ASSERT_EQUAL_MEMORY(ct1, ct2, 5 + CryptoEngine::AEAD_TAG_SIZE); } - // Test 10: Wrong fromNode — nonce mismatch, verify rejection + // Test 10: Wrong nonce input — the nonce derives from both fromNode and packetId, + // so each one on its own must be enough to make the tag check fail { CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901"); uint32_t fromNodeA = 0x11111111; uint32_t fromNodeB = 0x22222222; - uint64_t packetId = 0xAAAABBBB; + uint64_t packetIdA = 0xAAAABBBB; + uint64_t packetIdB = 0xCCCCDDDD; uint8_t plaintext[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; uint8_t ciphertextWithTag[6 + CryptoEngine::AEAD_TAG_SIZE]; - TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNodeA, packetId, 6, plaintext, ciphertextWithTag)); + TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNodeA, packetIdA, 6, plaintext, ciphertextWithTag)); - // Decrypt with different fromNode (nonce will differ) uint8_t decrypted[6]; + // Wrong fromNode, right packetId TEST_ASSERT_FALSE( - crypto->decryptPacketCCM(psk, fromNodeB, packetId, 6 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); + crypto->decryptPacketCCM(psk, fromNodeB, packetIdA, 6 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); + // Right fromNode, wrong packetId + TEST_ASSERT_FALSE( + crypto->decryptPacketCCM(psk, fromNodeA, packetIdB, 6 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); + // Both wrong + TEST_ASSERT_FALSE( + crypto->decryptPacketCCM(psk, fromNodeB, packetIdB, 6 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); + // Both right still succeeds, so the assertions above are not passing for free + TEST_ASSERT_TRUE( + crypto->decryptPacketCCM(psk, fromNodeA, packetIdA, 6 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); + TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 6); } // Test 11: Empty PSK — must return false, not crash @@ -679,9 +695,8 @@ void test_AES_CCM_AEAD(void) TEST_ASSERT_FALSE(crypto->decryptPacketCCM(emptyPsk, fromNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted)); - // CryptoKey uses -1 as the "invalid key - do not use" sentinel. length is int8_t - // and the aes_ccm_* key length is size_t, so -1 would widen to a huge unsigned - // length rather than being rejected. Both directions must refuse it. + // CryptoKey uses -1 as its "invalid key - do not use" sentinel, and it would widen + // into a huge unsigned length rather than be rejected. Both directions must refuse it. CryptoKey invalidPsk; memset(&invalidPsk, 0, sizeof(invalidPsk)); invalidPsk.length = -1;