Source
stdlib/tls/tls.hpp
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
#pragma once5
/**6
* @file tls.hpp7
* @brief cheatah `tls` — a from-scratch TLS 1.3 CLIENT (RFC 8446). `import tls` to use it.8
* Built ENTIRELY on the cheatah crypto modules: `x25519` key exchange, the `aead`9
* record ciphers (ChaCha20-Poly1305 + AES-GCM), and hashlib's HKDF key schedule. No OpenSSL.10
*11
* Scope (v1): TLS 1.3 only; cipher suites TLS_CHACHA20_POLY1305_SHA256, TLS_AES_128_GCM_SHA256,12
* and TLS_AES_256_GCM_SHA384 (offered in hardware-preference order); X25519 key share;13
* SNI. The handshake transcript is fully verified (server Finished MAC), and the server's14
* CertificateVerify signature is checked for the common leaf-certificate key types: Ed2551915
* (cheatah's `ed25519`), ECDSA P-256 (`p256`) and P-384 (`p384`), and RSA via16
* rsa_pss_rsae_sha256 (`rsa_verify.hpp`). Servers using any OTHER certificate algorithm are17
* REFUSED with a clear error rather than silently left unauthenticated — no unverified18
* connections. The presented chain then gets full X.509 path validation (hostname, validity19
* window, signatures up to a trusted CA — see x509.hpp) unless explicitly opted out.20
*21
* Sessions ride an already-connected TCP fd (cheatah `socket`). The cheatah-facing API is the22
* owning `tls::Conn` guard, created by `tls.open(fd, server_name)`: it sends close_notify and23
* erases the session automatically when it goes out of scope (so a cheatah program cannot leak a24
* session). The underlying fd is NOT owned by the session — close it with `socket.close()` (or25
* guard it with a `socket::Conn`). The flat handle-based calls live in tls_lowlevel.hpp (C++ only).26
*/27
#include <string>28
#include <string_view>29
#include <vector>31
namespace cheatah::tls {33
// The low-level, handle-based API (client_connect / send / recv / close / shutdown, keyed by an34
// integer session id) is C++-only and lives in tls_lowlevel.hpp. It is intentionally NOT part of35
// this cheatah-facing header: a cheatah program cannot reach it, so it cannot leak a session — it36
// uses the owning `tls::Conn` guard + `tls.open()` below, which release automatically at scope37
// exit. `tls::Conn` is implemented on top of that low-level API.39
/**40
* The most recent tls error message on this thread ("" when none).41
*42
* @return the last error text set on the calling thread, or "" if none.43
* @complexity O(1).44
* @alloc the returned string.45
* @concurrency the error slot is thread-local — read it on the thread whose call failed.46
* @systest TlsSys.RefusesBadPeer47
* @systest TlsSys.HttpsGet48
*/49
std::string last_error();51
// ---- owning RAII session (the `with`-friendly, leak-proof API) ----53
/**54
* @brief An owning TLS 1.3 client session — closes (and erases) itself on destruction.55
*56
* The RAII counterpart to the handle-based calls above. A `Conn` owns one session; when it57
* is destroyed (scope exit out of a `with` body, including via exception) or explicitly58
* close()d, the session is torn down and removed from the module's session table, so59
* `with tls.open(sock.fd(), host) as conn { … }` cannot leak the session. Move-only: the60
* copy operations are deleted and a moved-from `Conn` is left closed. The underlying TCP fd61
* is NOT owned here (tls rides a caller-owned socket) — guard it with a socket::Conn.62
*/63
class Conn {64
public:65
/**66
* Construct a closed session (owns nothing).67
* @complexity O(1).68
* @alloc none.69
* @systest TlsSys.ConnGuardRoundTrip70
*/71
Conn() = default;72
/**73
* Adopt an existing session handle (e.g. from client_connect()); the `Conn` now owns it.74
* @param session a session handle to take ownership of (<= 0 for a closed session).75
* @complexity O(1).76
* @alloc none.77
* @systest TlsSys.ConnGuardRoundTrip78
*/79
explicit Conn(long long session) : session_(session) {}80
Conn(const Conn&) = delete;81
Conn& operator=(const Conn&) = delete;82
/**83
* Move-construct, taking over @p other's session (the moved-from `Conn` becomes closed).84
* @param other the session to move from.85
* @complexity O(1).86
* @alloc none.87
* @systest TlsSys.ConnGuardRoundTrip88
*/89
Conn(Conn&& other) noexcept : session_(other.session_) { other.session_ = 0; }90
/**91
* Move-assign, closing this session first, then taking over @p other's (which becomes closed).92
* @param other the session to move from.93
* @return reference to this session.94
* @complexity O(1).95
* @alloc none.96
* @systest TlsSys.ConnGuardRoundTrip97
*/98
Conn& operator=(Conn&& other) noexcept;99
/**100
* Send close_notify and forget the session if still open.101
* @complexity O(log n) lookup + a close_notify write.102
* @alloc a small close_notify record (when still open).103
* @systest TlsSys.ConnGuardRoundTrip104
*/105
~Conn();107
/**108
* Is a session open?109
* @return true iff this owns an open session.110
* @complexity O(1).111
* @alloc none.112
* @systest TlsSys.ConnGuardRoundTrip113
*/114
bool is_open() const { return session_ > 0; }115
/**116
* The raw session handle (for the low-level calls).117
* @return the owned handle, or 0 when closed.118
* @complexity O(1).119
* @alloc none.120
* @systest TlsSys.ConnGuardRoundTrip121
*/122
long long id() const { return session_; }123
/**124
* Encrypt and send @p data as TLS application data (see the free send()).125
* @param data plaintext to send.126
* @return 0 on success, -1 on error.127
* @complexity O(|data|).128
* @alloc the ciphertext record(s).129
* @concurrency a session is single-owner — never send on one session from two130
* threads at once (the record sequence would race). Separate sessions131
* are independent.132
* @systest TlsSys.ConnGuardRoundTrip133
*/134
long long send(const std::string& data);135
/**136
* Receive and decrypt up to @p bufsize bytes of application data (see the free recv()).137
* @param bufsize maximum plaintext bytes to return.138
* @return the plaintext, or "" on clean close/EOF/error.139
* @complexity O(bytes drained) — it decrypts every record already buffered, up to @p bufsize.140
* @alloc the returned plaintext (plus per-record decryption buffers while draining).141
* @concurrency blocks (bounded by the fd's socket.set_timeout()) only while nothing is142
* ready; a session has ONE reader — shutdown() is the cross-thread wake-up.143
* @systest TlsSys.ConnGuardRoundTrip144
*/145
std::string recv(long long bufsize);146
/**147
* Wake a reader blocked in recv() WITHOUT closing the session (see the free shutdown()).148
* @return 0 on success, -1 on error.149
* @complexity O(log n) lookup + one syscall.150
* @alloc none.151
* @concurrency safe to call from another thread while the owner's recv() blocks —152
* that wake-up is its purpose; then join the reader before close().153
* @systest TlsSys.ConnGuardRoundTrip154
*/155
long long shutdown();156
/**157
* Close the session now (idempotent — the destructor will not close it again).158
* @return 0 on success, -1 if already closed / unknown.159
* @complexity O(log n) lookup + a close_notify write.160
* @alloc a small close_notify record (when still open).161
* @systest TlsSys.ConnGuardRoundTrip162
*/163
long long close();165
private:166
long long session_ = 0;167
};169
/**170
* Run the TLS 1.3 client handshake over connected fd @p fd and return an owning Conn (the171
* RAII, `with`-friendly form of client_connect()). By default the server is AUTHENTICATED:172
* the certificate chain is built to a trusted CA, the hostname is matched against the leaf's173
* subjectAltName, and the validity dates are checked — so the connection resists an active MITM.174
* @param fd a CONNECTED TCP socket (e.g. socket::Conn::fd()).175
* @param server_name the hostname (SNI + certificate SAN match).176
* @param insecure when true, skip chain/hostname/expiry validation (leaf-key possession only) —177
* for a pinned/controlled peer where identity is established out of band. Default false.178
* @param ca_file a PEM CA bundle to trust instead of the system store (empty = system default).179
* @return an owning Conn; on handshake or validation failure its is_open() is false (see last_error()).180
* @warning @p insecure = true drops the MITM protection: ANY peer that holds its own181
* certificate's key is accepted, whoever it is. Use it only when the peer's182
* identity is pinned/established out of band.183
* @complexity one network round trip + O(handshake bytes) crypto (+ a one-time parse of the184
* system CA store; a custom @p ca_file is parsed on every call).185
* @alloc the session state (plus transient handshake buffers).186
* @concurrency blocks for the handshake round trip — bound it with socket.set_timeout() on @p fd.187
* @systest TlsSys.ConnGuardRoundTrip188
* @systest TlsSys.HttpsGet189
*/190
Conn open(long long fd, const std::string& server_name, bool insecure = false,191
const std::string& ca_file = "");193
/**194
* Run the TLS 1.3 SERVER handshake over an accepted TCP fd and return an owning Conn — the195
* `with`-friendly server counterpart to open(). We present @p cert_pem and prove possession of196
* its key by signing the handshake, so a client that validates the certificate gets an197
* authenticated, encrypted channel with **no OpenSSL** anywhere.198
*199
* The server certificate is **Ed25519 or ECDSA P-256** — the second is what public CAs200
* (Let's Encrypt) actually issue, so a browser-facing HTTPS server works with an ordinary201
* `fullchain.pem`/`privkey.pem` pair. @p cert_pem may carry the WHOLE chain (leaf first, then202
* intermediates); every block is sent, giving clients a path to their trust anchor. The private203
* key's derived public half must match the leaf's SPKI, or the handshake refuses at startup with204
* a precise error rather than failing opaquely at the first client. Both suites205
* (ChaCha20-Poly1305 and AES-128-GCM) and X25519 key exchange are supported; the client picks206
* the suite, and per RFC 8446 §4.4.3 we refuse a client whose signature_algorithms do not207
* include our certificate's algorithm.208
* @param fd a CONNECTED TCP socket from socket::accept()/Listener (e.g. one client of a server loop).209
* @param cert_pem the server certificate PEM — a single leaf or a full chain, leaf first.210
* @param key_pem the leaf's private key, PEM: PKCS#8 Ed25519, or PKCS#8/SEC1 P-256 EC.211
* @return an owning Conn; on handshake failure its is_open() is false (see last_error()).212
* @complexity one network round trip + O(handshake bytes) crypto.213
* @alloc the session state (plus transient handshake buffers).214
* @concurrency blocks awaiting the client's handshake flights — bound it with215
* socket.set_timeout() on @p fd so a silent client cannot hang the server.216
* @systest TlsSys.ServerHandshakeAgainstOpenssl217
* @systest TlsSys.ServerHandshakeEcdsaAgainstOpenssl218
*/219
Conn accept(long long fd, const std::string& cert_pem, const std::string& key_pem);221
// Internal key-schedule primitives, exposed for the RFC 8448 vector tests only.222
namespace detail {223
/**224
* HKDF-Expand-Label (RFC 8446 §7.1). @complexity O(length) @alloc the returned string225
* @test CheatahTls.KeySchedule226
*/227
std::string expand_label(std::string_view secret, std::string_view label,228
std::string_view context, unsigned length);229
/**230
* Derive-Secret (RFC 8446 §7.1). @complexity O(|transcript|) (hashes the transcript, then231
* HKDF-expands). @alloc the returned string232
* @test CheatahTls.KeySchedule233
*/234
std::string derive_secret(std::string_view secret, std::string_view label,235
std::string_view transcript);237
/**238
* Append our three TLS 1.3 cipher suites to a ClientHello body, in preference order.239
*240
* Takes the hardware decision as a parameter rather than reading the CPU, so BOTH orders are241
* reachable from a test on any machine: read inline, whichever branch does not match the build242
* host's CPU is dead code no test can execute, and the suite ordering is a wire-format decision that243
* deserves pinning everywhere rather than only on ARM.244
*245
* @param body the ClientHello body to append the 6 bytes to.246
* @param hardware_aes true when AES-NI + PCLMULQDQ are present, so AES-GCM leads; false to lead with247
* ChaCha20, which is the faster path when AES has no hardware backing.248
* @complexity O(1).249
* @alloc appends 6 bytes to @p body.250
* @test CheatahTls.CipherPreferenceFollowsHardware251
*/252
void append_cipher_preference(std::string& body, bool hardware_aes);254
// Server-handshake parsers, exposed as test seams so crafted-input unit tests can drive every255
// refusal branch deterministically (no network peer needed). Not part of the cheatah surface.256
/**257
* Parse a ClientHello (choose a supported suite, extract the client X25519 share + session id +258
* its signature_algorithms). @param msg the handshake message bytes. @param client_pub_raw filled259
* with the 32-byte share. @param chosen_suite filled with the negotiated cipher suite.260
* @param session_id filled with the legacy_session_id to echo. @param sig_algs filled with the261
* raw u16-pair bytes of extension 13 ("" when absent — the parser stays lenient; server policy262
* enforces the match). @return true iff usable (TLS 1.3, X25519, a shared suite).263
* @test CheatahTls.ParseClientHelloRejectsMalformed264
* @test CheatahTls.ParseClientHelloSurfacesSignatureAlgorithms265
*/266
bool parse_client_hello(std::string_view msg, std::string& client_pub_raw, unsigned& chosen_suite,267
std::string& session_id, std::string& sig_algs);268
/**269
* A PEM block's DER bytes (strict base64). @param pem the PEM text. @param label e.g.270
* "CERTIFICATE". @return the decoded DER, or "" when the block is absent/malformed.271
* @test CheatahTls.PemBlockExtractsAndRejects272
*/273
std::string pem_block(const std::string& pem, const std::string& label);274
/**275
* EVERY PEM block under @p label, in order — the server Certificate message sends a full chain.276
* @param pem the PEM text (e.g. a fullchain.pem). @param label e.g. "CERTIFICATE".277
* @return the decoded DER blocks, or {} when any block is malformed (no partial chains).278
* @test CheatahTls.PemBlocksExtractsChains279
*/280
std::vector<std::string> pem_blocks(const std::string& pem, const std::string& label);281
/**282
* The 32-byte Ed25519 seed from a PKCS#8 private-key DER. @param der the key DER.283
* @return the 32-byte seed, or "" when @p der is not a PKCS#8 Ed25519 key.284
* @test CheatahTls.PemBlockExtractsAndRejects285
*/286
std::string ed25519_seed_from_pkcs8(std::string_view der);287
/**288
* The 32-byte P-256 private scalar from a server key PEM — PKCS#8 ("PRIVATE KEY") or SEC1289
* ("EC PRIVATE KEY"), both requiring the prime256v1 OID so other curves are refused rather than290
* misread. @param key_pem the key PEM text. @return the 32-byte scalar, or "" when absent.291
* @test CheatahTls.EcP256ScalarFromPem292
*/293
std::string ec_p256_scalar_from_pem(const std::string& key_pem);294
/**295
* Build a ClientHello handshake message (offering both suites + an X25519 key share) — the test296
* seam a crafted "malformed client" peer uses to drive the server handshake past ServerHello.297
* @param server_name the SNI host. @param pub_raw a 32-byte X25519 client share.298
* @return the ClientHello message bytes.299
* @systest TlsSys.ServerRejectsMidHandshake300
*/301
std::string build_client_hello(const std::string& server_name, std::string_view pub_raw);302
} // namespace detail304
} // namespace cheatah::tls