Source
stdlib/tests/aead_test.cpp
1
// Copyright (c) 2026 BigBrain LLC. MIT-licensed (see LICENSE).2
// Original work; see ACKNOWLEDGMENTS.md for the open-source ideas we build upon.3
// Unit tests for the `aead` module against the RFC 8439 §2.8.2 AEAD test vector, plus4
// round-trip, tamper-rejection, and malformed-input behavior.5
#include <gtest/gtest.h>7
#include <cstring>8
#include <random>9
#include <vector>10
#include <string>12
#include "aead.hpp"14
namespace a = cheatah::aead;16
namespace {17
const std::string kKey = "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f";18
const std::string kNonce = "070000004041424344454647";19
const std::string kAad = std::string("\x50\x51\x52\x53\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7", 12);20
const char* kPlain =21
"Ladies and Gentlemen of the class of '99: If I could offer you "22
"only one tip for the future, sunscreen would be it.";23
// RFC 8439 §2.8.2 expected tag (the ciphertext bytes are checked via round-trip + length).24
const std::string kTagHex = "1ae10b594f09e26a7e902ecbd0600691";26
std::string hex_of(std::string_view raw) {27
static constexpr char kHex[] = "0123456789abcdef";28
std::string out;29
for (const char ch : raw) {30
out.push_back(kHex[static_cast<unsigned char>(ch) >> 4]);31
out.push_back(kHex[static_cast<unsigned char>(ch) & 0xF]);32
}33
return out;34
}35
} // namespace37
// The RFC 8439 §2.8.2 vector: ciphertext prefix + tag must match the spec exactly.38
TEST(CheatahAead, Rfc8439Encrypt) {39
const std::string ct = a::chacha20poly1305_encrypt(kKey, kNonce, kAad, kPlain);40
ASSERT_EQ(ct.size(), std::string(kPlain).size() + 16);41
EXPECT_EQ(hex_of(ct.substr(0, 16)), "d31a8d34648e60db7b86afbc53ef7ec2"); // first CT block42
EXPECT_EQ(hex_of(ct.substr(ct.size() - 16)), kTagHex); // the Poly1305 tag43
}45
// Decrypt of the spec ciphertext returns the spec plaintext (and the round trip holds).46
TEST(CheatahAead, Rfc8439Decrypt) {47
const std::string ct = a::chacha20poly1305_encrypt(kKey, kNonce, kAad, kPlain);48
EXPECT_EQ(a::chacha20poly1305_decrypt(kKey, kNonce, kAad, ct), kPlain);49
}51
// Any tampering — ciphertext byte, tag byte, or different aad — must yield "" (rejected).52
TEST(CheatahAead, RejectsTamper) {53
std::string ct = a::chacha20poly1305_encrypt(kKey, kNonce, kAad, kPlain);54
std::string flipped = ct;55
flipped[3] = static_cast<char>(flipped[3] ^ 0x01);56
EXPECT_EQ(a::chacha20poly1305_decrypt(kKey, kNonce, kAad, flipped), "");57
std::string bad_tag = ct;58
bad_tag[bad_tag.size() - 1] = static_cast<char>(bad_tag.back() ^ 0x80);59
EXPECT_EQ(a::chacha20poly1305_decrypt(kKey, kNonce, kAad, bad_tag), "");60
EXPECT_EQ(a::chacha20poly1305_decrypt(kKey, kNonce, "other aad", ct), "");61
EXPECT_EQ(a::chacha20poly1305_decrypt(kKey, kNonce, kAad, "short"), "");62
EXPECT_EQ(a::chacha20poly1305_encrypt("zz", kNonce, kAad, kPlain), "");63
}65
// Empty plaintext and empty aad are valid AEAD inputs (TLS uses empty aad in places).66
TEST(CheatahAead, EmptyInputs) {67
const std::string ct = a::chacha20poly1305_encrypt(kKey, kNonce, "", "");68
ASSERT_EQ(ct.size(), std::size_t{16}); // tag only69
EXPECT_EQ(a::chacha20poly1305_decrypt(kKey, kNonce, "", ct), "");70
// "" is also the FAILURE value; distinguish via a 1-byte round trip71
const std::string one = a::chacha20poly1305_encrypt(kKey, kNonce, "", "x");72
EXPECT_EQ(a::chacha20poly1305_decrypt(kKey, kNonce, "", one), "x");73
}75
// ---- AES-128-GCM (TLS_AES_128_GCM_SHA256) against the NIST/GCM (McGrew & Viega) test vectors ----76
namespace {77
std::string unhex(std::string_view h) {78
auto v = [](char c) { return c <= '9' ? c - '0' : (c | 0x20) - 'a' + 10; };79
std::string o;80
for (std::size_t i = 0; i + 1 < h.size(); i += 2)81
o.push_back(static_cast<char>((v(h[i]) << 4) | v(h[i + 1])));82
return o;83
}84
} // namespace86
// GCM Test Case 1: all-zero key + iv, empty AAD and plaintext → ciphertext is just the tag.87
TEST(CheatahAead, AesGcmNistCase1) {88
const std::string ct = a::aes128gcm_encrypt(std::string(32, '0'), std::string(24, '0'), "", "");89
ASSERT_EQ(ct.size(), std::size_t{16});90
EXPECT_EQ(hex_of(ct), "58e2fccefa7e3061367f1d57a4e7455a");91
}93
// GCM Test Case 2: a single all-zero block, no AAD — exact ciphertext + tag, and the round trip.94
TEST(CheatahAead, AesGcmNistCase2) {95
const std::string key(32, '0'), iv(24, '0'); // 16 zero key bytes, 12 zero iv bytes96
const std::string p = unhex("00000000000000000000000000000000");97
const std::string expect = unhex(98
"0388dace60b6a392f328c2b971b2fe78" // ciphertext99
"ab6e47d42cec13bdf53a67b21257bddf"); // tag100
const std::string ct = a::aes128gcm_encrypt(key, iv, "", p);101
EXPECT_EQ(ct, expect);102
EXPECT_EQ(a::aes128gcm_decrypt(key, iv, "", ct), p);103
}105
// GCM Test Case 4: same key/iv/plaintext as case 3 but WITH AAD — ciphertext unchanged, new tag.106
TEST(CheatahAead, AesGcmNistCase4) {107
const std::string key = "feffe9928665731c6d6a8f9467308308", iv = "cafebabefacedbaddecaf888";108
const std::string aad = unhex("feedfacedeadbeeffeedfacedeadbeefabaddad2");109
const std::string p = unhex(110
"d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a72"111
"1c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39");112
const std::string ct = a::aes128gcm_encrypt(key, iv, aad, p);113
EXPECT_EQ(hex_of(ct.substr(ct.size() - 16)), "5bc94fbc3221a5db94fae95ae7121a47");114
EXPECT_EQ(a::aes128gcm_decrypt(key, iv, aad, ct), p);115
}117
// AES-256-GCM known-answer: McGrew GCM Test Case 16 (the AES-256 analog of Case 4), the standard118
// vector — AES-256 key, non-empty AAD + plaintext. Validates the FIPS-197 AES-256 key schedule +119
// the shared GCM machinery through the portable path.120
TEST(CheatahAead, Aes256GcmNistKat) {121
const std::string key =122
"feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308";123
const std::string iv = "cafebabefacedbaddecaf888";124
const std::string aad = unhex("feedfacedeadbeeffeedfacedeadbeefabaddad2");125
const std::string p = unhex(126
"d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a72"127
"1c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39");128
const std::string ct = a::aes256gcm_encrypt(key, iv, aad, p);129
ASSERT_EQ(ct.size(), p.size() + 16);130
EXPECT_EQ(hex_of(ct.substr(0, p.size())),131
"522dc1f099567d07f47f37a32a84427d643a8cdcbfe5c0c97598a2bd"132
"2555d1aa8cb08e48590dbb3da7b08b1056828838c5f61e6393ba7a0abcc9f662");133
EXPECT_EQ(hex_of(ct.substr(ct.size() - 16)), "76fc6ece0f4e1768cddf8853bb2d551b");134
EXPECT_EQ(a::aes256gcm_decrypt(key, iv, aad, ct), p);135
}137
// AES-256-GCM round trip + tamper/malformed rejection (mirrors the AES-128 case).138
TEST(CheatahAead, Aes256GcmRejectsTamper) {139
const std::string key =140
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";141
const std::string iv = "000102030405060708090a0b";142
const std::string aad = "header", p = "the quick brown fox jumps over the lazy dog";143
std::string ct = a::aes256gcm_encrypt(key, iv, aad, p);144
ASSERT_EQ(ct.size(), p.size() + 16);145
EXPECT_EQ(a::aes256gcm_decrypt(key, iv, aad, ct), p); // round trip146
std::string flip_ct = ct;147
flip_ct[2] = static_cast<char>(flip_ct[2] ^ 0x01);148
EXPECT_EQ(a::aes256gcm_decrypt(key, iv, aad, flip_ct), "");149
std::string flip_tag = ct;150
flip_tag[flip_tag.size() - 1] = static_cast<char>(flip_tag.back() ^ 0x80);151
EXPECT_EQ(a::aes256gcm_decrypt(key, iv, aad, flip_tag), "");152
EXPECT_EQ(a::aes256gcm_decrypt(key, iv, "other aad", ct), "");153
EXPECT_EQ(a::aes256gcm_decrypt(key, iv, aad, "short"), "");154
EXPECT_EQ(a::aes256gcm_encrypt("ababab", iv, aad, p), ""); // key not 32 bytes155
EXPECT_EQ(a::aes256gcm_encrypt(std::string(64, 'a'), "00", aad, p), ""); // nonce not 12 bytes156
}158
// Round trip + tamper rejection (ciphertext, tag, aad) + malformed key/nonce/short input.159
TEST(CheatahAead, AesGcmRejectsTamperAndMalformed) {160
const std::string key = "000102030405060708090a0b0c0d0e0f", iv = "000102030405060708090a0b";161
const std::string aad = "header", p = "the quick brown fox";162
std::string ct = a::aes128gcm_encrypt(key, iv, aad, p);163
ASSERT_EQ(ct.size(), p.size() + 16);164
EXPECT_EQ(a::aes128gcm_decrypt(key, iv, aad, ct), p); // round trip165
std::string flip_ct = ct;166
flip_ct[2] = static_cast<char>(flip_ct[2] ^ 0x01);167
EXPECT_EQ(a::aes128gcm_decrypt(key, iv, aad, flip_ct), "");168
std::string flip_tag = ct;169
flip_tag[flip_tag.size() - 1] = static_cast<char>(flip_tag.back() ^ 0x80);170
EXPECT_EQ(a::aes128gcm_decrypt(key, iv, aad, flip_tag), "");171
EXPECT_EQ(a::aes128gcm_decrypt(key, iv, "other aad", ct), "");172
EXPECT_EQ(a::aes128gcm_decrypt(key, iv, aad, "short"), "");173
EXPECT_EQ(a::aes128gcm_encrypt("ababab", iv, aad, p), ""); // key not 16 bytes174
EXPECT_EQ(a::aes128gcm_encrypt(key, "00", aad, p), ""); // nonce not 12 bytes175
}177
// The AES-NI/PCLMULQDQ fast path and the portable scalar reference must agree byte-for-byte,178
// across sizes that exercise the 4-wide CTR loop (>=64), its tail (non-multiple of 64), the179
// single sub-block, and empty. Also keeps the scalar reference covered on AES-NI hardware.180
TEST(CheatahAead, AesGcmPortableMatchesHardware) {181
const std::string key = "000102030405060708090a0b0c0d0e0f";182
const std::string nonce = "101112131415161718191a1b";183
const std::string aad = "associated-data-header";184
for (std::size_t n : {std::size_t(0), std::size_t(13), std::size_t(16), std::size_t(64),185
std::size_t(100), std::size_t(255)}) {186
std::string pt(n, '\0');187
for (std::size_t i = 0; i < n; ++i) pt[i] = static_cast<char>(i * 7 + 1);189
a::set_force_portable_crypto(false);190
const std::string hw = a::aes128gcm_encrypt(key, nonce, aad, pt);191
a::set_force_portable_crypto(true);192
const std::string sw = a::aes128gcm_encrypt(key, nonce, aad, pt);193
a::set_force_portable_crypto(false);195
ASSERT_EQ(hw.size(), n + 16);196
EXPECT_EQ(hw, sw) << "hardware vs portable AES-GCM differ at size " << n;197
EXPECT_EQ(a::aes128gcm_decrypt(key, nonce, aad, hw), pt); // hardware decrypt198
a::set_force_portable_crypto(true);199
EXPECT_EQ(a::aes128gcm_decrypt(key, nonce, aad, hw), pt); // portable decrypt200
a::set_force_portable_crypto(false);201
}202
// A tampered tag is rejected on BOTH paths.203
std::string ct = a::aes128gcm_encrypt(key, nonce, aad, std::string(80, 'z'));204
ct[ct.size() - 1] = static_cast<char>(ct.back() ^ 0x01);205
EXPECT_EQ(a::aes128gcm_decrypt(key, nonce, aad, ct), "");206
a::set_force_portable_crypto(true);207
EXPECT_EQ(a::aes128gcm_decrypt(key, nonce, aad, ct), "");208
EXPECT_EQ(a::aes128gcm_decrypt(key, nonce, aad, "short"), ""); // portable: too short209
a::set_force_portable_crypto(false);211
// Large AAD (>= 128 bytes) exercises the 8-wide GHASH aggregation over the AAD too.212
{213
const std::string big_aad(200, 'A');214
const std::string pt = "payload for the large-aad cross-check";215
a::set_force_portable_crypto(false);216
const std::string hw = a::aes128gcm_encrypt(key, nonce, big_aad, pt);217
a::set_force_portable_crypto(true);218
const std::string sw = a::aes128gcm_encrypt(key, nonce, big_aad, pt);219
a::set_force_portable_crypto(false);220
EXPECT_EQ(hw, sw);221
EXPECT_EQ(a::aes128gcm_decrypt(key, nonce, big_aad, hw), pt);222
}224
// Hex parser branches: UPPERCASE A-F is accepted, a non-hex character is rejected.225
EXPECT_FALSE(226
a::aes128gcm_encrypt("000102030405060708090A0B0C0D0E0F", nonce, aad, "x").empty());227
EXPECT_EQ(a::aes128gcm_encrypt("zz0102030405060708090a0b0c0d0e0f", nonce, aad, "x"), "");228
}230
// AES-256-GCM: the hardware path (x86 AES-NI/PCLMULQDQ or ARMv8 AES/PMULL) must agree byte-for-byte231
// with the portable scalar reference, across sizes that exercise the 8-wide CTR/GHASH loop, its tail,232
// a sub-block, and empty. This is what an ARM/macOS user runs to verify THEIR hardware AES-256 path233
// against the reference — just `ctest`.234
TEST(CheatahAead, Aes256GcmPortableMatchesHardware) {235
const std::string key =236
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";237
const std::string nonce = "101112131415161718191a1b";238
const std::string aad = "associated-data-header";239
for (std::size_t n : {std::size_t(0), std::size_t(13), std::size_t(16), std::size_t(64),240
std::size_t(100), std::size_t(255)}) {241
std::string pt(n, '\0');242
for (std::size_t i = 0; i < n; ++i) pt[i] = static_cast<char>(i * 7 + 1);244
a::set_force_portable_crypto(false);245
const std::string hw = a::aes256gcm_encrypt(key, nonce, aad, pt);246
a::set_force_portable_crypto(true);247
const std::string sw = a::aes256gcm_encrypt(key, nonce, aad, pt);248
a::set_force_portable_crypto(false);250
ASSERT_EQ(hw.size(), n + 16);251
EXPECT_EQ(hw, sw) << "hardware vs portable AES-256-GCM differ at size " << n;252
EXPECT_EQ(a::aes256gcm_decrypt(key, nonce, aad, hw), pt); // hardware decrypt253
a::set_force_portable_crypto(true);254
EXPECT_EQ(a::aes256gcm_decrypt(key, nonce, aad, hw), pt); // portable decrypt255
a::set_force_portable_crypto(false);256
}257
// Large AAD (>= 128 bytes) exercises the 8-wide GHASH aggregation over the AAD.258
const std::string big_aad(200, 'A');259
const std::string pt = "payload for the large-aad AES-256 cross-check";260
a::set_force_portable_crypto(false);261
const std::string hw = a::aes256gcm_encrypt(key, nonce, big_aad, pt);262
a::set_force_portable_crypto(true);263
const std::string sw = a::aes256gcm_encrypt(key, nonce, big_aad, pt);264
a::set_force_portable_crypto(false);265
EXPECT_EQ(hw, sw);266
EXPECT_EQ(a::aes256gcm_decrypt(key, nonce, big_aad, hw), pt);267
}269
// The allocation-free `_into` forms must be indistinguishable from the string-returning ones —270
// same ciphertext, same tag, same rejection behavior — across the RFC vector, empty inputs,271
// aliasing, and randomized sizes that straddle ChaCha's 64-byte block and Poly1305's 16-byte block.272
TEST(CheatahAead, IntoFormsMatchStringForms) {273
const auto unhex = [](const std::string& h) {274
std::vector<unsigned char> b(h.size() / 2);275
for (std::size_t i = 0; i < b.size(); ++i)276
b[i] = static_cast<unsigned char>(std::stoul(h.substr(2 * i, 2), nullptr, 16));277
return b;278
};279
const std::vector<unsigned char> key = unhex(kKey);280
const std::vector<unsigned char> nonce = unhex(kNonce);281
const std::string plain(kPlain); // kPlain is a const char* literal283
// (a) the RFC 8439 §2.8.2 vector, through both forms.284
{285
const std::string want = a::chacha20poly1305_encrypt(kKey, kNonce, kAad, kPlain);286
ASSERT_FALSE(want.empty());287
std::vector<unsigned char> got(plain.size() + 16);288
ASSERT_TRUE(a::chacha20poly1305_encrypt_into(289
key.data(), nonce.data(), reinterpret_cast<const unsigned char*>(kAad.data()),290
kAad.size(), reinterpret_cast<const unsigned char*>(plain.data()), plain.size(),291
got.data()));292
ASSERT_EQ(got.size(), want.size());293
EXPECT_EQ(0, std::memcmp(got.data(), want.data(), want.size()));295
std::vector<unsigned char> back(plain.size());296
ASSERT_TRUE(a::chacha20poly1305_decrypt_into(297
key.data(), nonce.data(), reinterpret_cast<const unsigned char*>(kAad.data()),298
kAad.size(), got.data(), got.size(), back.data()));299
EXPECT_EQ(std::string(reinterpret_cast<char*>(back.data()), back.size()), plain);300
}302
// (b) randomized sizes around the block boundaries, with and without aad.303
std::mt19937_64 rng(0xA11CE);304
for (int trial = 0; trial < 200; ++trial) {305
const std::size_t n = trial < 70 ? static_cast<std::size_t>(trial)306
: static_cast<std::size_t>(rng() % 600);307
const std::size_t an = trial % 3 == 0 ? 0 : static_cast<std::size_t>(rng() % 40);308
std::string msg(n, '\0'), aad(an, '\0');309
for (auto& c : msg) c = static_cast<char>(rng() & 0xff);310
for (auto& c : aad) c = static_cast<char>(rng() & 0xff);312
const std::string want = a::chacha20poly1305_encrypt(kKey, kNonce, aad, msg);313
ASSERT_EQ(want.size(), n + 16) << "n=" << n;314
std::vector<unsigned char> got(n + 16);315
ASSERT_TRUE(a::chacha20poly1305_encrypt_into(316
key.data(), nonce.data(), reinterpret_cast<const unsigned char*>(aad.data()), an,317
reinterpret_cast<const unsigned char*>(msg.data()), n, got.data()));318
ASSERT_EQ(0, std::memcmp(got.data(), want.data(), want.size()))319
<< "ciphertext/tag differ at n=" << n << " aad=" << an;321
std::vector<unsigned char> back(n);322
ASSERT_TRUE(a::chacha20poly1305_decrypt_into(323
key.data(), nonce.data(), reinterpret_cast<const unsigned char*>(aad.data()), an,324
got.data(), got.size(), back.data()));325
ASSERT_EQ(0, n == 0 ? 0 : std::memcmp(back.data(), msg.data(), n)) << "n=" << n;326
}328
// (c) in-place aliasing: out may equal plaintext.329
{330
const std::string msg = "encrypt me where I already live";331
const std::string want = a::chacha20poly1305_encrypt(kKey, kNonce, "", msg);332
std::vector<unsigned char> buf(msg.size() + 16);333
std::memcpy(buf.data(), msg.data(), msg.size());334
ASSERT_TRUE(a::chacha20poly1305_encrypt_into(key.data(), nonce.data(), nullptr, 0,335
buf.data(), msg.size(), buf.data()));336
EXPECT_EQ(0, std::memcmp(buf.data(), want.data(), want.size()));337
}339
// (d) tamper rejection, and the perturbation guard that proves (c)/(a) can fail: every single340
// bit flip in the ciphertext OR the tag must be refused, and nothing written.341
{342
std::vector<unsigned char> ct(plain.size() + 16);343
ASSERT_TRUE(a::chacha20poly1305_encrypt_into(344
key.data(), nonce.data(), reinterpret_cast<const unsigned char*>(kAad.data()),345
kAad.size(), reinterpret_cast<const unsigned char*>(plain.data()), plain.size(),346
ct.data()));347
for (std::size_t i = 0; i < ct.size(); ++i) {348
std::vector<unsigned char> bad = ct;349
bad[i] ^= 0x01;350
std::vector<unsigned char> back(plain.size(), 0xEE);351
EXPECT_FALSE(a::chacha20poly1305_decrypt_into(352
key.data(), nonce.data(), reinterpret_cast<const unsigned char*>(kAad.data()),353
kAad.size(), bad.data(), bad.size(), back.data()))354
<< "accepted a flipped bit at byte " << i;355
}356
// A changed aad must also fail.357
std::vector<unsigned char> back(plain.size());358
const std::string other_aad = kAad + "x";359
EXPECT_FALSE(a::chacha20poly1305_decrypt_into(360
key.data(), nonce.data(), reinterpret_cast<const unsigned char*>(other_aad.data()),361
other_aad.size(), ct.data(), ct.size(), back.data()));362
}364
// (e) malformed arguments refuse rather than crash — including a null key or nonce, which365
// would otherwise reach memcpy as undefined behaviour.366
std::vector<unsigned char> sink(64);367
EXPECT_FALSE(a::chacha20poly1305_encrypt_into(nullptr, nonce.data(), nullptr, 0, sink.data(), 1,368
sink.data()));369
EXPECT_FALSE(a::chacha20poly1305_encrypt_into(key.data(), nullptr, nullptr, 0, sink.data(), 1,370
sink.data()));371
EXPECT_FALSE(a::chacha20poly1305_decrypt_into(nullptr, nonce.data(), nullptr, 0, sink.data(), 32,372
sink.data()));373
EXPECT_FALSE(a::chacha20poly1305_decrypt_into(key.data(), nullptr, nullptr, 0, sink.data(), 32,374
sink.data()));375
EXPECT_FALSE(a::chacha20poly1305_encrypt_into(key.data(), nonce.data(), nullptr, 5,376
sink.data(), 1, sink.data()));377
EXPECT_FALSE(a::chacha20poly1305_decrypt_into(key.data(), nonce.data(), nullptr, 0,378
sink.data(), 15, sink.data()));379
EXPECT_FALSE(a::chacha20poly1305_encrypt_into(key.data(), nonce.data(), nullptr, 0,380
sink.data(), 1, nullptr));381
}