cheatah
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, plus
4// 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"
14namespace a = cheatah::aead;
16namespace {
17const std::string kKey = "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f";
18const std::string kNonce = "070000004041424344454647";
19const std::string kAad = std::string("\x50\x51\x52\x53\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7", 12);
20const 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).
24const std::string kTagHex = "1ae10b594f09e26a7e902ecbd0600691";
26std::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;
35} // namespace
37// The RFC 8439 §2.8.2 vector: ciphertext prefix + tag must match the spec exactly.
38TEST(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 block
42 EXPECT_EQ(hex_of(ct.substr(ct.size() - 16)), kTagHex); // the Poly1305 tag
45// Decrypt of the spec ciphertext returns the spec plaintext (and the round trip holds).
46TEST(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);
51// Any tampering — ciphertext byte, tag byte, or different aad — must yield "" (rejected).
52TEST(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), "");
65// Empty plaintext and empty aad are valid AEAD inputs (TLS uses empty aad in places).
66TEST(CheatahAead, EmptyInputs) {
67 const std::string ct = a::chacha20poly1305_encrypt(kKey, kNonce, "", "");
68 ASSERT_EQ(ct.size(), std::size_t{16}); // tag only
69 EXPECT_EQ(a::chacha20poly1305_decrypt(kKey, kNonce, "", ct), "");
70 // "" is also the FAILURE value; distinguish via a 1-byte round trip
71 const std::string one = a::chacha20poly1305_encrypt(kKey, kNonce, "", "x");
72 EXPECT_EQ(a::chacha20poly1305_decrypt(kKey, kNonce, "", one), "x");
75// ---- AES-128-GCM (TLS_AES_128_GCM_SHA256) against the NIST/GCM (McGrew & Viega) test vectors ----
76namespace {
77std::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;
84} // namespace
86// GCM Test Case 1: all-zero key + iv, empty AAD and plaintext → ciphertext is just the tag.
87TEST(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");
93// GCM Test Case 2: a single all-zero block, no AAD — exact ciphertext + tag, and the round trip.
94TEST(CheatahAead, AesGcmNistCase2) {
95 const std::string key(32, '0'), iv(24, '0'); // 16 zero key bytes, 12 zero iv bytes
96 const std::string p = unhex("00000000000000000000000000000000");
97 const std::string expect = unhex(
98 "0388dace60b6a392f328c2b971b2fe78" // ciphertext
99 "ab6e47d42cec13bdf53a67b21257bddf"); // tag
100 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);
105// GCM Test Case 4: same key/iv/plaintext as case 3 but WITH AAD — ciphertext unchanged, new tag.
106TEST(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);
117// AES-256-GCM known-answer: McGrew GCM Test Case 16 (the AES-256 analog of Case 4), the standard
118// 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.
120TEST(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);
137// AES-256-GCM round trip + tamper/malformed rejection (mirrors the AES-128 case).
138TEST(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 trip
146 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 bytes
155 EXPECT_EQ(a::aes256gcm_encrypt(std::string(64, 'a'), "00", aad, p), ""); // nonce not 12 bytes
158// Round trip + tamper rejection (ciphertext, tag, aad) + malformed key/nonce/short input.
159TEST(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 trip
165 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 bytes
174 EXPECT_EQ(a::aes128gcm_encrypt(key, "00", aad, p), ""); // nonce not 12 bytes
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), the
179// single sub-block, and empty. Also keeps the scalar reference covered on AES-NI hardware.
180TEST(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 decrypt
198 a::set_force_portable_crypto(true);
199 EXPECT_EQ(a::aes128gcm_decrypt(key, nonce, aad, hw), pt); // portable decrypt
200 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 short
209 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"), "");
230// AES-256-GCM: the hardware path (x86 AES-NI/PCLMULQDQ or ARMv8 AES/PMULL) must agree byte-for-byte
231// 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 path
233// against the reference — just `ctest`.
234TEST(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 decrypt
253 a::set_force_portable_crypto(true);
254 EXPECT_EQ(a::aes256gcm_decrypt(key, nonce, aad, hw), pt); // portable decrypt
255 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);
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.
272TEST(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* literal
283 // (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 single
340 // 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, which
365 // 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));