Source
stdlib/tests/tls_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 `tls` module's key schedule against the RFC 8448 trace constants4
// (the published TLS 1.3 test vectors, SHA-256 suite — suite-independent for the schedule).5
#include <gtest/gtest.h>7
#include <cstdint>8
#include <string>9
#include <algorithm>10
#include <vector>12
#include "hashlib.hpp"13
#include "tls.hpp"15
namespace {16
std::string hex_of(std::string_view raw) {17
static constexpr char kHex[] = "0123456789abcdef";18
std::string out;19
for (const char ch : raw) {20
out.push_back(kHex[static_cast<unsigned char>(ch) >> 4]);21
out.push_back(kHex[static_cast<unsigned char>(ch) & 0xF]);22
}23
return out;24
}26
void be16(std::string& o, unsigned v) {27
o.push_back(static_cast<char>((v >> 8) & 0xFF));28
o.push_back(static_cast<char>(v & 0xFF));29
}31
// A ClientHello handshake message with control over the fields parse_client_hello gates on:32
// the offered cipher suites, whether supported_versions advertises TLS 1.3, whether a valid33
// X25519 key_share is present, the legacy_session_id length (echoed by the server), and the34
// signature_algorithms list (ext 13; empty = extension omitted, the lenient-parse case).35
std::string make_client_hello(const std::vector<unsigned>& suites, bool tls13, bool x25519,36
unsigned sid_len = 0, const std::vector<unsigned>& sig_algs = {}) {37
std::string body;38
be16(body, 0x0303); // legacy_version39
body.append(32, 'R'); // random40
body.push_back(static_cast<char>(sid_len));41
body.append(sid_len, 'S'); // legacy_session_id42
be16(body, static_cast<unsigned>(suites.size() * 2));43
for (unsigned s : suites) be16(body, s);44
body.push_back(1); // legacy_compression_methods length45
body.push_back(0); // null compression46
std::string ext;47
if (tls13) { // supported_versions: [TLS 1.3]48
be16(ext, 43);49
be16(ext, 3);50
ext.push_back(2);51
be16(ext, 0x0304);52
}53
if (x25519) { // key_share: one X25519 entry54
std::string entry;55
be16(entry, 0x001d);56
be16(entry, 32);57
entry.append(32, 'K');58
std::string ks;59
be16(ks, static_cast<unsigned>(entry.size()));60
ks += entry;61
be16(ext, 51);62
be16(ext, static_cast<unsigned>(ks.size()));63
ext += ks;64
}65
if (!sig_algs.empty()) { // signature_algorithms: the u16-pair list66
std::string sa;67
be16(sa, static_cast<unsigned>(sig_algs.size() * 2));68
for (unsigned a : sig_algs) be16(sa, a);69
be16(ext, 13);70
be16(ext, static_cast<unsigned>(sa.size()));71
ext += sa;72
}73
be16(body, static_cast<unsigned>(ext.size()));74
body += ext;75
std::string msg;76
msg.push_back(1); // client_hello77
msg.push_back(static_cast<char>((body.size() >> 16) & 0xFF));78
msg.push_back(static_cast<char>((body.size() >> 8) & 0xFF));79
msg.push_back(static_cast<char>(body.size() & 0xFF));80
msg += body;81
return msg;82
}83
} // namespace85
// RFC 8448 §3: the early secret HKDF-Extract(0, 0^32) and the "derived" secret from it.86
TEST(CheatahTls, KeySchedule) {87
const std::string zeros(32, '\0');88
const std::string early = cheatah::hashlib::hkdf_extract(std::string(), zeros);89
EXPECT_EQ(hex_of(early), "33ad0a1c607ec03b09e6cd9893680ce210adf300aa1f2660e1b22e10f170f92a");90
const std::string derived = cheatah::tls::detail::derive_secret(early, "derived", "");91
EXPECT_EQ(hex_of(derived), "6f2615a108c702c5678f54fc9dbab69716c076189c48250cebeac3576c3611ba");92
}94
// HKDF-Expand-Label structure: deterministic, length-exact, label-sensitive.95
TEST(CheatahTls, ExpandLabel) {96
const std::string secret(32, '\x42');97
const std::string a = cheatah::tls::detail::expand_label(secret, "key", "", 32);98
const std::string b = cheatah::tls::detail::expand_label(secret, "iv", "", 12);99
EXPECT_EQ(a.size(), std::size_t{32});100
EXPECT_EQ(b.size(), std::size_t{12});101
EXPECT_NE(a.substr(0, 12), b); // different labels -> unrelated output102
EXPECT_EQ(a, cheatah::tls::detail::expand_label(secret, "key", "", 32)); // deterministic103
}105
// The server's ClientHello parser: accept a well-formed hello (preferring ChaCha20), fall back to106
// AES-128-GCM when only that is offered, and reject every malformed / unsupported shape. Driving the107
// parser directly (a test seam) covers each refusal branch without a live network peer.108
TEST(CheatahTls, ParseClientHelloAcceptsAndNegotiates) {109
namespace d = cheatah::tls::detail;110
std::string pub, sid, sa;111
unsigned suite = 0;113
// Both suites offered -> ChaCha20-Poly1305 preferred; the X25519 share + session id come back.114
ASSERT_TRUE(d::parse_client_hello(make_client_hello({0x1303, 0x1301}, true, true, 4),115
pub, suite, sid, sa));116
EXPECT_EQ(suite, 0x1303u);117
EXPECT_EQ(pub, std::string(32, 'K'));118
EXPECT_EQ(sid, std::string(4, 'S'));119
EXPECT_EQ(sa, ""); // extension omitted -> lenient parse, empty list121
// Only AES-128-GCM offered -> negotiate it.122
ASSERT_TRUE(d::parse_client_hello(make_client_hello({0x1301}, true, true), pub, suite, sid, sa));123
EXPECT_EQ(suite, 0x1301u);124
}126
TEST(CheatahTls, ParseClientHelloSurfacesSignatureAlgorithms) {127
namespace d = cheatah::tls::detail;128
std::string pub, sid, sa;129
unsigned suite = 0;131
// A browser-shaped offer: ECDSA P-256 + Ed25519 + RSA-PSS. The raw u16 pairs come back in132
// order, so the server can check containment without re-parsing.133
ASSERT_TRUE(d::parse_client_hello(134
make_client_hello({0x1303}, true, true, 0, {0x0403, 0x0807, 0x0804}), pub, suite, sid, sa));135
ASSERT_EQ(sa.size(), std::size_t{6});136
const auto u16 = [&](std::size_t i) {137
return (static_cast<unsigned>(static_cast<unsigned char>(sa[i])) << 8) |138
static_cast<unsigned char>(sa[i + 1]);139
};140
EXPECT_EQ(u16(0), 0x0403u);141
EXPECT_EQ(u16(2), 0x0807u);142
EXPECT_EQ(u16(4), 0x0804u);144
// A stale list from a previous parse never leaks into a hello without the extension.145
ASSERT_TRUE(d::parse_client_hello(make_client_hello({0x1303}, true, true), pub, suite, sid, sa));146
EXPECT_EQ(sa, "");147
}149
TEST(CheatahTls, ParseClientHelloRejectsMalformed) {150
namespace d = cheatah::tls::detail;151
std::string pub, sid, sa;152
unsigned suite = 0;154
EXPECT_FALSE(d::parse_client_hello("", pub, suite, sid, sa)); // too short155
EXPECT_FALSE(d::parse_client_hello(std::string("\x02\x00\x00\x00", 4),156
pub, suite, sid, sa)); // not a client_hello157
EXPECT_FALSE(d::parse_client_hello(make_client_hello({0x9999}, true, true),158
pub, suite, sid, sa)); // no cipher suite in common159
EXPECT_FALSE(d::parse_client_hello(make_client_hello({0x1303}, false, true),160
pub, suite, sid, sa)); // no TLS 1.3 offered161
EXPECT_FALSE(d::parse_client_hello(make_client_hello({0x1303}, true, false),162
pub, suite, sid, sa)); // no X25519 key share164
// Truncations inside each length-prefixed field must be refused, never over-read. Cutting a165
// well-formed hello at increasing offsets walks the bounds checks (session id, cipher-suite166
// list, compression, extensions, key-share body) in turn.167
const std::string full = make_client_hello({0x1303, 0x1301}, true, true, 4, {0x0403});168
for (std::size_t cut = 4; cut < full.size(); ++cut) {169
EXPECT_FALSE(d::parse_client_hello(full.substr(0, cut), pub, suite, sid, sa))170
<< "truncation at " << cut << " must be rejected";171
}172
}174
// PEM block extraction (strict base64) and the Ed25519 PKCS#8 seed parse, incl. the reject paths.175
TEST(CheatahTls, PemBlockExtractsAndRejects) {176
namespace d = cheatah::tls::detail;177
// "hi" base64 is "aGk=" -> a well-formed CERTIFICATE block round-trips to its bytes.178
const std::string pem = "-----BEGIN CERTIFICATE-----\naGk=\n-----END CERTIFICATE-----\n";179
EXPECT_EQ(d::pem_block(pem, "CERTIFICATE"), "hi");180
EXPECT_EQ(d::pem_block(pem, "PRIVATE KEY"), ""); // label absent181
EXPECT_EQ(d::pem_block("-----BEGIN CERTIFICATE-----\naGk=\n", "CERTIFICATE"), ""); // no END183
// A real PKCS#8 Ed25519 key DER: 302e020100300506032b657004220420 || 32-byte seed.184
std::string der = {static_cast<char>(0x30), 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,185
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20};186
der.append(32, static_cast<char>(0xAB));187
EXPECT_EQ(d::ed25519_seed_from_pkcs8(der), std::string(32, static_cast<char>(0xAB)));188
EXPECT_EQ(d::ed25519_seed_from_pkcs8("not a key"), ""); // pattern absent189
}191
// The multi-block reader behind full-chain Certificate emission: every block in order, and any192
// malformed block poisons the whole read — a chain with a hole is worse than no chain.193
TEST(CheatahTls, PemBlocksExtractsChains) {194
namespace d = cheatah::tls::detail;195
const std::string one = "-----BEGIN CERTIFICATE-----\naGk=\n-----END CERTIFICATE-----\n";196
const std::string two = one + "-----BEGIN CERTIFICATE-----\neW8=\n-----END CERTIFICATE-----\n";197
const auto chain = d::pem_blocks(two, "CERTIFICATE");198
ASSERT_EQ(chain.size(), std::size_t{2});199
EXPECT_EQ(chain[0], "hi");200
EXPECT_EQ(chain[1], "yo"); // "yo" base64 is "eW8="201
ASSERT_EQ(d::pem_blocks(one, "CERTIFICATE").size(), std::size_t{1});202
EXPECT_TRUE(d::pem_blocks(two, "PRIVATE KEY").empty()); // label absent -> empty, not error204
const std::string bad =205
one + "-----BEGIN CERTIFICATE-----\n!!!!\n-----END CERTIFICATE-----\n";206
EXPECT_TRUE(d::pem_blocks(bad, "CERTIFICATE").empty()); // one bad block poisons the read207
EXPECT_TRUE(d::pem_blocks("-----BEGIN CERTIFICATE-----\naGk=\n", "CERTIFICATE").empty());208
}210
// The P-256 private-scalar parse: PKCS#8 and SEC1 shapes both yield the scalar; a key on another211
// curve (no prime256v1 OID) is refused rather than misread.212
TEST(CheatahTls, EcP256ScalarFromPem) {213
namespace d = cheatah::tls::detail;214
const std::string scalar(32, '\x11');215
// The pieces the parser anchors on, in PKCS#8 order: the prime256v1 OID TLV, then the216
// SEC1 ECPrivateKey's version + scalar (02 01 01 04 20 <d32>).217
const std::string oid = {0x06, 0x08, 0x2a, static_cast<char>(0x86), 0x48,218
static_cast<char>(0xce), 0x3d, 0x03, 0x01, 0x07};219
const std::string ver_and_scalar = std::string({0x02, 0x01, 0x01, 0x04, 0x20}) + scalar;220
const auto pem_of = [](const std::string& der, const std::string& label) {221
return "-----BEGIN " + label + "-----\n" + cheatah::hashlib::base64_encode(der) +222
"\n-----END " + label + "-----\n";223
};225
EXPECT_EQ(d::ec_p256_scalar_from_pem(pem_of(oid + ver_and_scalar, "PRIVATE KEY")), scalar);226
EXPECT_EQ(d::ec_p256_scalar_from_pem(pem_of(ver_and_scalar + oid, "EC PRIVATE KEY")), scalar);228
// P-384's OID (2B 81 04 00 22) instead of prime256v1: refuse, never misread the scalar.229
const std::string p384_oid = {0x06, 0x05, 0x2b, static_cast<char>(0x81), 0x04, 0x00, 0x22};230
EXPECT_EQ(d::ec_p256_scalar_from_pem(pem_of(p384_oid + ver_and_scalar, "PRIVATE KEY")), "");231
EXPECT_EQ(d::ec_p256_scalar_from_pem("not a key"), "");232
EXPECT_EQ(d::ec_p256_scalar_from_pem(pem_of(oid, "PRIVATE KEY")), ""); // OID but no scalar233
}235
// The ClientHello's cipher ORDER is a wire-format decision that depends on the host CPU: with AES-NI236
// we lead with AES-GCM, without it ChaCha20 leads. Read inline, whichever branch does not match the237
// build machine was dead code no test could reach — so the ordering went unpinned on every machine238
// except the one nobody was testing on. Taking the decision as a parameter makes both orders239
// checkable anywhere.240
TEST(CheatahTls, CipherPreferenceFollowsHardware) {241
const auto suites = [](bool hw) {242
std::string body;243
cheatah::tls::detail::append_cipher_preference(body, hw);244
EXPECT_EQ(body.size(), 6u) << "exactly three suites, two bytes each";245
std::vector<unsigned> out;246
for (std::size_t i = 0; i + 1 < body.size(); i += 2) {247
out.push_back((static_cast<unsigned char>(body[i]) << 8) |248
static_cast<unsigned char>(body[i + 1]));249
}250
return out;251
};253
// With hardware AES, AES-GCM runs multi-GB/s and must be offered first.254
EXPECT_EQ(suites(true), (std::vector<unsigned>{0x1302, 0x1301, 0x1303}));255
// Without it, scalar ChaCha20 is our faster path, so it leads.256
EXPECT_EQ(suites(false), (std::vector<unsigned>{0x1303, 0x1301, 0x1302}));258
// Both orders offer the SAME three suites — the preference changes, never the capability, so a259
// server that honours client order can always still pick something we can actually speak.260
auto a = suites(true), b = suites(false);261
std::sort(a.begin(), a.end());262
std::sort(b.begin(), b.end());263
EXPECT_EQ(a, b);264
}