Files
firmware/test/test_crypto/test_main.cpp
T
e0c76fd41e Improve performance of encrypted packets with a shared secret cache (#11979)
* Improve performance of encrypted packets with a shared secret cache

Every PKI encrypt, decrypt and ack-proof ran Curve25519::dh2 plus a SHA256
to derive the pairwise key, so a node in a conversation paid a full X25519
per packet, on the main loop, under cryptLock. On a RAK4631 that is ~96 ms
of the ~210 ms it takes to handle a DM.

setCryptoSharedSecret() derives the key only when it is not already held
for that peer, keeping the last MAX_CACHED_SHARED_SECRETS derivations
(8 on nRF52, 2 on STM32WL, 10 elsewhere, under 400 bytes) and evicting the
least recently used. encryptCurve25519, decryptCurve25519 and
ackProofCompute all go through it, so the ack proof gets the same cache
without a second DH path. The cache is emptied whenever our own private
key changes, since every secret in it is then stale.

The lookup key is the first 4 bytes of the peer's public key. A collision
makes us derive against the wrong cached secret, which costs a failed
decrypt for that pair; it cannot disclose either secret.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLHxcWJuvSoSSMpz3LWdt3

* Do not let an empty cache slot answer for a zero-prefixed peer key

The cache used lookup_key == 0 to mean "slot unused", so a peer key whose
first 4 bytes are zero matched every unused slot and was handed that slot's
zeroed secret as a hit. The all-zero key is exactly such a key, which is how
test_proof_rejects_weak_peer_key caught it: dh2's weak-point check never ran.

Entries carry an explicit valid flag instead, which the struct's existing
padding absorbs. A weak peer key is also now rejected before the cache is
consulted, since with 4 bytes of lookup key it could otherwise collide with a
cached peer and be served that peer's secret rather than being refused.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLHxcWJuvSoSSMpz3LWdt3

* Key the shared secret cache by the whole peer public key

A four-byte lookup key is grindable: anyone can generate a keypair whose
public key shares those bytes with a peer they want to shadow, get their own
entry cached, and then be handed the secret this node uses to talk to that
peer - readable by them, since they hold the matching private key. That is
disclosure of traffic meant for the peer, and a forgeable ack proof in its
name, not the failed exchange a chance collision would cause.

Entries hold the peer key itself and are matched on all 32 bytes, so the
weak-key check dh2 does on a miss can no longer be skipped either and the
explicit isWeakPoint guard goes away with it. The cache costs 66 bytes per
entry: 528 on nRF52, 660 where the default 10 entries apply.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLHxcWJuvSoSSMpz3LWdt3

* Allowlist the cache's uptime shift for the millis deadline guard

The guard's regex reads `millis() >> 22` as a comparison against the uptime
clock. It is a right shift, coarsening uptime into the ~1.165 hour units the
cache stamps entries with, and the eviction arithmetic handles that stamp's
8-bit wrap itself.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLHxcWJuvSoSSMpz3LWdt3

---------

Co-authored-by: Jason B. Cox <contact@jasonbcox.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 17:47:25 +00:00

1043 lines
45 KiB
C++

// trunk-ignore-all(gitleaks): These are dummy values. Not real secrets.
#include "CryptoEngine.h"
#include "mesh/Router.h" // BITFIELD_* masks the signing buffer covers
#include "TestUtil.h"
#include "aes-ccm.h"
#include <XEdDSA.h>
#include <cassert>
#include <unity.h>
void HexToBytes(uint8_t *result, const std::string hex, size_t len = 0)
{
if (len) {
memset(result, 0, len);
}
for (unsigned int i = 0; i < hex.length(); i += 2) {
std::string byteString = hex.substr(i, 2);
result[i / 2] = (uint8_t)strtol(byteString.c_str(), NULL, 16);
}
return;
}
void setUp(void)
{
// set stuff up here
}
void tearDown(void)
{
// clean stuff up here
}
void test_SHA256(void)
{
uint8_t expected[32];
uint8_t hash[32] = {0};
HexToBytes(expected, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
crypto->hash(hash, 0);
TEST_ASSERT_EQUAL_MEMORY(hash, expected, 32);
HexToBytes(hash, "d3", 32);
HexToBytes(expected, "28969cdfa74a12c82f3bad960b0b000aca2ac329deea5c2328ebc6f2ba9802c1");
crypto->hash(hash, 1);
TEST_ASSERT_EQUAL_MEMORY(hash, expected, 32);
HexToBytes(hash, "11af", 32);
HexToBytes(expected, "5ca7133fa735326081558ac312c620eeca9970d1e70a4b95533d956f072d1f98");
crypto->hash(hash, 2);
TEST_ASSERT_EQUAL_MEMORY(hash, expected, 32);
}
void test_SHA256_large_input(void)
{
uint8_t hash[300] = {0};
uint8_t expected[32];
HexToBytes(expected, "d13d4a8b3b8add19b5970157f09d00c12cbda4fed4d74d8493156523f7069b66");
crypto->hash(hash, sizeof(hash));
TEST_ASSERT_EQUAL_MEMORY(hash, expected, sizeof(expected));
}
void test_ECB_AES256(void)
{
// https://csrc.nist.gov/CSRC/media/Projects/Cryptographic-Standards-and-Guidelines/documents/examples/AES_ECB.pdf
uint8_t key[32] = {0};
uint8_t plain[16] = {0};
uint8_t result[16] = {0};
uint8_t expected[16] = {0};
HexToBytes(key, "603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4");
HexToBytes(plain, "6BC1BEE22E409F96E93D7E117393172A");
HexToBytes(expected, "F3EED1BDB5D2A03C064B5A7E3DB181F8");
crypto->aesSetKey(key, 32);
crypto->aesEncrypt(plain, result); // Does 16 bytes at a time
TEST_ASSERT_EQUAL_MEMORY(expected, result, 16);
HexToBytes(plain, "AE2D8A571E03AC9C9EB76FAC45AF8E51");
HexToBytes(expected, "591CCB10D410ED26DC5BA74A31362870");
crypto->aesSetKey(key, 32);
crypto->aesEncrypt(plain, result); // Does 16 bytes at a time
TEST_ASSERT_EQUAL_MEMORY(expected, result, 16);
HexToBytes(plain, "30C81C46A35CE411E5FBC1191A0A52EF");
HexToBytes(expected, "B6ED21B99CA6F4F9F153E7B1BEAFED1D");
crypto->aesSetKey(key, 32);
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
// https://github.com/C2SP/wycheproof/blob/master/testvectors/x25519_test.json
uint8_t private_key[32];
uint8_t public_key[32];
uint8_t expected_shared[32];
HexToBytes(public_key, "504a36999f489cd2fdbc08baff3d88fa00569ba986cba22548ffde80f9806829");
HexToBytes(private_key, "c8a9d5a91091ad851c668b0736c1c9a02936c0d3ad62670858088047ba057475");
HexToBytes(expected_shared, "436a2c040cf45fea9b29a0cb81b1f41458f863d0d61b453d0a982720d6d61320");
crypto->setDHPrivateKey(private_key);
TEST_ASSERT(crypto->setDHPublicKey(public_key));
TEST_ASSERT_EQUAL_MEMORY(expected_shared, crypto->shared_key, 32);
HexToBytes(public_key, "63aa40c6e38346c5caf23a6df0a5e6c80889a08647e551b3563449befcfc9733");
HexToBytes(private_key, "d85d8c061a50804ac488ad774ac716c3f5ba714b2712e048491379a500211958");
HexToBytes(expected_shared, "279df67a7c4611db4708a0e8282b195e5ac0ed6f4b2f292c6fbd0acac30d1332");
crypto->setDHPrivateKey(private_key);
TEST_ASSERT(crypto->setDHPublicKey(public_key));
TEST_ASSERT_EQUAL_MEMORY(expected_shared, crypto->shared_key, 32);
HexToBytes(public_key, "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f");
HexToBytes(private_key, "18630f93598637c35da623a74559cf944374a559114c7937811041fc8605564a");
crypto->setDHPrivateKey(private_key);
TEST_ASSERT(!crypto->setDHPublicKey(public_key)); // Weak public key results in 0 shared key
HexToBytes(public_key, "f7e13a1a067d2f4e1061bf9936fde5be6b0c2494a8f809cbac7f290ef719e91c");
HexToBytes(private_key, "10300724f3bea134eb1575245ef26ff9b8ccd59849cd98ce1a59002fe1d5986c");
HexToBytes(expected_shared, "24becd5dfed9e9289ba2e15b82b0d54f8e9aacb72f5e4248c58d8d74b451ce76");
crypto->setDHPrivateKey(private_key);
TEST_ASSERT(crypto->setDHPublicKey(public_key));
crypto->hash(crypto->shared_key, 32);
TEST_ASSERT_EQUAL_MEMORY(expected_shared, crypto->shared_key, 32);
}
void test_PKC(void)
{
uint8_t private_key[32];
meshtastic_NodeInfoLite_public_key_t public_key;
uint8_t expected_shared[32];
uint8_t expected_decrypted[32];
uint8_t radioBytes[128] __attribute__((__aligned__));
uint8_t decrypted[128] __attribute__((__aligned__));
uint8_t expected_nonce[16];
uint32_t fromNode = 0x0929;
uint64_t packetNum = 0x13b2d662;
HexToBytes(public_key.bytes, "db18fc50eea47f00251cb784819a3cf5fc361882597f589f0d7ff820e8064457");
public_key.size = 32;
HexToBytes(private_key, "a00330633e63522f8a4d81ec6d9d1e6617f6c8ffd3a4c698229537d44e522277");
HexToBytes(expected_shared, "777b1545c9d6f9a2");
HexToBytes(expected_decrypted, "08011204746573744800");
HexToBytes(radioBytes, "8c646d7a2909000062d6b2136b00000040df24abfcc30a17a3d9046726099e796a1c036a792b");
HexToBytes(expected_nonce, "62d6b213036a792b2909000000");
crypto->setDHPrivateKey(private_key);
TEST_ASSERT(crypto->decryptCurve25519(fromNode, public_key, packetNum, 22, radioBytes + 16, decrypted));
TEST_ASSERT_EQUAL_MEMORY(expected_shared, crypto->shared_key, 8);
TEST_ASSERT_EQUAL_MEMORY(expected_nonce, crypto->nonce, 13);
TEST_ASSERT_EQUAL_MEMORY(expected_decrypted, decrypted, 10);
uint32_t toNode = 0; // Only impacts logging
uint8_t encrypted[128] __attribute__((__aligned__));
TEST_ASSERT(crypto->encryptCurve25519(toNode, fromNode, public_key, packetNum, 10, decrypted, encrypted));
TEST_ASSERT_EQUAL_MEMORY(expected_shared, crypto->shared_key, 8);
// The extraNonce is random, so skip checking the nonce and encrypted output here
// Copy the nonce to check it after encryption
memcpy(expected_nonce, crypto->nonce, 16);
// Decrypt the re-encrypted bytes and check they are the same as what we expect
TEST_ASSERT(crypto->decryptCurve25519(fromNode, public_key, packetNum, 22, encrypted, decrypted));
TEST_ASSERT_EQUAL_MEMORY(expected_shared, crypto->shared_key, 8);
TEST_ASSERT_EQUAL_MEMORY(expected_nonce, crypto->nonce, 13);
TEST_ASSERT_EQUAL_MEMORY(expected_decrypted, decrypted, 10);
}
// The signature covers the whole Data envelope, not just its payload, so these cases build a Data
// rather than passing bare bytes. Fields left zero are what an ordinary packet carries.
static meshtastic_Data makeSignableData(const uint8_t *payload, size_t len, uint32_t portnum = 1)
{
meshtastic_Data d = meshtastic_Data_init_zero;
d.portnum = (meshtastic_PortNum)portnum;
d.payload.size = (pb_size_t)len;
memcpy(d.payload.bytes, payload, len);
return d;
}
void test_XEdDSA(void)
{
uint8_t private_key[32];
uint8_t x_public_key[32];
uint8_t ed_private_key[32];
uint8_t ed_public_key[32];
uint8_t ed_public_key2[32];
uint8_t message[] = "This is a test!";
uint8_t message2[] = "This is a test.";
uint8_t signature[64];
uint32_t fromNode = 0x1234;
uint32_t packetId = 0xDEADBEEF;
uint32_t toNode = 0x5678;
// Every envelope field the buffer covers is set nonzero, so each negative case below is
// actually flipping something that was signed.
meshtastic_Data d = makeSignableData(message, sizeof(message));
d.request_id = 0xCAFE0001;
d.reply_id = 0xCAFE0002;
d.emoji = 0xCAFE0003;
d.has_bitfield = true;
d.bitfield = BITFIELD_OK_TO_MQTT_MASK;
d.want_response = true;
for (int times = 0; times < 10; times++) {
printf("Start of time %u\n", times);
crypto->generateKeyPair(x_public_key, private_key);
XEdDSA::priv_curve_to_ed_keys(private_key, ed_private_key, ed_public_key);
crypto->curve_to_ed_pub(x_public_key, ed_public_key2);
TEST_ASSERT_EQUAL_MEMORY(ed_public_key, ed_public_key2, 32);
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, toNode, &d, signature));
TEST_ASSERT(crypto->xeddsa_verify(x_public_key, fromNode, packetId, toNode, &d, signature));
// Header fields outside the Data envelope.
TEST_ASSERT_FALSE_MESSAGE(crypto->xeddsa_verify(x_public_key, fromNode + 1, packetId, toNode, &d, signature),
"reattribution to another sender must fail");
TEST_ASSERT_FALSE_MESSAGE(crypto->xeddsa_verify(x_public_key, fromNode, packetId + 1, toNode, &d, signature),
"replay under another packet id must fail");
// Re-addressing a signed broadcast as a direct message would otherwise deliver a public
// statement as an apparent private one.
TEST_ASSERT_FALSE_MESSAGE(crypto->xeddsa_verify(x_public_key, fromNode, packetId, toNode + 1, &d, signature),
"re-addressing the packet must fail");
// Each Data field, flipped one at a time.
meshtastic_Data t = d;
t.payload.size = sizeof(message2);
memcpy(t.payload.bytes, message2, sizeof(message2));
TEST_ASSERT_FALSE_MESSAGE(crypto->xeddsa_verify(x_public_key, fromNode, packetId, toNode, &t, signature),
"payload tampering must fail");
t = d;
t.portnum = (meshtastic_PortNum)(d.portnum + 1);
TEST_ASSERT_FALSE_MESSAGE(crypto->xeddsa_verify(x_public_key, fromNode, packetId, toNode, &t, signature),
"portnum redirection must fail");
t = d;
t.request_id++;
TEST_ASSERT_FALSE_MESSAGE(crypto->xeddsa_verify(x_public_key, fromNode, packetId, toNode, &t, signature),
"retargeting at another request must fail");
t = d;
t.reply_id++;
TEST_ASSERT_FALSE_MESSAGE(crypto->xeddsa_verify(x_public_key, fromNode, packetId, toNode, &t, signature),
"re-pointing a reply or tapback at another message must fail");
t = d;
t.emoji++;
TEST_ASSERT_FALSE_MESSAGE(crypto->xeddsa_verify(x_public_key, fromNode, packetId, toNode, &t, signature),
"turning a reply into a reaction must fail");
t = d;
t.bitfield ^= BITFIELD_OK_TO_MQTT_MASK;
TEST_ASSERT_FALSE_MESSAGE(crypto->xeddsa_verify(x_public_key, fromNode, packetId, toNode, &t, signature),
"flipping the MQTT upload consent must fail");
// Stripping the optional field is distinct from sending it zero, so presence is signed too.
t = d;
t.has_bitfield = false;
t.bitfield = 0;
TEST_ASSERT_FALSE_MESSAGE(crypto->xeddsa_verify(x_public_key, fromNode, packetId, toNode, &t, signature),
"stripping the bitfield must fail");
t = d;
t.want_response = false;
TEST_ASSERT_FALSE_MESSAGE(crypto->xeddsa_verify(x_public_key, fromNode, packetId, toNode, &t, signature),
"clearing want_response must fail");
}
}
// The payload boundary is a fixed offset, never derived from content. If it were conditional, an
// attacker could move payload bytes into the envelope fields (or the reverse) and produce the same
// signed bytes - truncating a signed message while its signature still verified. Two shapes that
// differ only in where the split falls must therefore sign differently.
void test_XEdDSA_layout_is_unambiguous(void)
{
uint8_t pub[32], priv[32], sigA[64];
crypto->generateKeyPair(pub, priv);
const uint32_t fromNode = 0x77, packetId = 0x1CEB00DA, toNode = 0xFFFFFFFF;
uint8_t whole[] = {0xA1, 0xA2, 0xA3, 0xA4, 0xB1, 0xB2, 0xB3, 0xB4, 'h', 'e', 'l', 'l', 'o'};
// A plain packet whose payload begins with eight bytes an attacker would like to re-read as
// request_id and reply_id.
meshtastic_Data plain = makeSignableData(whole, sizeof(whole));
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, toNode, &plain, sigA));
// The same bytes re-split: those eight moved into the envelope, payload truncated to "hello".
meshtastic_Data split = makeSignableData(whole + 8, sizeof(whole) - 8);
split.request_id = 0xA4A3A2A1;
split.reply_id = 0xB4B3B2B1;
TEST_ASSERT_FALSE_MESSAGE(crypto->xeddsa_verify(pub, fromNode, packetId, toNode, &split, sigA),
"a re-split of the same bytes must not verify under the original signature");
}
// A signature only verifies under the signer's own key; a different key (or an all-zero key) fails.
void test_XEdDSA_cross_key_reject(void)
{
uint8_t pubA[32], privA[32];
uint8_t pubB[32], privB[32];
uint8_t signature[64];
uint8_t message[] = "cross-key check";
uint32_t fromNode = 0x4242, packetId = 0xABCD1234, toNode = 0x99;
meshtastic_Data d = makeSignableData(message, sizeof(message), 7);
d.request_id = 0x77;
d.reply_id = 0x88;
crypto->generateKeyPair(pubA, privA); // engine now holds key A
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, toNode, &d, signature));
crypto->generateKeyPair(pubB, privB); // unrelated key pair
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubA, fromNode, packetId, toNode, &d, signature));
TEST_ASSERT_FALSE(crypto->xeddsa_verify(pubB, fromNode, packetId, toNode, &d, signature));
uint8_t zeroKey[32] = {0};
TEST_ASSERT_FALSE(crypto->xeddsa_verify(zeroKey, fromNode, packetId, toNode, &d, signature));
}
// Signing with an unset (all-zero) private key must fail rather than emit a bogus signature.
void test_XEdDSA_empty_key_sign_fails(void)
{
CryptoEngine fresh; // freshly constructed: xeddsa_private_key is all zero
uint8_t signature[64];
uint8_t message[] = "no key";
meshtastic_Data d = makeSignableData(message, sizeof(message), 3);
TEST_ASSERT_FALSE(fresh.xeddsa_sign(0x1, 0x2, 0x3, &d, signature));
}
// curve_to_ed_pub caches the last converted key; verifying A, then B, then A must stay correct.
void test_XEdDSA_curve_to_ed_cache(void)
{
uint8_t pubA[32], privA[32], sigA[64];
uint8_t pubB[32], privB[32], sigB[64];
uint8_t message[] = "cache check";
uint32_t fromNode = 0x11, packetId = 0x22, toNode = 0x33;
meshtastic_Data d = makeSignableData(message, sizeof(message), 3);
d.request_id = 0x44;
d.reply_id = 0x55;
crypto->generateKeyPair(pubA, privA);
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, toNode, &d, sigA));
crypto->generateKeyPair(pubB, privB);
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, toNode, &d, sigB));
// Interleave keys to exercise both cache hits and cache invalidation.
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubA, fromNode, packetId, toNode, &d, sigA));
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubB, fromNode, packetId, toNode, &d, sigB));
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pubA, fromNode, packetId, toNode, &d, sigA));
TEST_ASSERT_FALSE(crypto->xeddsa_verify(pubA, fromNode, packetId, toNode, &d, sigB));
}
// The largest payload the Data schema can hold must still fit the signing buffer. An overflow makes
// buildSigningBuffer return 0 and signing fail silently, so this is the case that catches a header
// that has grown past its room.
void test_XEdDSA_max_payload(void)
{
uint8_t payload[meshtastic_Constants_DATA_PAYLOAD_LEN];
for (size_t i = 0; i < sizeof(payload); i++)
payload[i] = (uint8_t)(i * 7 + 1);
uint8_t pub[32], priv[32], signature[64];
crypto->generateKeyPair(pub, priv);
uint32_t fromNode = 0xFEED, packetId = 0xC0DE, toNode = 0xF00D;
meshtastic_Data d = makeSignableData(payload, sizeof(payload));
d.request_id = 0xF00D;
d.reply_id = 0xBEAD;
TEST_ASSERT_MESSAGE(crypto->xeddsa_sign(fromNode, packetId, toNode, &d, signature),
"a maximum-size payload must still fit the signing buffer");
TEST_ASSERT(crypto->xeddsa_verify(pub, fromNode, packetId, toNode, &d, signature));
d.payload.bytes[0] ^= 0x01;
TEST_ASSERT_FALSE(crypto->xeddsa_verify(pub, fromNode, packetId, toNode, &d, signature));
}
// XEdDSA is a randomized (hedged) scheme: the nonce mixes in Z, caller-supplied randomness
// (Signal spec; meshtastic/Crypto#3). CryptoEngine::xeddsa_sign seeds Z from the hardware RNG, so
// signing the same message twice yields *different* signatures that both verify. This pins that
// the randomization is actually wired through end to end - if signing regresses to deterministic
// (Z dropped by the library, or xeddsa_sign stops seeding entropy), the inequality assertion fails.
void test_XEdDSA_repeated_sign_is_randomized(void)
{
uint8_t pub[32], priv[32], sig1[64], sig2[64];
uint8_t message[] = "same message";
uint32_t fromNode = 0x9, packetId = 0x9, toNode = 0x9;
meshtastic_Data d = makeSignableData(message, sizeof(message), 9);
d.request_id = 0x9;
d.reply_id = 0x9;
crypto->generateKeyPair(pub, priv);
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, toNode, &d, sig1));
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, toNode, &d, sig2));
TEST_ASSERT_TRUE_MESSAGE(memcmp(sig1, sig2, sizeof(sig1)) != 0,
"signatures must differ - XEdDSA Z randomization is not wired through");
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pub, fromNode, packetId, toNode, &d, sig1));
TEST_ASSERT_TRUE(crypto->xeddsa_verify(pub, fromNode, packetId, toNode, &d, sig2));
}
// Finds the cache slot holding this peer's key, so the tests can assert on the cache itself and not
// only on what setCryptoSharedSecret leaves in shared_key. Protected members are public under
// PIO_UNIT_TESTING.
static bool findCachedSecret(const uint8_t *peerPubKey, CachedSharedSecret &entry)
{
for (size_t i = 0; i < MAX_CACHED_SHARED_SECRETS; i++) {
if (crypto->sharedSecretCache[i].valid && memcmp(crypto->sharedSecretCache[i].peer_public_key, peerPubKey, 32) == 0) {
entry = crypto->sharedSecretCache[i];
return true;
}
}
return false;
}
void test_shared_secret_cache(void)
{
uint8_t private_key[32];
uint8_t other_private_key[32];
uint8_t public_key[32];
uint8_t derived[32];
uint8_t stale[32];
// Same wycheproof vector test_DH25519 uses, hashed: the cache holds the hashed key packet
// crypto and the ack proof both consume.
HexToBytes(public_key, "504a36999f489cd2fdbc08baff3d88fa00569ba986cba22548ffde80f9806829");
HexToBytes(private_key, "c8a9d5a91091ad851c668b0736c1c9a02936c0d3ad62670858088047ba057475");
HexToBytes(other_private_key, "d85d8c061a50804ac488ad774ac716c3f5ba714b2712e048491379a500211958");
HexToBytes(derived, "436a2c040cf45fea9b29a0cb81b1f41458f863d0d61b453d0a982720d6d61320");
crypto->hash(derived, 32);
crypto->setDHPrivateKey(private_key);
TEST_ASSERT(crypto->setCryptoSharedSecret(public_key));
TEST_ASSERT_EQUAL_MEMORY(derived, crypto->shared_key, 32);
// The derivation was cached under the peer's key
CachedSharedSecret entry;
TEST_ASSERT(findCachedSecret(public_key, entry));
TEST_ASSERT_EQUAL_MEMORY(derived, entry.shared_secret, 32);
// A second call refills shared_key from the cache. Clearing it first means an equal result can
// only have come from the cache or from a fresh derivation, and either way it must match.
memset(crypto->shared_key, 0, 32);
TEST_ASSERT(crypto->setCryptoSharedSecret(public_key));
TEST_ASSERT_EQUAL_MEMORY(derived, crypto->shared_key, 32);
// Our own key changing invalidates every entry: the old secrets no longer belong to this identity
memcpy(stale, derived, 32);
crypto->setDHPrivateKey(other_private_key);
TEST_ASSERT_FALSE(findCachedSecret(public_key, entry));
TEST_ASSERT(crypto->setCryptoSharedSecret(public_key));
TEST_ASSERT(memcmp(stale, crypto->shared_key, 32) != 0);
TEST_ASSERT(findCachedSecret(public_key, entry));
TEST_ASSERT_EQUAL_MEMORY(crypto->shared_key, entry.shared_secret, 32);
}
// Two distinct peers whose keys share a leading prefix must not share a cache entry: an impostor
// can grind a key that shares any short prefix with a peer it wants to shadow, and would then be
// handed the secret we use with that peer. An all-zero key is a weak point and stays rejected.
void test_shared_secret_cache_distinguishes_prefix_sharing_keys(void)
{
uint8_t private_key[32];
uint8_t zeroPub[32] = {0};
uint8_t peerA[32], peerB[32];
uint8_t expectedA[32], expectedB[32];
CachedSharedSecret entry;
HexToBytes(private_key, "c8a9d5a91091ad851c668b0736c1c9a02936c0d3ad62670858088047ba057475");
crypto->setDHPrivateKey(private_key);
// Both peer keys open with the same four zero bytes. SHA256 of X25519(private_key, peer).
HexToBytes(peerA, "000000009f489cd2fdbc08baff3d88fa00569ba986cba22548ffde80f9806829");
HexToBytes(peerB, "0000000063aa40c6e38346c5caf23a6df0a5e6c80889a08647e551b3563449be");
HexToBytes(expectedA, "8df84c5a00ed192f7dadac05873a1dae6a07478153db8395c4604fc905a274ae");
HexToBytes(expectedB, "2b011221f1626664332ff61c28715044758ff62a72e91a2b215fba9dbb426097");
TEST_ASSERT(crypto->setCryptoSharedSecret(peerA));
TEST_ASSERT_EQUAL_MEMORY(expectedA, crypto->shared_key, 32);
// peerB must be derived on its own, not served peerA's entry
TEST_ASSERT(crypto->setCryptoSharedSecret(peerB));
TEST_ASSERT_EQUAL_MEMORY(expectedB, crypto->shared_key, 32);
TEST_ASSERT(findCachedSecret(peerA, entry));
TEST_ASSERT_EQUAL_MEMORY(expectedA, entry.shared_secret, 32);
// And each is still served its own secret once both are resident
TEST_ASSERT(crypto->setCryptoSharedSecret(peerA));
TEST_ASSERT_EQUAL_MEMORY(expectedA, crypto->shared_key, 32);
// The all-zero key shares that prefix too, and must be refused rather than matched
TEST_ASSERT_FALSE(crypto->setCryptoSharedSecret(zeroPub));
}
void test_AES_CTR(void)
{
uint8_t expected[32];
uint8_t plain[32];
uint8_t nonce[32];
CryptoKey k;
// vectors from https://www.rfc-editor.org/rfc/rfc3686#section-6
k.length = 32;
HexToBytes(k.bytes, "776BEFF2851DB06F4C8A0542C8696F6C6A81AF1EEC96B4D37FC1D689E6C1C104");
HexToBytes(nonce, "00000060DB5672C97AA8F0B200000001");
HexToBytes(expected, "145AD01DBF824EC7560863DC71E3E0C0");
memcpy(plain, "Single block msg", 16);
crypto->encryptAESCtr(k, nonce, 16, plain);
TEST_ASSERT_EQUAL_MEMORY(expected, plain, 16);
k.length = 16;
memcpy(plain, "Single block msg", 16);
HexToBytes(k.bytes, "AE6852F8121067CC4BF7A5765577F39E");
HexToBytes(nonce, "00000030000000000000000000000001");
HexToBytes(expected, "E4095D4FB7A7B3792D6175A3261311B8");
crypto->encryptAESCtr(k, nonce, 16, plain);
TEST_ASSERT_EQUAL_MEMORY(expected, plain, 16);
}
void test_AES_CCM_partial_block_bounds(void)
{
// aes_ccm_encr() used to write a whole 16-byte AES block at the output before XOR-ing,
// so a trailing partial block scribbled up to 15 bytes past what the caller allocated.
const uint8_t guard = 0xA5;
const size_t guardLen = 16;
const size_t lengths[] = {5, 20}; // pure partial block, and one full block plus a partial one
uint8_t key[32];
uint8_t nonce[13];
uint8_t auth[8];
HexToBytes(key, "603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4");
HexToBytes(nonce, "000102030405060708090A0B0C");
for (size_t n = 0; n < sizeof(lengths) / sizeof(lengths[0]); n++) {
const size_t len = lengths[n];
uint8_t plain[32];
uint8_t crypt[32 + guardLen];
uint8_t decrypted[32 + guardLen];
for (size_t i = 0; i < len; i++)
plain[i] = (uint8_t)i;
memset(crypt + len, guard, guardLen);
memset(decrypted + len, guard, guardLen);
TEST_ASSERT_EQUAL(0, aes_ccm_ae(key, sizeof(key), nonce, sizeof(auth), plain, len, nullptr, 0, crypt, auth));
for (size_t i = 0; i < guardLen; i++)
TEST_ASSERT_EQUAL_UINT8(guard, crypt[len + i]);
TEST_ASSERT_TRUE(aes_ccm_ad(key, sizeof(key), nonce, sizeof(auth), crypt, len, nullptr, 0, auth, decrypted));
for (size_t i = 0; i < guardLen; i++)
TEST_ASSERT_EQUAL_UINT8(guard, decrypted[len + i]);
TEST_ASSERT_EQUAL_MEMORY(plain, decrypted, len);
}
}
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)
{
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;
}
void test_AES_CCM_AEAD_smoke(void)
{
// Smoke test - encryption changes the payload and produces a tag
// (the known-answer coverage lives in test_AES_CCM_rfc3610)
CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901");
uint32_t fromNode = 0x12345678;
uint32_t toNode = 0x0000AAAA;
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, toNode, 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);
}
void test_AES_CCM_AEAD_roundtrip_aes256(void)
{
// Round-trip encrypt → decrypt → compare (AES-256)
CryptoKey psk = makePsk("603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4");
uint32_t fromNode = 0xDEADBEEF;
uint32_t toNode = 0xFFFFFFFF;
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, toNode, 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, toNode, packetId, totalBytes, ciphertextWithTag, decrypted));
TEST_ASSERT_EQUAL_MEMORY(msg, decrypted, msgLen);
}
void test_AES_CCM_AEAD_rejects_tampering(void)
{
// Tampered ciphertext - flip a bit, verify rejection
{
CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901");
uint32_t fromNode = 0xABCD1234;
uint32_t toNode = 0x00000001;
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, toNode, 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, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE,
ciphertextWithTag, decrypted));
}
// Tampered auth tag - modify tag, verify rejection
{
CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901");
uint32_t fromNode = 0xABCD1234;
uint32_t toNode = 0x87654321;
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, toNode, 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, toNode, packetId, 16 + CryptoEngine::AEAD_TAG_SIZE,
ciphertextWithTag, decrypted));
}
}
void test_AES_CCM_AEAD_rejects_undersized(void)
{
// Packet too small for AEAD - totalBytes <= AEAD_TAG_SIZE
CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901");
uint8_t dummy[CryptoEngine::AEAD_TAG_SIZE] = {0};
// 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, 0x4321, 0x5678, CryptoEngine::AEAD_TAG_SIZE, dummy, out));
TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, 0x1234, 0x4321, 0x5678, 0, dummy, out));
}
void test_AES_CCM_AEAD_rejects_wrong_psk(void)
{
// Wrong PSK - decrypt with different key, verify rejection
CryptoKey pskA = makePsk("d4f1bb3a20290759f0bcffabcf4e6901");
CryptoKey pskB = makePsk("00112233445566778899aabbccddeeff");
uint32_t fromNode = 0x99887766;
uint32_t toNode = 0x13579BDF;
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, toNode, packetId, 12, plaintext, ciphertextWithTag));
// Attempt decryption with wrong key
uint8_t decrypted[12];
TEST_ASSERT_FALSE(crypto->decryptPacketCCM(pskB, fromNode, toNode, packetId, 12 + CryptoEngine::AEAD_TAG_SIZE,
ciphertextWithTag, decrypted));
}
void test_AES_CCM_AEAD_roundtrip_aes128(void)
{
// Round-trip with AES-128 PSK (16-byte key, true AES-128-CCM)
CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901");
uint32_t fromNode = 0x42424242;
uint32_t toNode = 0x2468ACE0;
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, toNode, packetId, 20, plaintext, ciphertextWithTag));
uint8_t decrypted[20];
TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 20 + CryptoEngine::AEAD_TAG_SIZE,
ciphertextWithTag, decrypted));
TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 20);
}
void test_AES_CCM_AEAD_tamper_sweep(void)
{
// AES-256-CCM round-trip + per-byte tamper detection
CryptoKey psk = makePsk("603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4");
uint32_t fromNode = 0x01020304;
uint32_t toNode = 0x0BADCAFE;
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, toNode, packetId, 32, plaintext, ciphertextWithTag));
// Valid decrypt
uint8_t decrypted[32];
TEST_ASSERT_TRUE(crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 32 + CryptoEngine::AEAD_TAG_SIZE,
ciphertextWithTag, decrypted));
TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 32);
// 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;
TEST_ASSERT_FALSE(
crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 32 + CryptoEngine::AEAD_TAG_SIZE, tampered, decrypted));
}
}
void test_AES_CCM_AEAD_is_deterministic(void)
{
// Deterministic - same inputs produce same output
CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901");
uint32_t fromNode = 0xCAFEBABE;
uint32_t toNode = 0x5A5A5A5A;
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, toNode, packetId, 5, plaintext, ct1));
TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 5, plaintext, ct2));
TEST_ASSERT_EQUAL_MEMORY(ct1, ct2, 5 + CryptoEngine::AEAD_TAG_SIZE);
}
void test_AES_CCM_AEAD_binds_nonce_inputs(void)
{
// 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;
uint32_t toNode = 0x77777777;
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, toNode, packetIdA, 6, plaintext, ciphertextWithTag));
uint8_t decrypted[6];
// Wrong fromNode, right packetId
TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNodeB, toNode, packetIdA, 6 + CryptoEngine::AEAD_TAG_SIZE,
ciphertextWithTag, decrypted));
// Right fromNode, wrong packetId
TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNodeA, toNode, packetIdB, 6 + CryptoEngine::AEAD_TAG_SIZE,
ciphertextWithTag, decrypted));
// Both wrong
TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNodeB, toNode, 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, toNode, packetIdA, 6 + CryptoEngine::AEAD_TAG_SIZE,
ciphertextWithTag, decrypted));
TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 6);
}
void test_AES_CCM_AEAD_rejects_invalid_psk(void)
{
// Empty PSK - must return false, not crash
CryptoKey emptyPsk;
memset(&emptyPsk, 0, sizeof(emptyPsk));
emptyPsk.length = 0;
uint32_t fromNode = 0xDEADBEEF;
uint32_t toNode = 0x0000BEEF;
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, toNode, 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, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE,
ciphertextWithTag, decrypted));
// 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;
TEST_ASSERT_FALSE(crypto->encryptPacketCCM(invalidPsk, fromNode, toNode, packetId, 8, plaintext, ciphertextWithTag));
TEST_ASSERT_FALSE(crypto->decryptPacketCCM(invalidPsk, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE,
ciphertextWithTag, decrypted));
}
void test_AES_CCM_AEAD_key_size_distinction(void)
{
// 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;
uint32_t toNode = 0x33333333;
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, toNode, packetId, 8, plaintext, ct128));
TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk256, fromNode, toNode, 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, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ct128, dec128));
TEST_ASSERT_EQUAL_MEMORY(plaintext, dec128, 8);
TEST_ASSERT_TRUE(
crypto->decryptPacketCCM(psk256, fromNode, toNode, 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, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ct128, dec128));
TEST_ASSERT_FALSE(
crypto->decryptPacketCCM(psk128, fromNode, toNode, packetId, 8 + CryptoEngine::AEAD_TAG_SIZE, ct256, dec256));
}
void test_AES_CCM_AEAD_binds_destination(void)
{
// Rewritten destination - `to` is authenticated as associated data, so changing
// it in flight must fail the tag check even though the nonce is unaffected
CryptoKey psk = makePsk("d4f1bb3a20290759f0bcffabcf4e6901");
uint32_t fromNode = 0x0A0B0C0D;
uint32_t toNode = 0x00000042;
uint32_t otherNode = 0x00000043;
uint32_t broadcast = 0xFFFFFFFF;
uint64_t packetId = 0x99887766;
uint8_t plaintext[9] = {'t', 'o', '-', 'i', 's', '-', 'a', 'a', 'd'};
uint8_t ciphertextWithTag[9 + CryptoEngine::AEAD_TAG_SIZE];
uint8_t decrypted[9];
TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, toNode, packetId, 9, plaintext, ciphertextWithTag));
// Redirecting the packet to another node must be rejected
TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, otherNode, packetId, 9 + CryptoEngine::AEAD_TAG_SIZE,
ciphertextWithTag, decrypted));
// Promoting a unicast to a broadcast must be rejected too
TEST_ASSERT_FALSE(crypto->decryptPacketCCM(psk, fromNode, broadcast, packetId, 9 + CryptoEngine::AEAD_TAG_SIZE,
ciphertextWithTag, decrypted));
// The unmodified destination still round-trips, so the rejections above are not vacuous
TEST_ASSERT_TRUE(
crypto->decryptPacketCCM(psk, fromNode, toNode, packetId, 9 + CryptoEngine::AEAD_TAG_SIZE, ciphertextWithTag, decrypted));
TEST_ASSERT_EQUAL_MEMORY(plaintext, decrypted, 9);
// A different destination must also change the tag, not just be rejected on decrypt
uint8_t otherCiphertextWithTag[9 + CryptoEngine::AEAD_TAG_SIZE];
TEST_ASSERT_TRUE(crypto->encryptPacketCCM(psk, fromNode, otherNode, packetId, 9, plaintext, otherCiphertextWithTag));
TEST_ASSERT_FALSE(memcmp(ciphertextWithTag + 9, otherCiphertextWithTag + 9, CryptoEngine::AEAD_TAG_SIZE) == 0);
}
void setup()
{
// NOTE!!! Wait for >2 secs
// if board doesn't support software reset via Serial.DTR/RTS
delay(10);
delay(2000);
initializeTestEnvironment();
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_shared_secret_cache);
RUN_TEST(test_shared_secret_cache_distinguishes_prefix_sharing_keys);
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_layout_is_unambiguous);
RUN_TEST(test_XEdDSA_cross_key_reject);
RUN_TEST(test_XEdDSA_empty_key_sign_fails);
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_smoke);
RUN_TEST(test_AES_CCM_AEAD_roundtrip_aes256);
RUN_TEST(test_AES_CCM_AEAD_rejects_tampering);
RUN_TEST(test_AES_CCM_AEAD_rejects_undersized);
RUN_TEST(test_AES_CCM_AEAD_rejects_wrong_psk);
RUN_TEST(test_AES_CCM_AEAD_roundtrip_aes128);
RUN_TEST(test_AES_CCM_AEAD_tamper_sweep);
RUN_TEST(test_AES_CCM_AEAD_is_deterministic);
RUN_TEST(test_AES_CCM_AEAD_binds_nonce_inputs);
RUN_TEST(test_AES_CCM_AEAD_rejects_invalid_psk);
RUN_TEST(test_AES_CCM_AEAD_key_size_distinction);
RUN_TEST(test_AES_CCM_AEAD_binds_destination);
exit(UNITY_END()); // stop unit testing
}
void loop() {}