# santoku-tls > TLS 1.3 on a socket you already own, with self-signed P-256 keys pinned by SPKI hash, as a Lua module and a header-only C API over OpenSSL 3 This file is one tab of the santoku reference, at https://santoku.dev/santoku-tls/llms.txt. https://santoku.dev/santoku-tls is the same tab in a browser, https://santoku.dev/llms.txt indexes every tab, and https://santoku.dev/llms-small.txt summarizes them all on one page. santoku-tls puts TLS 1.3 on a stream socket you already own, with self-signed P-256 keys on both ends and the peer pinned by the SHA-256 of its public key. There is no certificate authority. It binds OpenSSL 3 directly and is native only. The C header santoku/tls.h is header-only, so another rock's C code calls it on its own fd; the Lua module santoku.tls wraps the same functions. Every connection is TLS 1.3 with a required certificate from both sides, and the pin is checked inside the handshake, so a wrong key aborts it before any data moves. ### Keys and contexts generate({ cn }) makes a P-256 key and a self-signed certificate and returns the key PEM, the certificate PEM and the SPKI SHA-256 as 64 hex digits; cn is 1 to 64 bytes. Store the key yourself with mode 0600. context({ key, cert, pin }) takes PEM text or file paths and raises on a bad key, a key that doesn't match the certificate, or a pin that isn't 64 hex digits. With a pin, only that peer key passes. Without one the context is in pairing mode: any P-256 peer passes, and session:peer_spki() names it so you can store it and pin it next time. ```lua local tls = require("santoku.tls") local key, cert, spki = tls.generate({ cn = "my-cli" }) local pairing = tls.context({ key = key, cert = cert }) local pinned = tls.context({ key = key, cert = cert, pin = stored_peer_spki }) ``` ### Wrap a socket wrap(fd, ctx, role, ms) runs the handshake on fd as "server" or "client" within ms milliseconds and returns a session, or nil, a code and a message. It reads nothing before the handshake starts, so you can peek the first byte yourself. The fd must be a stream socket, and it stays yours: close sends close_notify and frees the TLS state but never closes the fd. A server always requires a client certificate. read(n, ms) returns up to n bytes, at most 16384 per call; write(data, ms) sends all of it. A missing or negative ms means no deadline. ```lua local s, code, msg = tls.wrap(fd, pinned, "server", 5000) if not s then error(code .. ": " .. msg) end assert(s:write("hello", 1000)) local data, why = s:read(4096, 1000) s:close() ``` ### Outcomes Network outcomes come back as nil, code, message, never as an error. Calling a method on a closed session, or passing bad arguments, raises. A timed-out read leaves the session usable; a timed-out write or any other failure ends it. ```text wouldblock read with ms = 0 and no plaintext buffered timeout the deadline passed closed the peer sent close_notify eof the peer closed the socket without close_notify pin the peer key didn't match the pin nocert a client sent no usable certificate, including a non-P-256 key nottls the first bytes from the peer aren't a TLS record handshake any other handshake failure, such as a TLS 1.2 peer error a socket error or a TLS failure after the handshake ``` ### Poll loops The raw fd stays pollable, but TLS decrypts whole records, so plaintext can sit in the session where poll can't see it. Before each poll, drain pending(): it returns the number of decrypted bytes ready to read. Then read with ms = 0 after poll reports the fd readable; "wouldblock" means the record isn't complete yet. ```lua while s:pending() > 0 do handle(s:read(s:pending(), 0)) end poll(fds, timeout) local data, why = s:read(4096, 0) ``` ### Keying material export(label, len, context) returns len bytes (1 to 16384) derived from the session as RFC 5705 and RFC 8446 define it. Both ends get the same bytes for the same label and context, and different bytes for any other, so a short code shown on both screens confirms there's no one in the middle. A missing context differs from an empty one. ```lua local code = s:export("EXPORTER-my-app-pair", 32, "v1") ``` ### From C santoku/tls.h is header-only static inline, so a consumer adds rock.include("santoku-tls") to its cflags and links -lssl -lcrypto through its own OpenSSL dependency. Functions return TK_TLS_OK or a negative code; tk_tls_strerror names it and the err buffer or tk_tls_error(s) holds the message. Pins are 32 raw bytes in C. tk_tls_read returns bytes read or a code. Building the rock needs the OpenSSL 3 headers (libssl-dev on Debian), and its test suite also needs pkg-config and the openssl command. ```c #include char err[TK_TLS_ERRLEN]; tk_tls_ctx *ctx = tk_tls_ctx_new(key_path, cert_path, pin32, err, sizeof(err)); tk_tls *s; int rc = tk_tls_wrap(fd, ctx, TK_TLS_SERVER, 5000, &s, err, sizeof(err)); if (rc != TK_TLS_OK) fail(tk_tls_strerror(rc), err); int n = tk_tls_read(s, buf, sizeof(buf), 1000); tk_tls_close(s, 1000); tk_tls_free(s); tk_tls_ctx_free(ctx); ```