santoku
santoku

santoku

A Lua framework for building applications end to end

Lua 5.1 from the build system to the server to the browser. Every example on this site runs in your browser. Edit one and run it.


santoku targets Lua 5.1, because 5.1 is the version its platforms actually share. LuaJIT implements 5.1 and OpenResty embeds LuaJIT, so server code, client code and the build harness all run the same language. Compiled to WebAssembly, the 5.1 core is smaller to embed, and its single number type sidesteps the integer and float split introduced in 5.3, which keeps numeric values crossing the JavaScript boundary simple. Support for newer versions is meant to come from compatibility shims layered over the 5.1 core rather than version branches in the tree; contributions and design ideas for that layer are welcome.

santoku-tls

View on LuaRocks

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

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 })
Illustrative: runs outside the browser.

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()
Illustrative: runs outside the browser.

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
Illustrative: runs outside the browser.

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)
Illustrative: runs outside the browser.

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")
Illustrative: runs outside the browser.

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);
Illustrative: runs outside the browser.