cheatah
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 once
5/**
6 * @file tls.hpp
7 * @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's
14 * CertificateVerify signature is checked for the common leaf-certificate key types: Ed25519
15 * (cheatah's `ed25519`), ECDSA P-256 (`p256`) and P-384 (`p384`), and RSA via
16 * rsa_pss_rsae_sha256 (`rsa_verify.hpp`). Servers using any OTHER certificate algorithm are
17 * REFUSED with a clear error rather than silently left unauthenticated — no unverified
18 * connections. The presented chain then gets full X.509 path validation (hostname, validity
19 * 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 the
22 * owning `tls::Conn` guard, created by `tls.open(fd, server_name)`: it sends close_notify and
23 * erases the session automatically when it goes out of scope (so a cheatah program cannot leak a
24 * session). The underlying fd is NOT owned by the session — close it with `socket.close()` (or
25 * 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>
31namespace cheatah::tls {
33// The low-level, handle-based API (client_connect / send / recv / close / shutdown, keyed by an
34// integer session id) is C++-only and lives in tls_lowlevel.hpp. It is intentionally NOT part of
35// this cheatah-facing header: a cheatah program cannot reach it, so it cannot leak a session — it
36// uses the owning `tls::Conn` guard + `tls.open()` below, which release automatically at scope
37// 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.RefusesBadPeer
47 * @systest TlsSys.HttpsGet
48 */
49std::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 it
57 * is destroyed (scope exit out of a `with` body, including via exception) or explicitly
58 * close()d, the session is torn down and removed from the module's session table, so
59 * `with tls.open(sock.fd(), host) as conn { … }` cannot leak the session. Move-only: the
60 * copy operations are deleted and a moved-from `Conn` is left closed. The underlying TCP fd
61 * is NOT owned here (tls rides a caller-owned socket) — guard it with a socket::Conn.
62 */
63class Conn {
64public:
65 /**
66 * Construct a closed session (owns nothing).
67 * @complexity O(1).
68 * @alloc none.
69 * @systest TlsSys.ConnGuardRoundTrip
70 */
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.ConnGuardRoundTrip
78 */
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.ConnGuardRoundTrip
88 */
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.ConnGuardRoundTrip
97 */
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.ConnGuardRoundTrip
104 */
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.ConnGuardRoundTrip
113 */
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.ConnGuardRoundTrip
121 */
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 two
130 * threads at once (the record sequence would race). Separate sessions
131 * are independent.
132 * @systest TlsSys.ConnGuardRoundTrip
133 */
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 is
142 * ready; a session has ONE reader — shutdown() is the cross-thread wake-up.
143 * @systest TlsSys.ConnGuardRoundTrip
144 */
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.ConnGuardRoundTrip
154 */
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.ConnGuardRoundTrip
162 */
163 long long close();
165private:
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 (the
171 * 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's
173 * 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 own
181 * certificate's key is accepted, whoever it is. Use it only when the peer's
182 * identity is pinned/established out of band.
183 * @complexity one network round trip + O(handshake bytes) crypto (+ a one-time parse of the
184 * 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.ConnGuardRoundTrip
188 * @systest TlsSys.HttpsGet
189 */
190Conn 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 — the
195 * `with`-friendly server counterpart to open(). We present @p cert_pem and prove possession of
196 * its key by signing the handshake, so a client that validates the certificate gets an
197 * authenticated, encrypted channel with **no OpenSSL** anywhere.
198 *
199 * The server certificate is **Ed25519 or ECDSA P-256** — the second is what public CAs
200 * (Let's Encrypt) actually issue, so a browser-facing HTTPS server works with an ordinary
201 * `fullchain.pem`/`privkey.pem` pair. @p cert_pem may carry the WHOLE chain (leaf first, then
202 * intermediates); every block is sent, giving clients a path to their trust anchor. The private
203 * key's derived public half must match the leaf's SPKI, or the handshake refuses at startup with
204 * a precise error rather than failing opaquely at the first client. Both suites
205 * (ChaCha20-Poly1305 and AES-128-GCM) and X25519 key exchange are supported; the client picks
206 * the suite, and per RFC 8446 §4.4.3 we refuse a client whose signature_algorithms do not
207 * 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 with
215 * socket.set_timeout() on @p fd so a silent client cannot hang the server.
216 * @systest TlsSys.ServerHandshakeAgainstOpenssl
217 * @systest TlsSys.ServerHandshakeEcdsaAgainstOpenssl
218 */
219Conn 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.
222namespace detail {
223/**
224 * HKDF-Expand-Label (RFC 8446 §7.1). @complexity O(length) @alloc the returned string
225 * @test CheatahTls.KeySchedule
226 */
227std::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, then
231 * HKDF-expands). @alloc the returned string
232 * @test CheatahTls.KeySchedule
233 */
234std::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 are
241 * reachable from a test on any machine: read inline, whichever branch does not match the build
242 * host's CPU is dead code no test can execute, and the suite ordering is a wire-format decision that
243 * 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 with
247 * 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.CipherPreferenceFollowsHardware
251 */
252void append_cipher_preference(std::string& body, bool hardware_aes);
254// Server-handshake parsers, exposed as test seams so crafted-input unit tests can drive every
255// 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 filled
259 * 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 the
261 * raw u16-pair bytes of extension 13 ("" when absent — the parser stays lenient; server policy
262 * enforces the match). @return true iff usable (TLS 1.3, X25519, a shared suite).
263 * @test CheatahTls.ParseClientHelloRejectsMalformed
264 * @test CheatahTls.ParseClientHelloSurfacesSignatureAlgorithms
265 */
266bool 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.PemBlockExtractsAndRejects
272 */
273std::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.PemBlocksExtractsChains
279 */
280std::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.PemBlockExtractsAndRejects
285 */
286std::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 SEC1
289 * ("EC PRIVATE KEY"), both requiring the prime256v1 OID so other curves are refused rather than
290 * misread. @param key_pem the key PEM text. @return the 32-byte scalar, or "" when absent.
291 * @test CheatahTls.EcP256ScalarFromPem
292 */
293std::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 test
296 * 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.ServerRejectsMidHandshake
300 */
301std::string build_client_hello(const std::string& server_name, std::string_view pub_raw);
302} // namespace detail
304} // namespace cheatah::tls