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 a6
// 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 macOS17
#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)25
namespace sock = cheatah::socket;26
namespace tls = cheatah::tls;28
namespace {30
// Resolve the `openssl` CLI used purely as test infrastructure (the peer). On Linux the31
// system openssl is real OpenSSL; on macOS /usr/bin/openssl is LibreSSL, whose s_server/req32
// flag surface differs (e.g. -newkey ed25519, -ciphersuites), so prefer a Homebrew OpenSSL.33
// Overridable via $CHEATAH_OPENSSL for unusual layouts.34
const 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
#endif45
return "openssl"; // real OpenSSL on PATH (Linux); on macOS a LibreSSL fallback46
}();47
return bin;48
}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 the53
// system tests exercise each leaf-cert algorithm (Ed25519 / RSA-PSS / ECDSA P-256) and each record54
// 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)56
class OpensslServer {57
public:58
// request_client_cert adds `-verify 1`, which makes s_server send a CertificateRequest59
// and ACCEPT a client that declines (unlike -Verify, which demands one). That is exactly60
// 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's66
// 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": passed71
// 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 bind85
}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_; }94
private:95
long long port_;96
std::string cert_, key_;97
bool ok_ = false;98
};100
} // namespace102
// 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).104
TEST(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);123
}125
// The owning-guard round trip: socket::Conn + tls::Conn (the `with`-friendly RAII API) run the126
// same X25519 handshake + encrypted GET, but the fd and TLS session are released deterministically127
// by the guards' destructors. Exercises tls::open/Conn::send/recv/shutdown/close/is_open/id and128
// both move operations end-to-end against a real peer.129
TEST(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 ownership140
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 guard152
EXPECT_FALSE(active.is_open());153
EXPECT_EQ(sink.shutdown(), 0);154
EXPECT_EQ(sink.close(), 0);155
EXPECT_EQ(sink.close(), -1); // idempotent156
// `tcp` (socket::Conn) closes the fd via its destructor here — no leak.157
}159
namespace {160
// Connect to a local TLS 1.3 server, GET /, and return true iff it answered 200 over the encrypted161
// channel — i.e. the FULL cheatah handshake (key exchange + leaf-cert verify + record cipher) and an162
// encrypted round trip all succeeded. The per-cert/per-cipher system tests below assert on this.163
bool 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;184
}185
} // namespace187
// 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.189
TEST(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();193
}195
// A server that ASKS for a client certificate. RFC 8446 §4.4.2: a client with nothing to present196
// must still answer the CertificateRequest with a Certificate message carrying an empty197
// certificate_list and echoing the request's context — a bare Finished is an unexpected_message and198
// 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 transcript203
// (get either wrong and the server's Finished check fails instead).204
TEST(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();208
}210
// AES-128-GCM record cipher (Ed25519 cert isolates the cipher): exercises the AES-128-GCM211
// seal_record/open_record path on a real TLS 1.3 channel.212
TEST(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();216
}218
// RSA leaf cert AND AES-128-GCM together — the exact combination required to reach exchanges whose219
// stream endpoints serve an RSA chain over an AES-GCM-only cipher policy (both additions at once).220
TEST(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();224
}226
// ECDSA P-256 leaf certificate: exercises the ecdsa_secp256r1_sha256 CertificateVerify path.227
TEST(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();232
}234
// ECDSA P-384 leaf certificate: exercises the ecdsa_secp384r1_sha384 CertificateVerify path235
// and, via the self-signed cert's own signature, the x509 P-384 chain-verification arm.236
TEST(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();241
}243
// TLS_AES_256_GCM_SHA384: exercises the SHA-384 key schedule + AES-256-GCM record cipher end-to-end244
// against openssl as the reference peer, with an Ed25519 leaf (isolates the suite from the cert path).245
TEST(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();249
}251
// The hardest combination: an ECDSA P-384 leaf UNDER the TLS_AES_256_GCM_SHA384 suite — the P-384252
// CertificateVerify (0x0503) + the P-384 x509 chain arm + the SHA-384 key schedule + AES-256-GCM253
// records all exercised together in one handshake.254
TEST(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();259
}261
// No common cipher suite (server offers ONLY AES-128-CCM, which cheatah does not implement): the262
// handshake MUST fail rather than silently proceed, and the error must NAME the alert — exercising the263
// alert-code diagnostic so a "no common cipher" refusal reports a named reason, not a generic error.264
TEST(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 fail272
EXPECT_NE(tls::last_error().find("handshake_failure"), std::string::npos) << tls::last_error();273
sock::close(fd);274
}276
// A peer that speaks plaintext garbage must fail the handshake with a clear error.277
TEST(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);295
}297
// A server that, right after the client's ClientHello, sends a plaintext TLS alert record instead of298
// a ServerHello must fail the handshake with a NAMED alert. This is adversarial crafted-byte input299
// (a 7-byte record: content_type 21 || version 0303 || length 0002 || level || description) — NOT a300
// mirrored handshake — and it drives alert_text()'s human-readable name table over every RFC 8446301
// alert description the client recognizes, so a refusal reports a specific cause, not a bare code.302
TEST(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" name316
};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 alert325
// Build the 7-byte alert record explicitly — a "\x00" in a string literal would326
// 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 = alert329
alert.push_back(static_cast<char>(0x03)); // legacy record version 0x0303330
alert.push_back(static_cast<char>(0x03));331
alert.push_back(static_cast<char>(0x00)); // length = 2332
alert.push_back(static_cast<char>(0x02));333
alert.push_back(static_cast<char>(2)); // level = fatal334
alert.push_back(static_cast<char>(code & 0xFF)); // description335
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
}349
}351
// A short (1-byte) alert record must not misparse: alert_text reports "(empty alert)" rather than352
// reading a description byte that is not there — the p.size() < 2 guard.353
TEST(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 = alert363
alert.push_back(static_cast<char>(0x03)); // legacy record version 0x0303364
alert.push_back(static_cast<char>(0x03));365
alert.push_back(static_cast<char>(0x00)); // length = 1366
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);380
}382
// Verify-by-default REFUSES an untrusted (self-signed, unknown-CA) server: this is the MITM383
// defense — key possession alone no longer completes the handshake. Exercises the default384
// (system) trust store + the validation-failure path.385
TEST(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 only392
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);396
}398
// Verify-by-default REFUSES a certificate whose SAN does not match the requested hostname (even399
// though the cert is otherwise trusted via ca_file).400
TEST(TlsSys, VerifyRejectsWrongHostname) {401
OpensslServer server(47935); // SAN = localhost402
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);410
}412
// insecure=true (a pinned/controlled peer) skips validation: the same untrusted, wrong-hostname413
// server that verification refuses is accepted.414
TEST(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 checks421
ASSERT_GE(s, 0) << tls::last_error();422
tls::close(s);423
sock::close(fd);424
}426
// THE FINALE: a pure-cheatah https GET — requests.purr (HTTP in .purr) over the tls module427
// (from-scratch TLS 1.3) over the cheatah crypto modules, against a real TLS server, WITH full428
// certificate validation (the cert's own PEM as the trusted CA; host "localhost" matches its SAN).429
TEST(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");440
}442
namespace {443
std::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;452
}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-scratch456
// 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.458
std::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;470
}471
} // namespace473
// 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 `openssl475
// s_client -verify_return_error` completes the TLS 1.3 handshake and reads our reply — so OpenSSL476
// validates our ServerHello + certificate flight + Ed25519 CertificateVerify + Finished end to end.477
// HTTPS with zero non-cheatah software on the server side.478
TEST(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 owning495
// `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 exit502
if (!tc.is_open()) { srv_err = tls::last_error(); sock::close(conn); return; }503
tc.recv(4096); // drain the client's request line504
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 here508
});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;517
}519
// The ECDSA mirror of ServerHandshakeAgainstOpenssl: the SAME cheatah server code presenting a520
// P-256 leaf — the certificate type public CAs actually issue — with OpenSSL validating our521
// ecdsa_secp256r1_sha256 CertificateVerify end to end. This is the browser-facing HTTPS shape.522
TEST(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;559
}561
// A CA-signed leaf served as a fullchain.pem (leaf + intermediate in one file — the exact artifact562
// 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.564
TEST(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 issuer588
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;615
}617
// The server's certificate/key pre-flight refusals — checked before any socket read, so a bad fd618
// 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 a620
// doomed handshake.621
TEST(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 PEM639
EXPECT_FALSE(tls::last_error().empty());640
EXPECT_LT(tls::server_accept(-1, ed_cert_pem, "not a key"), 0); // unparseable key641
EXPECT_LT(tls::server_accept(-1, ec_cert_pem, ed_key_pem), 0); // Ed25519 key, P-256 cert642
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 cert644
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 curve646
}648
// The server's ClientHello refusals against crafted TCP peers (mirror of RefusesBadPeer for the649
// client): a peer that closes immediately, one that sends a non-handshake record, and one that650
// sends a type-22 record with a junk body. Each must fail server_accept with a non-empty error.651
TEST(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, body660
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 ClientHello663
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
}690
}692
namespace {693
// Wrap raw handshake bytes in a TLS plaintext record of the given content type.694
std::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;703
}704
} // namespace706
// RFC 8446 §4.4.3: the server must not sign with an algorithm the client did not offer. A crafted707
// client sends a ClientHello that is valid in every respect (TLS 1.3, X25519 share, a shared708
// suite) but omits signature_algorithms entirely — the one shape openssl will never produce — and709
// the server must refuse before its certificate flight rather than sign anyway.710
TEST(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'); // random725
body.push_back(0); // empty legacy_session_id726
be16(body, 2);727
be16(body, 0x1303); // one suite: ChaCha20-Poly1305728
body.push_back(1);729
body.push_back(0); // null compression730
std::string ext;731
be16(ext, 43); be16(ext, 3); ext.push_back(2); be16(ext, 0x0304); // supported_versions732
{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 length743
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;766
}768
// A crafted client that sends a WELL-FORMED ClientHello (so the server proceeds through ServerHello769
// and its whole encrypted flight) and then misbehaves — closing, or sending an alert / a wrong-type770
// record / a bogus "encrypted" record where the client Finished belongs. Each drives one of the771
// server's post-ServerHello refusal branches (EOF, client alert, unexpected record, failed record772
// authentication). A zero X25519 share additionally drives the invalid-key-share refusal.773
TEST(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-coordinate781
const std::string zero_share(32, '\0'); // low-order point -> x25519 yields no secret782
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 flight806
// Drain the WHOLE flight (a short read timeout returns "" once the server has sent it807
// all and is blocked reading our Finished) — so the server reaches its client-Finished808
// 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_failure816
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 due819
} else if (m == BOGUS_AEAD) {820
sock::sendall(fd, tls_record(23, std::string(64, 'Z'))); // fails AEAD authentication821
}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
}829
}