santoku-tls
View on LuaRocksTLS 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
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.
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.
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.
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.
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.
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.
#include <santoku/tls.h>
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);