Files
firmware/test/test_crypto/test_main.cpp
James Rich 023164a7ea Clamp an imported private key before deriving its public key (#12095)
Curve25519::eval uses the scalar as given while XEdDSA::priv_curve_to_ed_keys
clamps it, so a private key set through admin, an app or a backup restore
without clamping derived an X25519 public key that disagreed with the signing
key, and peers rejected every signature the node sent. regeneratePublicKey now
clamps the key in place, so the stored key, public key and node number agree.
2026-10-07 12:04:13 -05:00

1067 lines
46 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));
}
// CryptoEngine::regeneratePublicKey must clamp the private key it is handed, in place. Curve25519::eval
// uses the scalar as given while XEdDSA::priv_curve_to_ed_keys clamps it, so an unclamped key (set via
// admin, an app's private-key field, or a backup restore) derives an X25519 public key that disagrees
// with the signing key: every signature the node sends then fails xeddsa_verify at its peers. If the
// clamp is dropped, the bit assertions and the final verify fail for this 0x11-filled key.
void test_regeneratePublicKey_clamps_imported_key(void)
{
uint8_t pub[32], priv[32], signature[64];
memset(priv, 0x11, sizeof(priv));
uint8_t message[] = "imported key";
uint32_t fromNode = 0x1111, packetId = 0x2222, toNode = 0x3333;
meshtastic_Data d = makeSignableData(message, sizeof(message), 5);
d.request_id = 0x44;
TEST_ASSERT_TRUE(crypto->regeneratePublicKey(pub, priv));
TEST_ASSERT_EQUAL_HEX8(0, priv[0] & 0x07);
TEST_ASSERT_EQUAL_HEX8(0x40, priv[31] & 0xC0);
TEST_ASSERT(crypto->xeddsa_sign(fromNode, packetId, toNode, &d, signature));
TEST_ASSERT_TRUE_MESSAGE(crypto->xeddsa_verify(pub, fromNode, packetId, toNode, &d, signature),
"the derived public key must verify the node's own signature");
}
// 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_regeneratePublicKey_clamps_imported_key);
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() {}