Files
firmware/test/test_crypto/test_main.cpp
T
95906609db Sign the whole Data envelope, in one unambiguous layout (#11422)
* Bind request_id and reply_id into the XEdDSA signing buffer

A signed reply can be re-pointed at a different message today. The client sets
reply_id on an outgoing text to make a tapback, firmware signs the resulting
broadcast, but reply_id lives in the Data envelope rather than the payload the
signature covers - and channel crypto is AES-CTR with no MAC, so anyone holding
the PSK can rewrite it in flight and the signature still verifies. request_id
has the same shape and is bound with it.

Packets carrying neither field keep the existing [from|id|portnum|payload]
layout, byte-identical to what v2.8.0 alphas are signing today, so the bulk of
signed traffic - broadcasts - stays verifiable in both directions across the
upgrade. Only packets that actually carry one of the two fields use the extended
layout. Both sides pick the layout from the packet's own decoded fields, so
nothing is transmitted to select it.

Open question for review, deliberately not decided here: a format/version byte
in the buffer would be cleaner than a conditional layout, because the safety
argument for the conditional form has to be re-derived whenever a portnum is
added. It costs nothing on the wire since the buffer is never transmitted, but
it changes every signature and so breaks verification against the alphas that
are already signing. If we want it, better done once and before 2.8.0 leaves
alpha.

Tests: request_id/reply_id flips alongside the existing from/id/portnum negative
cases; a hand-built alpha-format signature that must still verify, and must not
be reinterpretable as the extended layout or vice versa; and receive-path cases
for a retargeted tapback, a retargeted response, and an ordinary signed
broadcast that must be unaffected.

* Sign the whole Data envelope, in one unambiguous layout

Replaces the conditional two-layout signing buffer with a single fixed one:

  version(1) | from(4) | id(4) | to(4) | portnum(4) | request_id(4)
            | reply_id(4) | emoji(4) | bitfield(4) | flags(1) | payload(N)

The conditional scheme was ambiguous. Base was header || arbitrary payload, so
any byte string the extended layout emitted was also a legal base payload: an
attacker could move eight payload bytes into request_id/reply_id and truncate a
signed message while its signature still verified. No marker placed only in the
extended layout fixes that, because base can always reproduce it. A fixed-length
header does - the payload boundary is total - XEDDSA_SIGNED_HEADER_LEN and never
depends on content.

Binding reply_id alone was also not enough, because the fields around it are just
as malleable:

  emoji         a reaction is a text packet with the emoji in the payload,
                reply_id naming the parent, and this flag telling the client to
                render it as a reaction. Flipping it turns a signed reply into a
                signed reaction, so it has to travel with reply_id.
  bitfield      bit 0 is OK_TO_MQTT, the sender's consent to upload to a public
                broker, and the exploitable direction is the one that leaks. The
                whole uint32 is signed so bits 2..31 are covered in advance, and
                presence is signed separately so stripping it is not the same as
                sending it zero.
  want_response bit 1 of bitfield mirrors it and Router merges the two with |=,
                so signing either alone protects neither.
  to            without it a signed broadcast can be re-addressed as a direct
                message and still verify, delivering a public statement as an
                apparent private one. Relays rewrite hop_limit, next_hop and
                relay_node, never `to`.

Left out: dest and source (one write in the tree, no readers), channel (the wire
carries a hash where the decoded packet carries an index), and the hop fields,
which relays rewrite by design. Signing the encoded Data wholesale is not an
option either - a relay that holds the channel key decodes and re-encodes it, so
byte fidelity is lost and unknown fields are stripped. The ack_proof excision
trick does not transfer for the same reason: Routing survives because it rides
inside the opaque payload, which Data itself does not.

sign/verify now take the Data rather than a field list, so adding to the covered
set cannot silently miss a call site. Integers are explicitly little-endian, as
in ackProofCompute. The buffer is sized from the schema's own maximum payload
rather than from what the fits-on-air gate currently admits, because an overflow
makes buildSigningBuffer return 0 and signing fail with no error; MAX_BLOCKSIZE
is left alone so the AES-CTR guard and scratch buffers sharing it are unaffected.

This changes every signature, so it is not compatible with the v2.8.0 alphas. It
is deliberately being done while 2.8.0 is still prerelease: a verify failure
against an authoritative key is an unconditional drop, so the same change after
2.8.0 goes stable would split signed traffic across versions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hLcVif8GDEmA2k77hmFG8

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-09-24 07:26:37 +00:00

949 lines
40 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));
}
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_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() {}