cheatah
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 constants
4// (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"
15namespace {
16std::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;
26void 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));
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 valid
33// X25519 key_share is present, the legacy_session_id length (echoed by the server), and the
34// signature_algorithms list (ext 13; empty = extension omitted, the lenient-parse case).
35std::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_version
39 body.append(32, 'R'); // random
40 body.push_back(static_cast<char>(sid_len));
41 body.append(sid_len, 'S'); // legacy_session_id
42 be16(body, static_cast<unsigned>(suites.size() * 2));
43 for (unsigned s : suites) be16(body, s);
44 body.push_back(1); // legacy_compression_methods length
45 body.push_back(0); // null compression
46 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 entry
54 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 list
66 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_hello
77 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;
83} // namespace
85// RFC 8448 §3: the early secret HKDF-Extract(0, 0^32) and the "derived" secret from it.
86TEST(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");
94// HKDF-Expand-Label structure: deterministic, length-exact, label-sensitive.
95TEST(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 output
102 EXPECT_EQ(a, cheatah::tls::detail::expand_label(secret, "key", "", 32)); // deterministic
105// The server's ClientHello parser: accept a well-formed hello (preferring ChaCha20), fall back to
106// AES-128-GCM when only that is offered, and reject every malformed / unsupported shape. Driving the
107// parser directly (a test seam) covers each refusal branch without a live network peer.
108TEST(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 list
121 // 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);
126TEST(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 in
132 // 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, "");
149TEST(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 short
155 EXPECT_FALSE(d::parse_client_hello(std::string("\x02\x00\x00\x00", 4),
156 pub, suite, sid, sa)); // not a client_hello
157 EXPECT_FALSE(d::parse_client_hello(make_client_hello({0x9999}, true, true),
158 pub, suite, sid, sa)); // no cipher suite in common
159 EXPECT_FALSE(d::parse_client_hello(make_client_hello({0x1303}, false, true),
160 pub, suite, sid, sa)); // no TLS 1.3 offered
161 EXPECT_FALSE(d::parse_client_hello(make_client_hello({0x1303}, true, false),
162 pub, suite, sid, sa)); // no X25519 key share
164 // Truncations inside each length-prefixed field must be refused, never over-read. Cutting a
165 // well-formed hello at increasing offsets walks the bounds checks (session id, cipher-suite
166 // 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 }
174// PEM block extraction (strict base64) and the Ed25519 PKCS#8 seed parse, incl. the reject paths.
175TEST(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 absent
181 EXPECT_EQ(d::pem_block("-----BEGIN CERTIFICATE-----\naGk=\n", "CERTIFICATE"), ""); // no END
183 // 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 absent
191// The multi-block reader behind full-chain Certificate emission: every block in order, and any
192// malformed block poisons the whole read — a chain with a hole is worse than no chain.
193TEST(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 error
204 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 read
207 EXPECT_TRUE(d::pem_blocks("-----BEGIN CERTIFICATE-----\naGk=\n", "CERTIFICATE").empty());
210// The P-256 private-scalar parse: PKCS#8 and SEC1 shapes both yield the scalar; a key on another
211// curve (no prime256v1 OID) is refused rather than misread.
212TEST(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 the
216 // 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 scalar
235// The ClientHello's cipher ORDER is a wire-format decision that depends on the host CPU: with AES-NI
236// we lead with AES-GCM, without it ChaCha20 leads. Read inline, whichever branch does not match the
237// build machine was dead code no test could reach — so the ordering went unpinned on every machine
238// except the one nobody was testing on. Taking the decision as a parameter makes both orders
239// checkable anywhere.
240TEST(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 a
259 // 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);