cheatah
Source

tests/purrc/tls_sys_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// System tests for the `tls` module: a REAL TLS 1.3 handshake against `openssl s_server`
4// (test infrastructure only — the client side is pure cheatah crypto: x25519, ChaCha20-
5// Poly1305, HKDF, Ed25519 verification). Also the refusal paths: a non-TLS peer, and a
6// closed port.
7#include <chrono>
8#include <cstdio>
9#include <cstdlib>
10#include <string>
11#include <thread>
12#include <utility>
13#include <vector>
15#include <unistd.h> // access/X_OK — probe for a Homebrew openssl on macOS
17#include <gtest/gtest.h>
19#include "e2e_harness.hpp"
21#include "socket.hpp"
22#include "tls.hpp"
23#include "tls_lowlevel.hpp" // this C++ test drives the raw handle API (hidden from cheatah)
25namespace sock = cheatah::socket;
26namespace tls = cheatah::tls;
28namespace {
30// Resolve the `openssl` CLI used purely as test infrastructure (the peer). On Linux the
31// system openssl is real OpenSSL; on macOS /usr/bin/openssl is LibreSSL, whose s_server/req
32// flag surface differs (e.g. -newkey ed25519, -ciphersuites), so prefer a Homebrew OpenSSL.
33// Overridable via $CHEATAH_OPENSSL for unusual layouts.
34const std::string& openssl_bin() {
35 static const std::string bin = [] () -> std::string {
36 if (const char* env = std::getenv("CHEATAH_OPENSSL"); env && *env) return env;
37#if defined(__APPLE__)
38 for (const char* cand : {"/opt/homebrew/opt/openssl@3/bin/openssl",
39 "/usr/local/opt/openssl@3/bin/openssl",
40 "/opt/homebrew/bin/openssl",
41 "/usr/local/bin/openssl"}) {
42 if (::access(cand, X_OK) == 0) return cand;
43 }
44#endif
45 return "openssl"; // real OpenSSL on PATH (Linux); on macOS a LibreSSL fallback
46 }();
47 return bin;
50// Generate a throwaway self-signed cert (@p newkey selects the key algorithm — "ed25519",
51// "rsa:2048", "ec" …) and start `openssl s_server` on @p port restricted to @p ciphersuites.
52// Returns true when the server is accepting. Killed via pkill in stop(). This is what lets the
53// system tests exercise each leaf-cert algorithm (Ed25519 / RSA-PSS / ECDSA P-256) and each record
54// cipher (ChaCha20-Poly1305 / AES-128-GCM) against a real TLS 1.3 peer.
55// @complexity O(1) (two subprocesses) @alloc the command strings @test TlsSys (helper)
56class OpensslServer {
57public:
58 // request_client_cert adds `-verify 1`, which makes s_server send a CertificateRequest
59 // and ACCEPT a client that declines (unlike -Verify, which demands one). That is exactly
60 // the peer that used to break us: smtp.gmail.com asks, we do not present a certificate,
61 // and the handshake must still complete.
62 explicit OpensslServer(long long port, const std::string& newkey = "ed25519",
63 const std::string& ciphersuites = "TLS_CHACHA20_POLY1305_SHA256",
64 bool request_client_cert = false)
65 // Per-port cert/key paths so concurrent TlsSys tests (ctest -j) don't clobber each other's
66 // files — the client now VALIDATES the cert, so a shared path would race into failures.
67 : port_(port),
68 cert_(std::string(PURR_TEST_TMP) + "/tls_test_cert_" + std::to_string(port) + ".pem"),
69 key_(std::string(PURR_TEST_TMP) + "/tls_test_key_" + std::to_string(port) + ".pem") {
70 // Self-signed, with a subjectAltName so it is a valid trust anchor for "localhost": passed
71 // to the client as a ca_file (or via $SSL_CERT_FILE) it authenticates itself.
72 const std::string gen = openssl_bin() + " req -x509 -newkey " + newkey + " -keyout '" + key_ +
73 "' -out '" + cert_ +
74 "' -days 2 -nodes -subj /CN=localhost "
75 "-addext subjectAltName=DNS:localhost 2>/dev/null";
76 ok_ = std::system(gen.c_str()) == 0;
77 if (!ok_) return;
78 const std::string serve = openssl_bin() + " s_server -accept " + std::to_string(port_) +
79 " -cert '" + cert_ + "' -key '" + key_ +
80 "' -tls1_3 -ciphersuites " + ciphersuites + " -www " +
81 (request_client_cert ? "-verify 1 " : "") +
82 ">/dev/null 2>&1 &";
83 ok_ = std::system(serve.c_str()) == 0;
84 std::this_thread::sleep_for(std::chrono::milliseconds(700)); // let it bind
85 }
86 ~OpensslServer() {
87 const std::string kill = "pkill -f 's_server -accept " + std::to_string(port_) + "'";
88 std::system(kill.c_str());
89 }
90 [[nodiscard]] bool ok() const { return ok_; }
91 [[nodiscard]] long long port() const { return port_; }
92 [[nodiscard]] const std::string& cert_path() const { return cert_; }
94private:
95 long long port_;
96 std::string cert_, key_;
97 bool ok_ = false;
98};
100} // namespace
102// The full handshake: X25519 exchange, transcript-verified Finished, Ed25519 CertificateVerify,
103// then an HTTP exchange over the encrypted channel (s_server -www answers GET with 200).
104TEST(TlsSys, HandshakeAgainstOpenssl) {
105 OpensslServer server(47931);
106 ASSERT_TRUE(server.ok()) << "could not start openssl s_server (test infrastructure)";
107 const long long fd = sock::tcp_connect("127.0.0.1", server.port());
108 ASSERT_GE(fd, 0);
109 sock::set_timeout(fd, 5000);
110 const long long s = tls::client_connect(fd, "localhost", false, server.cert_path());
111 ASSERT_GE(s, 0) << tls::last_error();
113 ASSERT_EQ(tls::send(s, "GET / HTTP/1.0\r\n\r\n"), 0) << tls::last_error();
114 std::string all;
115 for (;;) {
116 const std::string chunk = tls::recv(s, 65536);
117 if (chunk.empty()) break;
118 all += chunk;
119 }
120 EXPECT_EQ(all.compare(0, 15, "HTTP/1.0 200 ok"), 0) << all.substr(0, 60);
121 tls::close(s);
122 sock::close(fd);
125// The owning-guard round trip: socket::Conn + tls::Conn (the `with`-friendly RAII API) run the
126// same X25519 handshake + encrypted GET, but the fd and TLS session are released deterministically
127// by the guards' destructors. Exercises tls::open/Conn::send/recv/shutdown/close/is_open/id and
128// both move operations end-to-end against a real peer.
129TEST(TlsSys, ConnGuardRoundTrip) {
130 OpensslServer server(47960);
131 ASSERT_TRUE(server.ok()) << "could not start openssl s_server (test infrastructure)";
132 sock::Conn tcp = sock::open("127.0.0.1", server.port());
133 ASSERT_TRUE(tcp.is_open()) << sock::last_error();
134 tcp.set_timeout(5000);
135 tls::Conn conn = tls::open(tcp.fd(), "localhost", false, server.cert_path());
136 ASSERT_TRUE(conn.is_open()) << tls::last_error();
137 EXPECT_GT(conn.id(), 0);
139 tls::Conn active(std::move(conn)); // move-construct: transfer ownership
140 EXPECT_FALSE(conn.is_open());
141 ASSERT_EQ(active.send("GET / HTTP/1.0\r\n\r\n"), 0) << tls::last_error();
142 std::string all;
143 for (;;) {
144 const std::string chunk = active.recv(65536);
145 if (chunk.empty()) break;
146 all += chunk;
147 }
148 EXPECT_EQ(all.compare(0, 15, "HTTP/1.0 200 ok"), 0) << all.substr(0, 60);
150 tls::Conn sink;
151 sink = std::move(active); // move-assign onto a closed guard
152 EXPECT_FALSE(active.is_open());
153 EXPECT_EQ(sink.shutdown(), 0);
154 EXPECT_EQ(sink.close(), 0);
155 EXPECT_EQ(sink.close(), -1); // idempotent
156 // `tcp` (socket::Conn) closes the fd via its destructor here — no leak.
159namespace {
160// Connect to a local TLS 1.3 server, GET /, and return true iff it answered 200 over the encrypted
161// channel — i.e. the FULL cheatah handshake (key exchange + leaf-cert verify + record cipher) and an
162// encrypted round trip all succeeded. The per-cert/per-cipher system tests below assert on this.
163bool handshake_gets_200(long long port, const std::string& ca_file) {
164 const long long fd = sock::tcp_connect("127.0.0.1", port);
165 if (fd < 0) return false;
166 sock::set_timeout(fd, 5000);
167 // Verify the server against its own self-signed cert (SAN localhost) supplied as the CA.
168 const long long s = tls::client_connect(fd, "localhost", false, ca_file);
169 if (s < 0) {
170 sock::close(fd);
171 return false;
172 }
173 tls::send(s, "GET / HTTP/1.0\r\n\r\n");
174 std::string all;
175 for (;;) {
176 const std::string c = tls::recv(s, 65536);
177 if (c.empty()) break;
178 all += c;
179 if (all.size() > 40) break;
180 }
181 tls::close(s);
182 sock::close(fd);
183 return all.compare(0, 12, "HTTP/1.0 200") == 0;
185} // namespace
187// RSA leaf certificate (ChaCha20 isolates the cert path): exercises RSA-PSS (rsa_pss_rsae_sha256)
188// CertificateVerify — the cheatah TLS client proving an RSA server's key possession.
189TEST(TlsSys, HandshakeRsaCertificate) {
190 OpensslServer server(47941, "rsa:2048", "TLS_CHACHA20_POLY1305_SHA256");
191 ASSERT_TRUE(server.ok()) << "could not start openssl s_server (test infrastructure)";
192 EXPECT_TRUE(handshake_gets_200(server.port(), server.cert_path())) << tls::last_error();
195// A server that ASKS for a client certificate. RFC 8446 §4.4.2: a client with nothing to present
196// must still answer the CertificateRequest with a Certificate message carrying an empty
197// certificate_list and echoing the request's context — a bare Finished is an unexpected_message and
198// the peer aborts. We used to send the bare Finished, so any such server was unreachable;
199// smtp.gmail.com is one, which is how this surfaced.
200//
201// `-verify 1` requests without requiring, so a correct decline still reaches the HTTP exchange:
202// a 200 here proves the client both sent the empty Certificate AND folded it into the transcript
203// (get either wrong and the server's Finished check fails instead).
204TEST(TlsSys, DeclinesCertificateRequest) {
205 OpensslServer server(47951, "ed25519", "TLS_CHACHA20_POLY1305_SHA256", true);
206 ASSERT_TRUE(server.ok()) << "could not start openssl s_server (test infrastructure)";
207 EXPECT_TRUE(handshake_gets_200(server.port(), server.cert_path())) << tls::last_error();
210// AES-128-GCM record cipher (Ed25519 cert isolates the cipher): exercises the AES-128-GCM
211// seal_record/open_record path on a real TLS 1.3 channel.
212TEST(TlsSys, HandshakeAes128Gcm) {
213 OpensslServer server(47942, "ed25519", "TLS_AES_128_GCM_SHA256");
214 ASSERT_TRUE(server.ok()) << "could not start openssl s_server (test infrastructure)";
215 EXPECT_TRUE(handshake_gets_200(server.port(), server.cert_path())) << tls::last_error();
218// RSA leaf cert AND AES-128-GCM together — the exact combination required to reach exchanges whose
219// stream endpoints serve an RSA chain over an AES-GCM-only cipher policy (both additions at once).
220TEST(TlsSys, HandshakeRsaAndAes128Gcm) {
221 OpensslServer server(47943, "rsa:2048", "TLS_AES_128_GCM_SHA256");
222 ASSERT_TRUE(server.ok()) << "could not start openssl s_server (test infrastructure)";
223 EXPECT_TRUE(handshake_gets_200(server.port(), server.cert_path())) << tls::last_error();
226// ECDSA P-256 leaf certificate: exercises the ecdsa_secp256r1_sha256 CertificateVerify path.
227TEST(TlsSys, HandshakeEcdsaP256Certificate) {
228 OpensslServer server(47944, "ec -pkeyopt ec_paramgen_curve:prime256v1",
229 "TLS_CHACHA20_POLY1305_SHA256");
230 ASSERT_TRUE(server.ok()) << "could not start openssl s_server (test infrastructure)";
231 EXPECT_TRUE(handshake_gets_200(server.port(), server.cert_path())) << tls::last_error();
234// ECDSA P-384 leaf certificate: exercises the ecdsa_secp384r1_sha384 CertificateVerify path
235// and, via the self-signed cert's own signature, the x509 P-384 chain-verification arm.
236TEST(TlsSys, HandshakeEcdsaP384Certificate) {
237 OpensslServer server(47946, "ec -pkeyopt ec_paramgen_curve:secp384r1",
238 "TLS_CHACHA20_POLY1305_SHA256");
239 ASSERT_TRUE(server.ok()) << "could not start openssl s_server (test infrastructure)";
240 EXPECT_TRUE(handshake_gets_200(server.port(), server.cert_path())) << tls::last_error();
243// TLS_AES_256_GCM_SHA384: exercises the SHA-384 key schedule + AES-256-GCM record cipher end-to-end
244// against openssl as the reference peer, with an Ed25519 leaf (isolates the suite from the cert path).
245TEST(TlsSys, HandshakeAes256GcmSha384) {
246 OpensslServer server(47947, "ed25519", "TLS_AES_256_GCM_SHA384");
247 ASSERT_TRUE(server.ok()) << "could not start openssl s_server (test infrastructure)";
248 EXPECT_TRUE(handshake_gets_200(server.port(), server.cert_path())) << tls::last_error();
251// The hardest combination: an ECDSA P-384 leaf UNDER the TLS_AES_256_GCM_SHA384 suite — the P-384
252// CertificateVerify (0x0503) + the P-384 x509 chain arm + the SHA-384 key schedule + AES-256-GCM
253// records all exercised together in one handshake.
254TEST(TlsSys, HandshakeEcdsaP384AndAes256Sha384) {
255 OpensslServer server(47948, "ec -pkeyopt ec_paramgen_curve:secp384r1",
256 "TLS_AES_256_GCM_SHA384");
257 ASSERT_TRUE(server.ok()) << "could not start openssl s_server (test infrastructure)";
258 EXPECT_TRUE(handshake_gets_200(server.port(), server.cert_path())) << tls::last_error();
261// No common cipher suite (server offers ONLY AES-128-CCM, which cheatah does not implement): the
262// handshake MUST fail rather than silently proceed, and the error must NAME the alert — exercising the
263// alert-code diagnostic so a "no common cipher" refusal reports a named reason, not a generic error.
264TEST(TlsSys, RefusesUnsupportedCipherWithNamedAlert) {
265 OpensslServer server(47945, "ed25519", "TLS_AES_128_CCM_SHA256");
266 ASSERT_TRUE(server.ok()) << "could not start openssl s_server (test infrastructure)";
267 const long long fd = sock::tcp_connect("127.0.0.1", server.port());
268 ASSERT_GE(fd, 0);
269 sock::set_timeout(fd, 5000);
270 const long long s = tls::client_connect(fd, "localhost");
271 EXPECT_LT(s, 0); // no shared suite -> the handshake must fail
272 EXPECT_NE(tls::last_error().find("handshake_failure"), std::string::npos) << tls::last_error();
273 sock::close(fd);
276// A peer that speaks plaintext garbage must fail the handshake with a clear error.
277TEST(TlsSys, RefusesBadPeer) {
278 const long long listen_fd = sock::tcp_listen("127.0.0.1", 0, 4);
279 ASSERT_GE(listen_fd, 0);
280 const long long port = sock::local_port(listen_fd);
281 std::thread peer([listen_fd]() {
282 const long long client = sock::accept(listen_fd);
283 sock::sendall(client, "definitely not a TLS server\r\n");
284 sock::close(client);
285 });
286 const long long fd = sock::tcp_connect("127.0.0.1", port);
287 ASSERT_GE(fd, 0);
288 sock::set_timeout(fd, 2000);
289 const long long s = tls::client_connect(fd, "localhost");
290 EXPECT_LT(s, 0);
291 EXPECT_FALSE(tls::last_error().empty());
292 sock::close(fd);
293 peer.join();
294 sock::close(listen_fd);
297// A server that, right after the client's ClientHello, sends a plaintext TLS alert record instead of
298// a ServerHello must fail the handshake with a NAMED alert. This is adversarial crafted-byte input
299// (a 7-byte record: content_type 21 || version 0303 || length 0002 || level || description) — NOT a
300// mirrored handshake — and it drives alert_text()'s human-readable name table over every RFC 8446
301// alert description the client recognizes, so a refusal reports a specific cause, not a bare code.
302TEST(TlsSys, NamesEveryHandshakeAlert) {
303 // description code -> the exact substring alert_text() must place in last_error().
304 const std::vector<std::pair<int, std::string>> alerts = {
305 {0, "close_notify"}, {10, "unexpected_message"},
306 {20, "bad_record_mac"}, {22, "record_overflow"},
307 {42, "bad_certificate"}, {43, "unsupported_certificate"},
308 {47, "illegal_parameter"}, {48, "unknown_ca"},
309 {49, "access_denied"}, {50, "decode_error"},
310 {51, "decrypt_error"}, {70, "protocol_version"},
311 {71, "insufficient_security"},{80, "internal_error"},
312 {109, "missing_extension"}, {110, "unsupported_extension"},
313 {112, "unrecognized_name"}, {116, "certificate_required"},
314 {120, "no_application_protocol"},
315 {200, "unknown"}, // an unlisted code -> the default "unknown" name
316 };
317 for (const auto& [code, name] : alerts) {
318 const long long listen_fd = sock::tcp_listen("127.0.0.1", 0, 4);
319 ASSERT_GE(listen_fd, 0);
320 const long long port = sock::local_port(listen_fd);
321 std::thread peer([listen_fd, code]() {
322 const long long client = sock::accept(listen_fd);
323 if (client < 0) return;
324 sock::recv(client, 16384); // drain the ClientHello, then answer with a fatal alert
325 // Build the 7-byte alert record explicitly — a "\x00" in a string literal would
326 // terminate it early, so append each byte (type 21 || ver 0303 || len 0002 || lvl || desc).
327 std::string alert;
328 alert.push_back(static_cast<char>(21)); // content_type = alert
329 alert.push_back(static_cast<char>(0x03)); // legacy record version 0x0303
330 alert.push_back(static_cast<char>(0x03));
331 alert.push_back(static_cast<char>(0x00)); // length = 2
332 alert.push_back(static_cast<char>(0x02));
333 alert.push_back(static_cast<char>(2)); // level = fatal
334 alert.push_back(static_cast<char>(code & 0xFF)); // description
335 sock::sendall(client, alert);
336 sock::close(client);
337 });
338 const long long fd = sock::tcp_connect("127.0.0.1", port);
339 ASSERT_GE(fd, 0);
340 sock::set_timeout(fd, 2000);
341 const long long s = tls::client_connect(fd, "localhost");
342 EXPECT_LT(s, 0) << "an alert instead of ServerHello must fail the handshake (code " << code << ")";
343 EXPECT_NE(tls::last_error().find(name), std::string::npos)
344 << "alert " << code << " should be named '" << name << "': " << tls::last_error();
345 sock::close(fd);
346 peer.join();
347 sock::close(listen_fd);
348 }
351// A short (1-byte) alert record must not misparse: alert_text reports "(empty alert)" rather than
352// reading a description byte that is not there — the p.size() < 2 guard.
353TEST(TlsSys, ShortAlertRecordIsHandled) {
354 const long long listen_fd = sock::tcp_listen("127.0.0.1", 0, 4);
355 ASSERT_GE(listen_fd, 0);
356 const long long port = sock::local_port(listen_fd);
357 std::thread peer([listen_fd]() {
358 const long long client = sock::accept(listen_fd);
359 if (client < 0) return;
360 sock::recv(client, 16384);
361 std::string alert; // an alert record with length=1 (truncated body: level only, no desc)
362 alert.push_back(static_cast<char>(21)); // content_type = alert
363 alert.push_back(static_cast<char>(0x03)); // legacy record version 0x0303
364 alert.push_back(static_cast<char>(0x03));
365 alert.push_back(static_cast<char>(0x00)); // length = 1
366 alert.push_back(static_cast<char>(0x01));
367 alert.push_back(static_cast<char>(2)); // one byte only (level, no description)
368 sock::sendall(client, alert);
369 sock::close(client);
370 });
371 const long long fd = sock::tcp_connect("127.0.0.1", port);
372 ASSERT_GE(fd, 0);
373 sock::set_timeout(fd, 2000);
374 const long long s = tls::client_connect(fd, "localhost");
375 EXPECT_LT(s, 0);
376 EXPECT_NE(tls::last_error().find("(empty alert)"), std::string::npos) << tls::last_error();
377 sock::close(fd);
378 peer.join();
379 sock::close(listen_fd);
382// Verify-by-default REFUSES an untrusted (self-signed, unknown-CA) server: this is the MITM
383// defense — key possession alone no longer completes the handshake. Exercises the default
384// (system) trust store + the validation-failure path.
385TEST(TlsSys, VerifyRejectsUntrustedServer) {
386 OpensslServer server(47934);
387 ASSERT_TRUE(server.ok()) << "could not start openssl s_server (test infrastructure)";
388 const long long fd = sock::tcp_connect("127.0.0.1", server.port());
389 ASSERT_GE(fd, 0);
390 sock::set_timeout(fd, 5000);
391 const long long s = tls::client_connect(fd, "localhost"); // no ca_file -> system store only
392 EXPECT_LT(s, 0);
393 EXPECT_NE(tls::last_error().find("certificate validation failed"), std::string::npos)
394 << tls::last_error();
395 sock::close(fd);
398// Verify-by-default REFUSES a certificate whose SAN does not match the requested hostname (even
399// though the cert is otherwise trusted via ca_file).
400TEST(TlsSys, VerifyRejectsWrongHostname) {
401 OpensslServer server(47935); // SAN = localhost
402 ASSERT_TRUE(server.ok()) << "could not start openssl s_server (test infrastructure)";
403 const long long fd = sock::tcp_connect("127.0.0.1", server.port());
404 ASSERT_GE(fd, 0);
405 sock::set_timeout(fd, 5000);
406 const long long s = tls::client_connect(fd, "wrong.example", false, server.cert_path());
407 EXPECT_LT(s, 0);
408 EXPECT_NE(tls::last_error().find("host"), std::string::npos) << tls::last_error();
409 sock::close(fd);
412// insecure=true (a pinned/controlled peer) skips validation: the same untrusted, wrong-hostname
413// server that verification refuses is accepted.
414TEST(TlsSys, InsecureSkipsValidation) {
415 OpensslServer server(47936);
416 ASSERT_TRUE(server.ok()) << "could not start openssl s_server (test infrastructure)";
417 const long long fd = sock::tcp_connect("127.0.0.1", server.port());
418 ASSERT_GE(fd, 0);
419 sock::set_timeout(fd, 5000);
420 const long long s = tls::client_connect(fd, "wrong.example", true, ""); // insecure -> no checks
421 ASSERT_GE(s, 0) << tls::last_error();
422 tls::close(s);
423 sock::close(fd);
426// THE FINALE: a pure-cheatah https GET — requests.purr (HTTP in .purr) over the tls module
427// (from-scratch TLS 1.3) over the cheatah crypto modules, against a real TLS server, WITH full
428// certificate validation (the cert's own PEM as the trusted CA; host "localhost" matches its SAN).
429TEST(TlsSys, HttpsGet) {
430 OpensslServer server(47933);
431 ASSERT_TRUE(server.ok()) << "could not start openssl s_server (test infrastructure)";
432 const std::string src = "import requests\nimport io\n"
433 "let o = requests.Options({.timeout_ms = 5000, .ca_file = \"" +
434 server.cert_path() + "\"})\n"
435 "let r = requests.get(\"https://localhost:" +
436 std::to_string(server.port()) +
437 "/\", o)\nio.print(r.status_code)\nio.print(r.ok())\n"
438 "io.print(len(r.body) > 0)\n";
439 e2e::expect_e2e("requests_https_get", src, "200\nTrue\nTrue\n");
442namespace {
443std::string slurp(const std::string& path) {
444 FILE* f = std::fopen(path.c_str(), "rb");
445 if (!f) return "";
446 std::string out;
447 char buf[4096];
448 std::size_t n;
449 while ((n = std::fread(buf, 1, sizeof buf, f)) > 0) out.append(buf, n);
450 std::fclose(f);
451 return out;
454// Run `openssl s_client` against our server and return its stdout. `-verify_return_error` +
455// `-CAfile <our cert>` makes OpenSSL — the reference implementation — FAIL unless our from-scratch
456// server handshake (ServerHello, key schedule, Certificate, Ed25519 CertificateVerify, Finished)
457// is byte-correct and the cert validates. So a passing assertion is OpenSSL certifying our server.
458std::string run_s_client(long long port, const std::string& ca_path, const std::string& request) {
459 const std::string cmd =
460 "printf '" + request + "' | openssl s_client -connect 127.0.0.1:" + std::to_string(port) +
461 " -tls1_3 -CAfile '" + ca_path + "' -verify_return_error -servername localhost -quiet 2>&1";
462 FILE* p = popen(cmd.c_str(), "r");
463 if (!p) return "";
464 std::string out;
465 char buf[4096];
466 std::size_t n;
467 while ((n = std::fread(buf, 1, sizeof buf, p)) > 0) out.append(buf, n);
468 pclose(p);
469 return out;
471} // namespace
473// The MIRROR of HandshakeAgainstOpenssl: now the SERVER is pure cheatah and OpenSSL is the client.
474// A cheatah TLS server (tls::server_accept, Ed25519 cert) accepts one connection, and `openssl
475// s_client -verify_return_error` completes the TLS 1.3 handshake and reads our reply — so OpenSSL
476// validates our ServerHello + certificate flight + Ed25519 CertificateVerify + Finished end to end.
477// HTTPS with zero non-cheatah software on the server side.
478TEST(TlsSys, ServerHandshakeAgainstOpenssl) {
479 const long long port = 47971;
480 const std::string dir = PURR_TEST_TMP;
481 const std::string cert = dir + "/tls_srv_cert_" + std::to_string(port) + ".pem";
482 const std::string key = dir + "/tls_srv_key_" + std::to_string(port) + ".pem";
483 const std::string gen = "openssl req -x509 -newkey ed25519 -keyout '" + key + "' -out '" +
484 cert + "' -days 2 -nodes -subj /CN=localhost "
485 "-addext subjectAltName=DNS:localhost 2>/dev/null";
486 ASSERT_EQ(std::system(gen.c_str()), 0) << "could not generate an Ed25519 cert (test infra)";
487 const std::string cert_pem = slurp(cert), key_pem = slurp(key);
488 ASSERT_FALSE(cert_pem.empty());
489 ASSERT_FALSE(key_pem.empty());
491 const long long listen_fd = sock::tcp_listen("127.0.0.1", port, 4);
492 ASSERT_GE(listen_fd, 0) << sock::last_error();
494 // Server thread: accept ONE client, run the cheatah TLS server handshake through the owning
495 // `tls::accept` guard (the leak-safe API a cheatah program uses), and answer its GET.
496 std::string srv_err;
497 std::thread server([&] {
498 const long long conn = sock::accept(listen_fd);
499 if (conn < 0) { srv_err = "accept failed"; return; }
500 sock::set_timeout(conn, 5000);
501 tls::Conn tc = tls::accept(conn, cert_pem, key_pem); // owning guard, closes at scope exit
502 if (!tc.is_open()) { srv_err = tls::last_error(); sock::close(conn); return; }
503 tc.recv(4096); // drain the client's request line
504 const std::string body = "hello from a pure-cheatah TLS server";
505 tc.send("HTTP/1.0 200 ok\r\nContent-Length: " + std::to_string(body.size()) +
506 "\r\nConnection: close\r\n\r\n" + body);
507 sock::close(conn); // tc closes the TLS session via its destructor here
508 });
510 const std::string out = run_s_client(port, cert, "GET / HTTP/1.0\\r\\n\\r\\n");
511 server.join();
512 sock::close(listen_fd);
514 EXPECT_TRUE(srv_err.empty()) << "cheatah server: " << srv_err;
515 EXPECT_NE(out.find("hello from a pure-cheatah TLS server"), std::string::npos)
516 << "openssl s_client output:\n" << out;
519// The ECDSA mirror of ServerHandshakeAgainstOpenssl: the SAME cheatah server code presenting a
520// P-256 leaf — the certificate type public CAs actually issue — with OpenSSL validating our
521// ecdsa_secp256r1_sha256 CertificateVerify end to end. This is the browser-facing HTTPS shape.
522TEST(TlsSys, ServerHandshakeEcdsaAgainstOpenssl) {
523 const long long port = 47972;
524 const std::string dir = PURR_TEST_TMP;
525 const std::string cert = dir + "/tls_srv_ec_cert_" + std::to_string(port) + ".pem";
526 const std::string key = dir + "/tls_srv_ec_key_" + std::to_string(port) + ".pem";
527 const std::string gen = "openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 "
528 "-keyout '" + key + "' -out '" + cert +
529 "' -days 2 -nodes -subj /CN=localhost "
530 "-addext subjectAltName=DNS:localhost 2>/dev/null";
531 ASSERT_EQ(std::system(gen.c_str()), 0) << "could not generate a P-256 cert (test infra)";
532 const std::string cert_pem = slurp(cert), key_pem = slurp(key);
533 ASSERT_FALSE(cert_pem.empty());
534 ASSERT_FALSE(key_pem.empty());
536 const long long listen_fd = sock::tcp_listen("127.0.0.1", port, 4);
537 ASSERT_GE(listen_fd, 0) << sock::last_error();
538 std::string srv_err;
539 std::thread server([&] {
540 const long long conn = sock::accept(listen_fd);
541 if (conn < 0) { srv_err = "accept failed"; return; }
542 sock::set_timeout(conn, 5000);
543 tls::Conn tc = tls::accept(conn, cert_pem, key_pem);
544 if (!tc.is_open()) { srv_err = tls::last_error(); sock::close(conn); return; }
545 tc.recv(4096);
546 const std::string body = "hello from a pure-cheatah ECDSA TLS server";
547 tc.send("HTTP/1.0 200 ok\r\nContent-Length: " + std::to_string(body.size()) +
548 "\r\nConnection: close\r\n\r\n" + body);
549 sock::close(conn);
550 });
552 const std::string out = run_s_client(port, cert, "GET / HTTP/1.0\\r\\n\\r\\n");
553 server.join();
554 sock::close(listen_fd);
556 EXPECT_TRUE(srv_err.empty()) << "cheatah server: " << srv_err;
557 EXPECT_NE(out.find("hello from a pure-cheatah ECDSA TLS server"), std::string::npos)
558 << "openssl s_client output:\n" << out;
561// A CA-signed leaf served as a fullchain.pem (leaf + intermediate in one file — the exact artifact
562// Let's Encrypt/acme.sh hand a production server): the Certificate message must carry EVERY block,
563// because s_client is given only the CA. A leaf-only emission (the old behavior) cannot validate.
564TEST(TlsSys, ServerHandshakeFullChainAgainstOpenssl) {
565 const long long port = 47973;
566 const std::string dir = PURR_TEST_TMP;
567 const std::string ca_key = dir + "/tls_chain_ca_key.pem", ca_cert = dir + "/tls_chain_ca.pem";
568 const std::string leaf_key = dir + "/tls_chain_leaf_key.pem";
569 const std::string leaf_csr = dir + "/tls_chain_leaf.csr";
570 const std::string leaf_cert = dir + "/tls_chain_leaf.pem";
571 const std::string ext = dir + "/tls_chain_ext.cnf";
572 ASSERT_EQ(std::system(("openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 "
573 "-keyout '" + ca_key + "' -out '" + ca_cert +
574 "' -days 2 -nodes -subj /CN=cheatah-test-ca 2>/dev/null").c_str()), 0);
575 ASSERT_EQ(std::system(("openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 "
576 "-keyout '" + leaf_key + "' -out '" + leaf_csr +
577 "' -nodes -subj /CN=localhost 2>/dev/null").c_str()), 0);
578 {
579 std::FILE* f = std::fopen(ext.c_str(), "w");
580 ASSERT_NE(f, nullptr);
581 std::fputs("subjectAltName=DNS:localhost\n", f);
582 std::fclose(f);
583 }
584 ASSERT_EQ(std::system(("openssl x509 -req -in '" + leaf_csr + "' -CA '" + ca_cert +
585 "' -CAkey '" + ca_key + "' -CAcreateserial -days 2 -extfile '" + ext +
586 "' -out '" + leaf_cert + "' 2>/dev/null").c_str()), 0);
587 const std::string fullchain = slurp(leaf_cert) + slurp(ca_cert); // leaf first, then issuer
588 const std::string key_pem = slurp(leaf_key);
589 ASSERT_FALSE(key_pem.empty());
591 const long long listen_fd = sock::tcp_listen("127.0.0.1", port, 4);
592 ASSERT_GE(listen_fd, 0) << sock::last_error();
593 std::string srv_err;
594 std::thread server([&] {
595 const long long conn = sock::accept(listen_fd);
596 if (conn < 0) { srv_err = "accept failed"; return; }
597 sock::set_timeout(conn, 5000);
598 tls::Conn tc = tls::accept(conn, fullchain, key_pem);
599 if (!tc.is_open()) { srv_err = tls::last_error(); sock::close(conn); return; }
600 tc.recv(4096);
601 const std::string body = "hello through a full chain";
602 tc.send("HTTP/1.0 200 ok\r\nContent-Length: " + std::to_string(body.size()) +
603 "\r\nConnection: close\r\n\r\n" + body);
604 sock::close(conn);
605 });
607 // s_client trusts ONLY the CA — validating proves the intermediate rode in our Certificate.
608 const std::string out = run_s_client(port, ca_cert, "GET / HTTP/1.0\\r\\n\\r\\n");
609 server.join();
610 sock::close(listen_fd);
612 EXPECT_TRUE(srv_err.empty()) << "cheatah server: " << srv_err;
613 EXPECT_NE(out.find("hello through a full chain"), std::string::npos)
614 << "openssl s_client output:\n" << out;
617// The server's certificate/key pre-flight refusals — checked before any socket read, so a bad fd
618// is never touched. A malformed cert PEM, an unparseable key, a cert/key MISMATCH (each key type),
619// and a key on an unsupported curve each fail fast with a named error rather than starting a
620// doomed handshake.
621TEST(TlsSys, ServerRejectsBadCredentials) {
622 const std::string dir = PURR_TEST_TMP;
623 // A valid Ed25519 cert + key to mix and match against bad ones.
624 const std::string ed_cert = dir + "/tls_bad_ed_cert.pem", ed_key = dir + "/tls_bad_ed_key.pem";
625 ASSERT_EQ(std::system(("openssl req -x509 -newkey ed25519 -keyout '" + ed_key + "' -out '" +
626 ed_cert + "' -days 2 -nodes -subj /CN=localhost 2>/dev/null").c_str()), 0);
627 const std::string ed_cert_pem = slurp(ed_cert), ed_key_pem = slurp(ed_key);
628 // A P-256 pair for the cross mismatches, and a P-384 key for the unsupported-curve refusal.
629 const std::string ec_cert = dir + "/tls_bad_ec_cert.pem", ec_key = dir + "/tls_bad_ec_key.pem";
630 ASSERT_EQ(std::system(("openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -keyout '" +
631 ec_key + "' -out '" + ec_cert +
632 "' -days 2 -nodes -subj /CN=localhost 2>/dev/null").c_str()), 0);
633 const std::string ec_cert_pem = slurp(ec_cert), ec_key_pem = slurp(ec_key);
634 const std::string p384_key = dir + "/tls_bad_p384_key.pem";
635 ASSERT_EQ(std::system(("openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -out '" +
636 p384_key + "' 2>/dev/null").c_str()), 0);
638 EXPECT_LT(tls::server_accept(-1, "not a certificate", ed_key_pem), 0); // malformed cert PEM
639 EXPECT_FALSE(tls::last_error().empty());
640 EXPECT_LT(tls::server_accept(-1, ed_cert_pem, "not a key"), 0); // unparseable key
641 EXPECT_LT(tls::server_accept(-1, ec_cert_pem, ed_key_pem), 0); // Ed25519 key, P-256 cert
642 EXPECT_NE(tls::last_error().find("does not match"), std::string::npos) << tls::last_error();
643 EXPECT_LT(tls::server_accept(-1, ed_cert_pem, ec_key_pem), 0); // P-256 key, Ed25519 cert
644 EXPECT_NE(tls::last_error().find("does not match"), std::string::npos) << tls::last_error();
645 EXPECT_LT(tls::server_accept(-1, ec_cert_pem, slurp(p384_key)), 0); // unsupported curve
648// The server's ClientHello refusals against crafted TCP peers (mirror of RefusesBadPeer for the
649// client): a peer that closes immediately, one that sends a non-handshake record, and one that
650// sends a type-22 record with a junk body. Each must fail server_accept with a non-empty error.
651TEST(TlsSys, ServerRejectsBadClientHello) {
652 const std::string dir = PURR_TEST_TMP;
653 const std::string cert = dir + "/tls_srvrej_cert.pem", key = dir + "/tls_srvrej_key.pem";
654 ASSERT_EQ(std::system(("openssl req -x509 -newkey ed25519 -keyout '" + key + "' -out '" + cert +
655 "' -days 2 -nodes -subj /CN=localhost 2>/dev/null").c_str()), 0);
656 const std::string cert_pem = slurp(cert), key_pem = slurp(key);
658 // (record bytes to send, "" = just close). type-21 alert record, and a type-22 junk record.
659 std::string alert; // content_type 21, ver 0303, len 2, body
660 alert.push_back(21); alert.push_back(0x03); alert.push_back(0x03);
661 alert.push_back(0x00); alert.push_back(0x02); alert.push_back(2); alert.push_back(40);
662 std::string junk_hs; // content_type 22, ver 0303, len 4, garbage that isn't a ClientHello
663 junk_hs.push_back(22); junk_hs.push_back(0x03); junk_hs.push_back(0x03);
664 junk_hs.push_back(0x00); junk_hs.push_back(0x04);
665 junk_hs.append(4, static_cast<char>(0xEE));
666 const std::vector<std::string> payloads = {"", alert, junk_hs};
668 for (const std::string& p : payloads) {
669 const long long listen_fd = sock::tcp_listen("127.0.0.1", 0, 4);
670 ASSERT_GE(listen_fd, 0);
671 const long long port = sock::local_port(listen_fd);
672 std::string err;
673 std::thread server([&] {
674 const long long conn = sock::accept(listen_fd);
675 if (conn < 0) return;
676 sock::set_timeout(conn, 2000);
677 const long long s = tls::server_accept(conn, cert_pem, key_pem);
678 if (s < 0) err = tls::last_error();
679 else tls::close(s);
680 sock::close(conn);
681 });
682 const long long fd = sock::tcp_connect("127.0.0.1", port);
683 ASSERT_GE(fd, 0);
684 if (!p.empty()) sock::sendall(fd, p);
685 sock::close(fd);
686 server.join();
687 sock::close(listen_fd);
688 EXPECT_FALSE(err.empty()) << "server should have refused this ClientHello";
689 }
692namespace {
693// Wrap raw handshake bytes in a TLS plaintext record of the given content type.
694std::string tls_record(unsigned type, const std::string& body) {
695 std::string r;
696 r.push_back(static_cast<char>(type));
697 r.push_back(0x03);
698 r.push_back(0x03);
699 r.push_back(static_cast<char>((body.size() >> 8) & 0xFF));
700 r.push_back(static_cast<char>(body.size() & 0xFF));
701 r += body;
702 return r;
704} // namespace
706// RFC 8446 §4.4.3: the server must not sign with an algorithm the client did not offer. A crafted
707// client sends a ClientHello that is valid in every respect (TLS 1.3, X25519 share, a shared
708// suite) but omits signature_algorithms entirely — the one shape openssl will never produce — and
709// the server must refuse before its certificate flight rather than sign anyway.
710TEST(TlsSys, ServerRefusesClientWithoutOurSignatureAlgorithm) {
711 const std::string dir = PURR_TEST_TMP;
712 const std::string cert = dir + "/tls_sigalg_cert.pem", key = dir + "/tls_sigalg_key.pem";
713 ASSERT_EQ(std::system(("openssl req -x509 -newkey ed25519 -keyout '" + key + "' -out '" + cert +
714 "' -days 2 -nodes -subj /CN=localhost 2>/dev/null").c_str()), 0);
715 const std::string cert_pem = slurp(cert), key_pem = slurp(key);
717 // A minimal, well-formed ClientHello with NO extension 13.
718 std::string body;
719 const auto be16 = [&](std::string& o, unsigned v) {
720 o.push_back(static_cast<char>((v >> 8) & 0xFF));
721 o.push_back(static_cast<char>(v & 0xFF));
722 };
723 be16(body, 0x0303);
724 body.append(32, 'R'); // random
725 body.push_back(0); // empty legacy_session_id
726 be16(body, 2);
727 be16(body, 0x1303); // one suite: ChaCha20-Poly1305
728 body.push_back(1);
729 body.push_back(0); // null compression
730 std::string ext;
731 be16(ext, 43); be16(ext, 3); ext.push_back(2); be16(ext, 0x0304); // supported_versions
732 {
733 std::string entry;
734 be16(entry, 0x001d); be16(entry, 32); entry.append(32, 'K');
735 std::string ks; be16(ks, static_cast<unsigned>(entry.size())); ks += entry;
736 be16(ext, 51); be16(ext, static_cast<unsigned>(ks.size())); ext += ks;
737 }
738 be16(body, static_cast<unsigned>(ext.size()));
739 body += ext;
740 std::string hello;
741 hello.push_back(1);
742 hello.push_back(0); be16(hello, static_cast<unsigned>(body.size())); // 24-bit length
743 hello += body;
745 const long long listen_fd = sock::tcp_listen("127.0.0.1", 0, 4);
746 ASSERT_GE(listen_fd, 0);
747 const long long port = sock::local_port(listen_fd);
748 std::string err;
749 std::thread server([&] {
750 const long long conn = sock::accept(listen_fd);
751 if (conn < 0) return;
752 sock::set_timeout(conn, 2000);
753 const long long s = tls::server_accept(conn, cert_pem, key_pem);
754 if (s < 0) err = tls::last_error();
755 else tls::close(s);
756 sock::close(conn);
757 });
758 const long long fd = sock::tcp_connect("127.0.0.1", port);
759 ASSERT_GE(fd, 0);
760 sock::sendall(fd, tls_record(22, hello));
761 sock::close(fd);
762 server.join();
763 sock::close(listen_fd);
764 EXPECT_NE(err.find("signature_algorithms"), std::string::npos)
765 << "expected the §4.4.3 refusal, got: " << err;
768// A crafted client that sends a WELL-FORMED ClientHello (so the server proceeds through ServerHello
769// and its whole encrypted flight) and then misbehaves — closing, or sending an alert / a wrong-type
770// record / a bogus "encrypted" record where the client Finished belongs. Each drives one of the
771// server's post-ServerHello refusal branches (EOF, client alert, unexpected record, failed record
772// authentication). A zero X25519 share additionally drives the invalid-key-share refusal.
773TEST(TlsSys, ServerRejectsMidHandshake) {
774 const std::string dir = PURR_TEST_TMP;
775 const std::string cert = dir + "/tls_srvmid_cert.pem", key = dir + "/tls_srvmid_key.pem";
776 ASSERT_EQ(std::system(("openssl req -x509 -newkey ed25519 -keyout '" + key + "' -out '" + cert +
777 "' -days 2 -nodes -subj /CN=localhost 2>/dev/null").c_str()), 0);
778 const std::string cert_pem = slurp(cert), key_pem = slurp(key);
780 const std::string good_share(32, 'K'); // any valid u-coordinate
781 const std::string zero_share(32, '\0'); // low-order point -> x25519 yields no secret
782 const std::string ch = tls::detail::build_client_hello("localhost", good_share);
784 enum Mode { CLOSE, ALERT, WRONG_TYPE, BOGUS_AEAD, ZERO_KEY };
785 for (int m = CLOSE; m <= ZERO_KEY; ++m) {
786 const long long listen_fd = sock::tcp_listen("127.0.0.1", 0, 4);
787 ASSERT_GE(listen_fd, 0);
788 const long long port = sock::local_port(listen_fd);
789 std::string err;
790 std::thread server([&] {
791 const long long conn = sock::accept(listen_fd);
792 if (conn < 0) return;
793 sock::set_timeout(conn, 2000);
794 const long long s = tls::server_accept(conn, cert_pem, key_pem);
795 if (s < 0) err = tls::last_error();
796 else tls::close(s);
797 sock::close(conn);
798 });
799 const long long fd = sock::tcp_connect("127.0.0.1", port);
800 ASSERT_GE(fd, 0);
801 if (m == ZERO_KEY) {
802 sock::set_timeout(fd, 2000);
803 sock::sendall(fd, tls_record(22, tls::detail::build_client_hello("localhost", zero_share)));
804 } else {
805 sock::sendall(fd, tls_record(22, ch)); // a valid ClientHello: server sends its flight
806 // Drain the WHOLE flight (a short read timeout returns "" once the server has sent it
807 // all and is blocked reading our Finished) — so the server reaches its client-Finished
808 // read and our misbehavior below lands there, not on an early send.
809 sock::set_timeout(fd, 300);
810 while (!sock::recv(fd, 16384).empty()) { /* keep draining */ }
811 sock::set_timeout(fd, 2000);
812 if (m == ALERT) {
813 std::string a;
814 a.push_back(2);
815 a.push_back(40); // fatal handshake_failure
816 sock::sendall(fd, tls_record(21, a));
817 } else if (m == WRONG_TYPE) {
818 sock::sendall(fd, tls_record(22, std::string(4, 'x'))); // plaintext where 23 is due
819 } else if (m == BOGUS_AEAD) {
820 sock::sendall(fd, tls_record(23, std::string(64, 'Z'))); // fails AEAD authentication
821 }
822 // CLOSE: send nothing more.
823 }
824 sock::close(fd);
825 server.join();
826 sock::close(listen_fd);
827 EXPECT_FALSE(err.empty()) << "server should refuse mid-handshake (mode " << m << ")";
828 }