From 2bdbe71eaf101f236d68d80c86f26f6fe81d38d0 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Sat, 12 Sep 2026 01:20:52 -0500 Subject: [PATCH] Some changes needed for Singe features. --- API.md | 52 ++- Makefile | 17 +- examples/httpd.lua | 32 +- examples/smtp.lua | 350 ++++++++++++++++++ libs/calogCrypto.c | 73 ++++ libs/calogCrypto.h | 6 +- libs/calogHttp.c | 237 +----------- libs/calogNet.c | 164 +++++++- libs/calogTrust.c | 245 ++++++++++++ libs/calogTrust.h | 26 ++ tests/testCrypto.c | 26 +- .../src/test/locale/koi8-to-win1251/README | 6 - 12 files changed, 967 insertions(+), 267 deletions(-) create mode 100644 examples/smtp.lua create mode 100644 libs/calogTrust.c create mode 100644 libs/calogTrust.h delete mode 100644 vendor/postgres/src/test/locale/koi8-to-win1251/README diff --git a/API.md b/API.md index 06baa7c1..7b3f230a 100644 --- a/API.md +++ b/API.md @@ -196,6 +196,12 @@ MD5/SHA1); both are already in the build. A single unnamed stream can be written Binary-safe cryptographic primitives over OpenSSL. +Hashing a password needs `cryptoPbkdf2`, not `cryptoHashSha256` or `cryptoHmacSha256`: a digest and a +MAC are fast by design, which is exactly what makes them useless for storing a password. Pick the +iteration count by timing the machine that will run it -- the floor of 1000 is where the result stops +being a password hash at all, not a recommendation -- and store the count alongside the hash so it can +be raised later without a migration. Compare the result with `cryptoEquals`. + | Function | Description | |---|---| | `cryptoHashSha256(data: string) -> string` | SHA-256 as a 64-char lowercase hex digest. | @@ -207,6 +213,8 @@ Binary-safe cryptographic primitives over OpenSSL. | `cryptoHexEncode(data: string) -> string` | Lowercase hex encode. | | `cryptoHexDecode(hexText: string) -> string` | Hex decode (case-insensitive). | | `cryptoUuid() -> string` | A random RFC 4122 version-4 UUID string. | +| `cryptoPbkdf2(password: string, salt: string, iterations: int, length: int) -> string` | PBKDF2-HMAC-SHA256, as a lowercase hex digest of `length` bytes. `iterations` is 1000 to 10000000 and `length` is 16 to 1024; outside either, the call fails. Like every native in this library it runs inline, on the calling script's thread, so being deliberately slow costs that one context rather than serialising the whole process. | +| `cryptoEquals(a: string, b: string) -> bool` | Whether two strings are equal, compared in constant time (`CRYPTO_memcmp`). For checking a password hash, a token or a MAC, where `a == b` leaks how much of the value matched through how long the comparison took. Strings of different lengths are unequal, and that much is compared in the clear -- a length is not a secret. | ## csv @@ -287,10 +295,15 @@ local httpd = load(fsRead("examples/httpd.lua"))() -- fsRead + load: dofile/re local s = httpd.new() s:route("GET", "/hi", function(req) return "hello " .. req.path end) -- string body => 200 s:route("GET", "/made", function(req) return { status = 201, body = "x" } end) -- map => custom status +s:routePrefix("GET", "/files/", function(req) return "you asked for " .. req.rest end) -- everything under a path s:websocket("/ws", function(msg) return "echo: " .. msg.message end) -- reply string, or nil -s:serve(8080) -- opts: { tls, cert, key, keep = fn, acceptTimeout } +s:serve(8080) -- opts: { host, tls, cert, key, keep = fn, acceptTimeout } ``` +`routePrefix` matches everything under a path, so `/v1/download/` can serve `/v1/download/dragon/3` +without registering every id; the handler also gets `req.prefix` and `req.rest`. An exact route wins +over a prefix, and between prefixes the longest match wins. + A route handler receives `{ method, path, query, headers (lowercased), body }` and returns a map `{ status, headers, body }`, a bare string (=> 200), or nil (=> 204). The server does HTTP/1.1 keep-alive; re-registering a path hot-swaps its handler. A WebSocket handler receives @@ -298,6 +311,40 @@ keep-alive; re-registering a path hot-swaps its handler. A WebSocket handler rec model; no server-push). Concurrency follows the actor model: one context per accept loop (serial), or share the listener handle across several contexts for parallelism. +## smtp (a script, not a native library) + +Sending mail lives in **`examples/smtp.lua`**, on the same principle as `httpd`: the systems +primitives are in C (`tcp*`, `tcpStartTls`, `crypto*`) and the protocol is a script. + +```lua +local smtp = load(fsRead("examples/smtp.lua"))() +local ok, err = smtp.send({ + host = "smtp.example.com", port = 587, tls = "starttls", -- or "implicit" (465), or "none" + user = "apikey", password = "...", + from = "Singe ", + to = { "player@example.com" }, -- a string or a list + subject = "Verify your account", + text = "Open this link ...", +}) +``` + +Returns `true`, or `nil` and a message naming the step that failed. It does not retry: whether a +message was sent is a question only the caller's own durable state can answer, so a sender that must +not lose one keeps a queue and calls again. + +What it refuses, on purpose: + +* **Downgrading.** `tls = "starttls"` on a relay that does not advertise STARTTLS is an error, not a + plaintext send. `tls = "none"` additionally needs `allowPlain = true`, and credentials are never + sent over a plain connection at all. +* **Header injection.** Every address and header value is rejected if it contains CR or LF, so a + display name or an address out of a database cannot add a `Bcc:` or end the message early. + +The body is always base64 with `charset=utf-8`, which makes it binary-safe and 8-bit clean and means +it can never produce an over-long line or one beginning with `.` -- so neither folding nor +dot-stuffing has to be got right separately. A subject that is not plain ASCII becomes an RFC 2047 +encoded word. `Date` and a UUID `Message-ID` are generated. + ## json | Function | Description | @@ -329,7 +376,8 @@ TCP and UDP: | Function | Description | |---|---| | `tcpConnect(host: string, port: int) -> handle` | Connect to a TCP server. | -| `tcpListen(port: int [, opts: map]) -> handle` | Listen on a TCP port. `opts`: `{ tls (bool), cert (PEM path), key (PEM path) }` -- with `tls`, every accepted connection is a TLS server session (used by the HTTPS/WSS server script). | +| `tcpStartTls(handle: handle, host: string [, opts: map]) -> true` | Turn an established connection into a TLS client session. `host` is both the SNI name and the name the certificate must carry. `opts`: `{ insecure (bool) }`. Verification is **on by default and fails closed** -- without trust anchors (see the `http` section for where those come from) the call errors rather than proceeding unverified. One native covers both shapes: call it immediately after `tcpConnect` for implicit TLS (SMTPS, IMAPS), or after the protocol's own upgrade command for STARTTLS. Upgrading twice is refused. | +| `tcpListen(port: int [, opts: map]) -> handle` | Listen on a TCP port. `opts`: `{ host (bind address), tls (bool), cert (PEM path), key (PEM path) }` -- with `tls`, every accepted connection is a TLS server session (used by the HTTPS/WSS server script). Without `host` the listener binds every interface; naming one (`"127.0.0.1"`) binds only that, which is what a service behind a reverse proxy wants -- there the proxy is the security boundary, and a wildcard bind is a way around it. | | `tcpAccept(handle: handle [, timeoutMs: int]) -> handle \| nil` | Block for a client (completing the TLS handshake for a TLS listener); returns a connection handle. With `timeoutMs`, returns nil if none arrives in time, so an accept loop can re-check its own stop condition. | | `tcpSend(handle: handle, data: string) -> int` | Send all of `data`; returns bytes sent. | | `tcpRecv(handle: handle, maxBytes: int) -> string \| nil` | Read up to `maxBytes`; nil at end of stream. | diff --git a/Makefile b/Makefile index 48d4ff12..5cd7ac1e 100644 --- a/Makefile +++ b/Makefile @@ -356,13 +356,14 @@ $(STRICTOBJ): obj/%.o: %.c | obj $(CC) $(COREFLAGS) $(INC) -c -o $@ $< # strict C, threaded -THREADOBJ = obj/context.o obj/mybasicEngine.o obj/testActor.o obj/testEngineLua.o obj/testEngineSquirrel.o obj/testEngineJs.o obj/testEngineMyBasic.o obj/testEngineBerry.o obj/testEngineS7.o obj/testEngineWren.o obj/testEngineMruby.o obj/testEngineTcl.o obj/testEngineJanet.o obj/calogHandle.o obj/testDb.o obj/testNet.o obj/testTask.o obj/calogExport.o obj/testExport.o obj/calogJson.o obj/testJson.o obj/calogFs.o obj/testFs.o obj/calogTime.o obj/testTime.o obj/calogKv.o obj/testKv.o obj/testCrypto.o obj/calogTimer.o obj/testTimer.o obj/calogPubsub.o obj/testPubsub.o obj/testHttp.o obj/testSsh.o obj/calogCsv.o obj/calogProc.o obj/testSandbox.o obj/testExit.o obj/testTeardown.o obj/testTrace.o obj/testHttpdLua.o obj/testHooks.o +THREADOBJ = obj/context.o obj/mybasicEngine.o obj/testActor.o obj/testEngineLua.o obj/testEngineSquirrel.o obj/testEngineJs.o obj/testEngineMyBasic.o obj/testEngineBerry.o obj/testEngineS7.o obj/testEngineWren.o obj/testEngineMruby.o obj/testEngineTcl.o obj/testEngineJanet.o obj/calogHandle.o obj/testDb.o obj/testNet.o obj/testTask.o obj/calogExport.o obj/testExport.o obj/calogJson.o obj/testJson.o obj/calogFs.o obj/testFs.o obj/calogTime.o obj/testTime.o obj/calogKv.o obj/testKv.o obj/testCrypto.o obj/calogTimer.o obj/testTimer.o obj/calogPubsub.o obj/testPubsub.o obj/testHttp.o obj/testSsh.o obj/calogCsv.o obj/calogProc.o obj/testSandbox.o obj/testExit.o obj/testTeardown.o obj/testTrace.o obj/testHooks.o $(THREADOBJ): obj/%.o: %.c | obj $(CC) $(COREFLAGS) $(INC) -pthread -c -o $@ $< -# calogCrypto and calogHttp need the vendored OpenSSL headers, which are outside $(INC). +# calogCrypto and calogHttp need the vendored OpenSSL headers, which are outside $(INC). So do the +# tests that drive them: testHttpdLua reaches for openssl/ssl.h to drive the TLS listener. OSSLINC = -Ivendor/openssl/include -obj/calogCrypto.o obj/calogHttp.o obj/testHttps.o: obj/%.o: %.c | obj +obj/calogCrypto.o obj/calogHttp.o obj/calogTrust.o obj/testHttps.o obj/testHttpdLua.o: obj/%.o: %.c | obj $(CC) $(COREFLAGS) $(INC) $(OSSLINC) -pthread -c -o $@ $< # calogSsh binds vendored libssh2 (static, built via CMake against our vendored OpenSSL). @@ -561,7 +562,7 @@ bin/testDb: obj/testDb.o obj/calogDb.o obj/calogHandle.o lib/libcalog.a lib/libl # network library test: calog + the network library + vendored ENet, driven from Lua # (TCP/UDP/ENet loopback). -bin/testNet: obj/testNet.o $(NETADP) obj/calogHandle.o lib/libcalog.a lib/liblua.a lib/libenet.a $(SSLARCH) | bin +bin/testNet: obj/testNet.o $(NETADP) obj/calogTrust.o obj/calogHandle.o lib/libcalog.a lib/liblua.a lib/libenet.a $(SSLARCH) | bin $(CC) $(LDFLAGS) -pthread -o $@ $(filter-out $(SSLARCH),$^) $(LUALIBS) $(SSLARCH) # --- optional Postgres + MySQL DB-client tests. They pull in the vendored client archives @@ -646,7 +647,7 @@ bin/embed: obj/embed.o lib/libcalog.a lib/libquickjs.a | bin # binary can drop the MySQL backend (see LICENSE.md). libssh2 precedes OpenSSL (it depends on # it); SSLARCH follows the archives that reference it. bin/calog: obj/calogMain.o \ - obj/calogArchive.o obj/calogCrypto.o obj/calogCsv.o obj/calogDbFull.o obj/calogExport.o obj/calogFs.o obj/calogHttp.o obj/calogJson.o obj/calogKv.o obj/calogNet.o obj/calogProc.o obj/calogPubsub.o obj/calogRegex.o obj/calogSsh.o obj/calogTask.o obj/calogTime.o obj/calogTimer.o obj/calogXml.o obj/calogHandle.o \ + obj/calogArchive.o obj/calogCrypto.o obj/calogCsv.o obj/calogDbFull.o obj/calogExport.o obj/calogFs.o obj/calogHttp.o obj/calogTrust.o obj/calogJson.o obj/calogKv.o obj/calogNet.o obj/calogProc.o obj/calogPubsub.o obj/calogRegex.o obj/calogSsh.o obj/calogTask.o obj/calogTime.o obj/calogTimer.o obj/calogXml.o obj/calogHandle.o \ lib/libcalog.a lib/liblua.a lib/libquickjs.a lib/libsquirrel.a lib/libmybasic.a lib/libberry.a lib/libs7.a lib/libwren.a lib/libjanet.a $(MRUBYLIB) $(TCLLIB) \ lib/libsqlite3.a lib/libenet.a $(LIBSSH2LIB) $(ARCHIVELIBS) $(PCRE2LIB) $(DBARCH) | bin $(CC) $(LDFLAGS) $(RELEASELD) -pthread -o $@ $(filter-out $(DBARCH),$^) -Wl,--start-group $(PGARCHIVES) -Wl,--end-group $(MYSQLARCH) $(SSLARCH) $(CXXLIB) $(DLLIB) -lm -lpthread $(SOCKETLIBS) @@ -734,7 +735,7 @@ bin/testTimer: obj/testTimer.o obj/calogTimer.o lib/libcalog.a lib/liblua.a | bi bin/testPubsub: obj/testPubsub.o obj/calogPubsub.o lib/libcalog.a lib/liblua.a | bin $(CC) $(LDFLAGS) -pthread -o $@ $^ $(LUALIBS) -bin/testHttp: obj/testHttp.o obj/calogHttp.o lib/libcalog.a lib/liblua.a $(SSLARCH) | bin +bin/testHttp: obj/testHttp.o obj/calogHttp.o obj/calogTrust.o lib/libcalog.a lib/liblua.a $(SSLARCH) | bin $(CC) $(LDFLAGS) -pthread -o $@ $^ $(LUALIBS) $(DLLIB) bin/testArchive: obj/testArchive.o obj/calogArchive.o obj/calogHandle.o lib/libcalog.a lib/liblua.a $(ARCHIVELIBS) $(SSLARCH) | bin @@ -760,10 +761,10 @@ bin/testTrace: obj/testTrace.o obj/calogExport.o lib/libcalog.a lib/liblua.a lib # The httpd-as-a-script (examples/httpd.lua): protocol in Lua over the tcp* transport (calogNet, which # needs libenet) + the crypto natives (calogCrypto, which needs OpenSSL). -bin/testHttpdLua: obj/testHttpdLua.o obj/calogNet.o obj/calogCrypto.o obj/calogHandle.o lib/libcalog.a lib/liblua.a lib/libenet.a $(SSLARCH) | bin +bin/testHttpdLua: obj/testHttpdLua.o obj/calogNet.o obj/calogTrust.o obj/calogCrypto.o obj/calogHandle.o lib/libcalog.a lib/liblua.a lib/libenet.a $(SSLARCH) | bin $(CC) $(LDFLAGS) -pthread -o $@ $(filter-out $(SSLARCH),$^) $(LUALIBS) $(SSLARCH) -bin/testHttps: obj/testHttps.o obj/calogHttp.o lib/libcalog.a lib/liblua.a $(SSLARCH) | bin +bin/testHttps: obj/testHttps.o obj/calogHttp.o obj/calogTrust.o lib/libcalog.a lib/liblua.a $(SSLARCH) | bin $(CC) $(LDFLAGS) -pthread -o $@ $^ $(LUALIBS) $(DLLIB) # SSH/SFTP over vendored libssh2 (before OpenSSL in the link line: libssh2 depends on it). diff --git a/examples/httpd.lua b/examples/httpd.lua index d4a0f30d..0896cc0d 100644 --- a/examples/httpd.lua +++ b/examples/httpd.lua @@ -35,7 +35,7 @@ local REASON = { function httpd.new() - return setmetatable({ routes = {}, wsRoutes = {} }, httpd) + return setmetatable({ routes = {}, prefixes = {}, wsRoutes = {} }, httpd) end @@ -44,6 +44,15 @@ function httpd:route(method, path, handler) end +-- A route for everything under a path. The handler gets req.prefix (what matched) and req.rest (the +-- segments after it), so "/v1/download/" can serve "/v1/download/dragon/3" without every id needing +-- its own registration. An exact route always wins, and between prefixes the longest one does. +function httpd:routePrefix(method, prefix, handler) + self.prefixes[#self.prefixes + 1] = { method = method:upper(), prefix = prefix, handler = handler } + table.sort(self.prefixes, function(a, b) return #a.prefix > #b.prefix end) +end + + function httpd:websocket(path, handler) self.wsRoutes[path] = handler end @@ -270,6 +279,16 @@ function httpd:handle(conn) end local keepAlive = wantsKeepAlive(req) local handler = self.routes[req.method .. " " .. req.path] or self.routes["* " .. req.path] + if not handler then + for _, entry in ipairs(self.prefixes) do + if (entry.method == req.method or entry.method == "*") and req.path:sub(1, #entry.prefix) == entry.prefix then + req.prefix = entry.prefix + req.rest = req.path:sub(#entry.prefix + 1) + handler = entry.handler + break + end + end + end if handler then local ok, resp = pcall(handler, req) if ok then @@ -288,14 +307,21 @@ end -- Bind `port` and serve until opts.keep() (if given) returns false. opts.acceptTimeout bounds each --- accept so the keep predicate is polled even with no traffic. +-- accept so the keep predicate is polled even with no traffic. opts.host narrows the bind to one +-- address -- "127.0.0.1" for a server that should be reachable only through a reverse proxy in +-- front of it, since there the proxy is the security boundary and a wildcard bind is a way around +-- it. function httpd:serve(port, opts) opts = opts or {} -- TLS is opt-in via opts.tls + opts.cert + opts.key; pass a clean map to the transport (never the -- whole opts table, which may carry Lua function fields like keep/onReady that are not map values). + -- An empty Lua table is not a keyed map, and tcpListen refuses one, so the plain case passes no + -- opts at all rather than a { host = nil } that vanishes into {}. local srv if opts.tls then - srv = tcpListen(port, { tls = true, cert = opts.cert, key = opts.key }) + srv = tcpListen(port, { host = opts.host, tls = true, cert = opts.cert, key = opts.key }) + elseif opts.host then + srv = tcpListen(port, { host = opts.host }) else srv = tcpListen(port) end diff --git a/examples/smtp.lua b/examples/smtp.lua new file mode 100644 index 00000000..d7a01240 --- /dev/null +++ b/examples/smtp.lua @@ -0,0 +1,350 @@ +-- smtp.lua -- sending mail, as a script. +-- +-- The same shape as examples/httpd.lua and for the same reason: the systems primitives are in C +-- (tcp*, tcpStartTls, crypto*) and the protocol lives here, where it can be read and patched. It +-- needs an engine with binary-safe strings. +-- +-- local smtp = load(fsRead("examples/smtp.lua"))() +-- smtp.send({ +-- host = "smtp.example.com", port = 587, tls = "starttls", +-- user = "apikey", password = "...", +-- from = "Singe ", +-- to = "player@example.com", -- or a list of addresses +-- subject = "Verify your account", +-- text = "Open this link ...", +-- }) +-- +-- Returns true, or nil plus a message. Nothing here retries: a caller that must not lose a message +-- keeps it in a queue and calls again, because "did it send" is a question only the caller's own +-- durable state can answer. + +local smtp = {} + +local CRLF = "\r\n" +local LINE_MAX = 998 -- RFC 5322's limit, excluding the CRLF +local B64_WRAP = 76 -- RFC 2045's limit for base64 body lines +local RECV_CHUNK = 4096 +local REPLY_MAX = 65536 -- a server that will not finish a reply is not a server + + +-- Every address and header value the caller supplies is checked for CR and LF before it goes near +-- the wire. Without this, a display name or an address out of a database could carry "\r\nBcc: ..." +-- and add headers, or "\r\n.\r\n" and end the message early. This is the single most important +-- function in the file. +local function guard(what, value) + value = tostring(value or "") + if value:find("[\r\n]") then + return nil, what .. " must not contain a line break" + end + return value +end + + +-- The address inside "Display Name ", or the whole string when it is bare. The +-- envelope wants only the address; the header keeps whatever the caller wrote. +local function addressOf(value) + local inner = value:match("<([^>]*)>") + return inner or value +end + + +local function isAscii(value) + return not value:find("[\128-\255]") +end + + +-- A header value that is not plain ASCII becomes an RFC 2047 encoded word, so a subject with an +-- accent or an emoji survives instead of arriving as mojibake. +local function encodeHeader(value) + if isAscii(value) then + return value + end + return "=?UTF-8?B?" .. cryptoBase64Encode(value) .. "?=" +end + + +-- Days since the epoch to a civil date (Howard Hinnant's algorithm, shifted so the era starts in +-- March and leap day lands at the end of it). +local function civilFromDays(days) + local z = days + 719468 + local era = math.floor(z / 146097) + local doe = z - era * 146097 + local yoe = math.floor((doe - math.floor(doe / 1460) + math.floor(doe / 36524) - math.floor(doe / 146096)) / 365) + local y = yoe + era * 400 + local doy = doe - (365 * yoe + math.floor(yoe / 4) - math.floor(yoe / 100)) + local mp = math.floor((5 * doy + 2) / 153) + local d = doy - math.floor((153 * mp + 2) / 5) + 1 + local m = mp + (mp < 10 and 3 or -9) + if m <= 2 then + y = y + 1 + end + return y, m, d +end + + +-- An RFC 5322 date in UTC. The zone is written "-0000" rather than "+0000" because that is the +-- form meaning "UTC, and the sender's own zone is not being disclosed", which is the truth here. +local function rfc5322Date(epoch) + local DAY_NAMES = { "Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed" } -- epoch day 0 was a Thursday + local MONTH_NAMES = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" } + local seconds = math.floor(epoch) + local days = math.floor(seconds / 86400) + local rest = seconds - days * 86400 + local year, month, day = civilFromDays(days) + + return string.format("%s, %02d %s %04d %02d:%02d:%02d -0000", + DAY_NAMES[(days % 7) + 1], day, MONTH_NAMES[month], year, + math.floor(rest / 3600), math.floor((rest % 3600) / 60), rest % 60) +end + + +-- A reader over one connection. tcpRecv hands back whatever has arrived, which is not the same +-- shape as a line, so what is left over is kept for the next call. +local function reader(conn) + return { conn = conn, buffer = "" } +end + + +local function readLine(r) + while true do + local at = r.buffer:find(CRLF, 1, true) + if at then + local line = r.buffer:sub(1, at - 1) + r.buffer = r.buffer:sub(at + 2) + return line + end + if #r.buffer > REPLY_MAX then + return nil, "the server sent an unreasonably long reply" + end + local chunk = tcpRecv(r.conn, RECV_CHUNK) + if not chunk or #chunk == 0 then + return nil, "the server closed the connection" + end + r.buffer = r.buffer .. chunk + end +end + + +-- An SMTP reply is one or more lines; every line but the last has a hyphen after its code +-- ("250-STARTTLS" then "250 HELP"). Returns the code and the joined text. +local function readReply(r) + local lines = {} + while true do + local line, err = readLine(r) + if not line then + return nil, err + end + local code, sep, text = line:match("^(%d%d%d)([ -]?)(.*)$") + if not code then + return nil, "malformed reply: " .. line + end + lines[#lines + 1] = text + if sep ~= "-" then + return tonumber(code), table.concat(lines, "\n") + end + end +end + + +local function say(conn, text) + return tcpSend(conn, text .. CRLF) +end + + +-- Send a command and require one of the expected codes, so every step fails at the step that +-- actually went wrong rather than three steps later. +local function expect(conn, r, command, ok, what) + if command then + say(conn, command) + end + local code, text = readReply(r) + if not code then + return nil, what .. ": " .. tostring(text) + end + for _, wanted in ipairs(ok) do + if code == wanted then + return code, text + end + end + return nil, string.format("%s: server said %d %s", what, code, (text or ""):gsub("\n.*", "")) +end + + +local function wrapBase64(text) + local out = {} + for i = 1, #text, B64_WRAP do + out[#out + 1] = text:sub(i, i + B64_WRAP - 1) + end + return table.concat(out, CRLF) +end + + +-- The message itself. The body is always base64: it makes the message binary-safe and 8-bit clean, +-- and it cannot produce a line starting with "." or one longer than the limit, so neither +-- dot-stuffing nor folding has to be got right separately. +local function buildMessage(opts, recipients) + local headers = {} + local body = tostring(opts.text or "") + + headers[#headers + 1] = "From: " .. encodeHeader(opts.from) + headers[#headers + 1] = "To: " .. encodeHeader(table.concat(recipients, ", ")) + headers[#headers + 1] = "Subject: " .. encodeHeader(opts.subject or "") + headers[#headers + 1] = "Date: " .. rfc5322Date(timeNow()) + headers[#headers + 1] = "Message-ID: <" .. cryptoUuid() .. "@" .. (opts.messageIdDomain or addressOf(opts.from):match("@(.+)") or "localhost") .. ">" + headers[#headers + 1] = "MIME-Version: 1.0" + headers[#headers + 1] = "Content-Type: text/plain; charset=utf-8" + headers[#headers + 1] = "Content-Transfer-Encoding: base64" + for _, extra in ipairs(opts.headers or {}) do + headers[#headers + 1] = extra + end + return table.concat(headers, CRLF) .. CRLF .. CRLF .. wrapBase64(cryptoBase64Encode(body)) +end + + +local function authenticate(conn, r, opts, extensions) + if not opts.user then + return true + end + local mechanisms = extensions:match("AUTH ([^\n]*)") or "" + if mechanisms:find("PLAIN") then + local token = cryptoBase64Encode("\0" .. opts.user .. "\0" .. opts.password) + return expect(conn, r, "AUTH PLAIN " .. token, { 235 }, "authentication") + end + if mechanisms:find("LOGIN") then + local ok, err = expect(conn, r, "AUTH LOGIN", { 334 }, "authentication") + if not ok then + return nil, err + end + ok, err = expect(conn, r, cryptoBase64Encode(opts.user), { 334 }, "authentication (user)") + if not ok then + return nil, err + end + return expect(conn, r, cryptoBase64Encode(opts.password), { 235 }, "authentication (password)") + end + return nil, "the relay offers no authentication mechanism this understands (saw: " .. mechanisms .. ")" +end + + +-- Send one message. opts.tls is "starttls" (default), "implicit", or "none"; "none" also needs +-- allowPlain, because sending a password or a recovery link over a plain connection should take +-- more than a typo. +function smtp.send(opts) + local mode = opts.tls or "starttls" + local port = opts.port or (mode == "implicit" and 465 or 587) + local ehloName = opts.ehlo or "localhost" + local recipients = type(opts.to) == "table" and opts.to or { opts.to } + local envelope = {} + + if mode == "none" and not opts.allowPlain then + return nil, "refusing to send over a plain connection: set tls, or allowPlain if the relay is reached some other safe way" + end + for _, field in ipairs({ "host", "from", "subject" }) do + local value, err = guard(field, opts[field]) + if not value then + return nil, err + end + end + for index, address in ipairs(recipients) do + local value, err = guard("recipient", address) + if not value then + return nil, err + end + recipients[index] = value + envelope[index] = addressOf(value) + end + if #recipients == 0 then + return nil, "no recipient" + end + + local conn = tcpConnect(opts.host, port) + if not conn then + return nil, "could not reach " .. opts.host .. ":" .. port + end + + local function fail(message) + tcpClose(conn) + return nil, message + end + + -- An implicit-TLS relay expects the handshake before it says anything at all. + if mode == "implicit" then + local ok, err = pcall(tcpStartTls, conn, opts.host, { insecure = opts.insecure }) + if not ok then + return fail("TLS: " .. tostring(err)) + end + end + + local r = reader(conn) + local code, text = expect(conn, r, nil, { 220 }, "greeting") + if not code then + return fail(text) + end + code, text = expect(conn, r, "EHLO " .. ehloName, { 250 }, "EHLO") + if not code then + return fail(text) + end + + if mode == "starttls" then + if not text:find("STARTTLS") then + return fail("the relay does not offer STARTTLS, and downgrading is not on offer here") + end + code, text = expect(conn, r, "STARTTLS", { 220 }, "STARTTLS") + if not code then + return fail(text) + end + local ok, err = pcall(tcpStartTls, conn, opts.host, { insecure = opts.insecure }) + if not ok then + return fail("TLS: " .. tostring(err)) + end + -- The extension list from before the upgrade is not to be trusted, and AUTH is usually only + -- advertised afterwards, so EHLO is asked again over the protected connection. + code, text = expect(conn, r, "EHLO " .. ehloName, { 250 }, "EHLO after STARTTLS") + if not code then + return fail(text) + end + end + + if opts.user and mode == "none" then + return fail("refusing to send credentials over a plain connection") + end + local authed, authErr = authenticate(conn, r, opts, text) + if not authed then + return fail(authErr) + end + + code, text = expect(conn, r, "MAIL FROM:<" .. addressOf(opts.from) .. ">", { 250 }, "MAIL FROM") + if not code then + return fail(text) + end + for _, address in ipairs(envelope) do + code, text = expect(conn, r, "RCPT TO:<" .. address .. ">", { 250, 251 }, "RCPT TO " .. address) + if not code then + return fail(text) + end + end + code, text = expect(conn, r, "DATA", { 354 }, "DATA") + if not code then + return fail(text) + end + tcpSend(conn, buildMessage(opts, recipients) .. CRLF .. "." .. CRLF) + code, text = expect(conn, r, nil, { 250 }, "message body") + if not code then + return fail(text) + end + say(conn, "QUIT") + tcpClose(conn) + return true +end + + +-- Exposed for testing: the pieces above are pure functions of their arguments and are worth +-- checking without a relay in the loop. +smtp.internal = { + addressOf = addressOf, + encodeHeader = encodeHeader, + guard = guard, + rfc5322Date = rfc5322Date, + buildMessage = buildMessage, +} + +return smtp diff --git a/libs/calogCrypto.c b/libs/calogCrypto.c index fce25562..e858fe8c 100644 --- a/libs/calogCrypto.c +++ b/libs/calogCrypto.c @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -19,17 +20,31 @@ static int32_t cryptoBase64DecodeNative(CalogValueT *args, int32_t argCount, Cal static int32_t cryptoBase64EncodeNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t cryptoBytesToHex(CalogValueT *result, const unsigned char *bytes, size_t length); static int32_t cryptoDigestHex(CalogValueT *args, int32_t argCount, CalogValueT *result, const EVP_MD *md, const char *usage); +static int32_t cryptoEqualsNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t cryptoHashSha1Native(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t cryptoHashSha256Native(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t cryptoHexDecodeNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t cryptoHexEncodeNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t cryptoHmacSha256Native(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); +static int32_t cryptoPbkdf2Native(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t cryptoRandomBytesNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t cryptoUuidNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); // The single source of truth for lowercase hex digit rendering, shared by cryptoBytesToHex // and cryptoUuidNative. static const char cryptoHexDigits[] = "0123456789abcdef"; +// PBKDF2 bounds. The floor is not a recommendation -- it is the point below which the result is +// not a password hash at all; pick the real count by timing the target machine. The ceiling and the +// length cap exist so a script cannot ask for work that never returns: every native here is +// registered inline (calogRegisterBatch registers inline), so a derivation holds the calling +// script's own thread for its duration rather than the host's, which is what makes a deliberately +// slow native safe to offer at all. +#define CRYPTO_TEXT_(x) #x +#define CRYPTO_TEXT(x) CRYPTO_TEXT_(x) +#define CRYPTO_PBKDF2_ITERATIONS_MIN 1000 +#define CRYPTO_PBKDF2_ITERATIONS_MAX 10000000 +#define CRYPTO_PBKDF2_LENGTH_MIN 16 +#define CRYPTO_PBKDF2_LENGTH_MAX 1024 // Every inline native this library exposes. One table so registration is a single // checked call (calogRegisterBatch) rather than a run of calls whose status was dropped. static const CalogNativeEntryT gCryptoNatives[] = { @@ -42,6 +57,8 @@ static const CalogNativeEntryT gCryptoNatives[] = { { "cryptoHexEncode", cryptoHexEncodeNative }, { "cryptoHexDecode", cryptoHexDecodeNative }, { "cryptoUuid", cryptoUuidNative }, + { "cryptoEquals", cryptoEqualsNative }, + { "cryptoPbkdf2", cryptoPbkdf2Native }, }; @@ -214,6 +231,23 @@ static int32_t cryptoDigestHex(CalogValueT *args, int32_t argCount, CalogValueT } +static int32_t cryptoEqualsNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { + (void)userData; + calogValueNil(result); + if (argCount != 2 || args[0].type != calogStringE || args[1].type != calogStringE) { + return calogFail(result, calogErrArgE, "cryptoEquals expects (a, b)"); + } + // Lengths are compared first and in the clear: they are not a secret, and CRYPTO_memcmp has + // nothing to say about two different-sized buffers. + if (args[0].as.s.length != args[1].as.s.length) { + calogValueBool(result, false); + return calogOkE; + } + calogValueBool(result, CRYPTO_memcmp(args[0].as.s.bytes, args[1].as.s.bytes, (size_t)args[0].as.s.length) == 0); + return calogOkE; +} + + static int32_t cryptoHashSha1Native(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { (void)userData; return cryptoDigestHex(args, argCount, result, EVP_sha1(), "cryptoHashSha1 expects (data)"); @@ -302,6 +336,45 @@ static int32_t cryptoHmacSha256Native(CalogValueT *args, int32_t argCount, Calog } +static int32_t cryptoPbkdf2Native(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { + unsigned char *derived; + int64_t iterations; + int64_t length; + int32_t status; + + (void)userData; + calogValueNil(result); + if (argCount != 4 || args[0].type != calogStringE || args[1].type != calogStringE || args[2].type != calogIntE || args[3].type != calogIntE) { + return calogFail(result, calogErrArgE, "cryptoPbkdf2 expects (password, salt, iterations, length)"); + } + iterations = args[2].as.i; + length = args[3].as.i; + if (iterations < CRYPTO_PBKDF2_ITERATIONS_MIN || iterations > CRYPTO_PBKDF2_ITERATIONS_MAX) { + return calogFail(result, calogErrRangeE, "cryptoPbkdf2: iterations must be " CRYPTO_TEXT(CRYPTO_PBKDF2_ITERATIONS_MIN) " to " CRYPTO_TEXT(CRYPTO_PBKDF2_ITERATIONS_MAX)); + } + if (length < CRYPTO_PBKDF2_LENGTH_MIN || length > CRYPTO_PBKDF2_LENGTH_MAX) { + return calogFail(result, calogErrRangeE, "cryptoPbkdf2: length must be " CRYPTO_TEXT(CRYPTO_PBKDF2_LENGTH_MIN) " to " CRYPTO_TEXT(CRYPTO_PBKDF2_LENGTH_MAX) " bytes"); + } + if (args[0].as.s.length > INT_MAX || args[1].as.s.length > INT_MAX) { + return calogFail(result, calogErrRangeE, "cryptoPbkdf2: password or salt too large"); + } + derived = (unsigned char *)malloc((size_t)length); + if (derived == NULL) { + return calogErrOomE; + } + if (PKCS5_PBKDF2_HMAC(args[0].as.s.bytes, (int)args[0].as.s.length, (const unsigned char *)args[1].as.s.bytes, (int)args[1].as.s.length, (int)iterations, EVP_sha256(), (int)length, derived) != 1) { + free(derived); + return calogFail(result, calogErrUnsupportedE, "cryptoPbkdf2 derivation failed"); + } + status = cryptoBytesToHex(result, derived, (size_t)length); + // The derived key is the thing worth stealing out of a freed heap block, so it does not merely + // go out of scope. OPENSSL_cleanse is not elided the way a plain memset can be. + OPENSSL_cleanse(derived, (size_t)length); + free(derived); + return status; +} + + static int32_t cryptoRandomBytesNative(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { unsigned char *buf; int64_t count; diff --git a/libs/calogCrypto.h b/libs/calogCrypto.h index 0324e157..8d287fee 100644 --- a/libs/calogCrypto.h +++ b/libs/calogCrypto.h @@ -11,8 +11,12 @@ // cryptoHexEncode(data) -> lowercase hex text // cryptoHexDecode(hexText) -> decoded binary string // cryptoUuid() -> random RFC 4122 version-4 UUID string +// cryptoPbkdf2(password, salt, iterations, length) +// -> lowercase hex PBKDF2-HMAC-SHA256 key of length bytes +// cryptoEquals(a, b) -> whether two strings match, compared in constant time // All natives are binary-safe and INLINE (pure computation over OpenSSL one-shots, no shared -// state), so there is nothing to shut down. +// state), so there is nothing to shut down. Inline also means a slow one -- cryptoPbkdf2 is +// meant to be slow -- costs the calling script's thread rather than the host's. #ifndef CALOG_CRYPTO_H #define CALOG_CRYPTO_H diff --git a/libs/calogHttp.c b/libs/calogHttp.c index e3380d05..2c73b585 100644 --- a/libs/calogHttp.c +++ b/libs/calogHttp.c @@ -10,6 +10,7 @@ #define _GNU_SOURCE #include "calogHttp.h" +#include "calogTrust.h" #include "calogInternal.h" #include "calogPlatform.h" @@ -25,27 +26,6 @@ #include #include -// Native trust-store access is platform specific. Windows reads the system certificate stores via -// wincrypt.h, whose macros collide with OpenSSL's X.509 API, so they are undefined right after -// inclusion. macOS (only when built against the Apple SDK) reads the keychain via the Security -// framework. Every POSIX target also probes the well-known CA-bundle files, which needs unistd.h. -#if defined(_WIN32) -#include -#include -#undef X509_NAME -#undef X509_EXTENSIONS -#undef PKCS7_ISSUER_AND_SERIAL -#undef PKCS7_SIGNER_INFO -#undef OCSP_REQUEST -#undef OCSP_RESPONSE -#else -#include -#if defined(__APPLE__) && defined(CALOG_MAC_KEYCHAIN_TRUST) -#include -#include -#endif -#endif - #define HTTP_BUF_INITIAL 256 #define HTTP_READ_CHUNK 16384 #define HTTP_MAX_RESPONSE (64 * 1024 * 1024) @@ -114,16 +94,6 @@ static bool httpHasBadByte(const char *bytes, size_t length, bool rejectSpace static bool httpHeaderIn(const char *name, int64_t len, const char *const *list); static int32_t httpHexVal(char c); static bool httpIsRedirect(int32_t code); -#if defined(__APPLE__) && defined(CALOG_MAC_KEYCHAIN_TRUST) -static bool httpLoadMacRoots(SSL_CTX *ctx); -#endif -#ifndef _WIN32 -static bool httpLoadProbedPaths(SSL_CTX *ctx); -#endif -static bool httpLoadTrustStore(SSL_CTX *ctx); -#if defined(_WIN32) -static bool httpLoadWindowsRoots(SSL_CTX *ctx); -#endif static char httpLower(char c); static int32_t httpMapSetAgg(CalogAggT *map, const char *key, CalogAggT *inner); static bool httpMethodHasBody(const char *method); @@ -976,209 +946,6 @@ static bool httpIsRedirect(int32_t code) { } -#if defined(__APPLE__) && defined(CALOG_MAC_KEYCHAIN_TRUST) -// Load trust anchors from the macOS system keychain via the Security framework. This needs the -// Apple SDK, so calog's SDK-less cross build leaves it disabled (falling back to -// httpLoadProbedPaths); it is compiled only for a native macOS build made with -// -DCALOG_MAC_KEYCHAIN_TRUST that links -framework Security -framework CoreFoundation. Each anchor -// is DER, so it round-trips through OpenSSL's d2i_X509 into ctx's store. Returns true once any -// anchor was added. -static bool httpLoadMacRoots(SSL_CTX *ctx) { - X509_STORE *store; - CFArrayRef anchors; - CFIndex count; - CFIndex i; - bool loaded; - - store = SSL_CTX_get_cert_store(ctx); - anchors = NULL; - loaded = false; - if (SecTrustCopyAnchorCertificates(&anchors) != errSecSuccess || anchors == NULL) { - return false; - } - count = CFArrayGetCount(anchors); - for (i = 0; i < count; i++) { - SecCertificateRef cert; - CFDataRef der; - const unsigned char *bytes; - X509 *x; - - cert = (SecCertificateRef)CFArrayGetValueAtIndex(anchors, i); - if (cert == NULL) { - continue; - } - der = SecCertificateCopyData(cert); - if (der == NULL) { - continue; - } - bytes = CFDataGetBytePtr(der); - x = d2i_X509(NULL, &bytes, (long)CFDataGetLength(der)); - CFRelease(der); - if (x == NULL) { - continue; - } - if (X509_STORE_add_cert(store, x) == 1) { - loaded = true; - } - X509_free(x); - } - CFRelease(anchors); - ERR_clear_error(); - return loaded; -} -#endif - - -#ifndef _WIN32 -// Load trust anchors from the CA-bundle files (and hash directories) that Linux distributions and -// the BSDs/macOS ship. The well-known locations are tried in order -- one bundle is enough (a -// system generally has exactly one), but a hash directory is also tried for distros that ship only -// that. access() gates each try so a missing path does not push errors onto OpenSSL's error queue. -// Returns true once any anchors loaded. (CALOG_CA_BUNDLE / SSL_CERT_* overrides are handled, ahead -// of this probe, by httpLoadTrustStore.) -static bool httpLoadProbedPaths(SSL_CTX *ctx) { - static const char *const files[] = { - "/etc/ssl/certs/ca-certificates.crt", // Debian, Ubuntu, Arch, Gentoo, Alpine - "/etc/pki/tls/certs/ca-bundle.crt", // Fedora, RHEL, CentOS - "/etc/ssl/ca-bundle.pem", // openSUSE - "/etc/pki/tls/cacert.pem", // OpenELEC - "/etc/ssl/cert.pem", // Alpine, macOS, OpenBSD, FreeBSD - "/usr/local/share/certs/ca-root-nss.crt", // FreeBSD (ports) - "/etc/openssl/certs/ca-certificates.crt" // NetBSD - }; - static const char *const dirs[] = { - "/etc/ssl/certs", // Debian, openSUSE (hashed) - "/etc/pki/tls/certs", // Fedora, RHEL - "/system/etc/security/cacerts" // Android - }; - size_t i; - bool loaded; - - loaded = false; - for (i = 0; i < sizeof(files) / sizeof(files[0]); i++) { - if (access(files[i], R_OK) != 0) { - continue; - } - if (SSL_CTX_load_verify_locations(ctx, files[i], NULL) == 1) { - loaded = true; - break; - } - } - if (!loaded) { - for (i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) { - if (access(dirs[i], R_OK) != 0) { - continue; - } - if (SSL_CTX_load_verify_locations(ctx, NULL, dirs[i]) == 1) { - loaded = true; - break; - } - } - } - return loaded; -} -#endif - - -// Populate ctx's certificate trust store from the host's native trust configuration. OpenSSL's -// compiled-in default paths point at the vendored build prefix, which does not exist at runtime, so -// they resolve nothing on their own; instead we consult the operating system directly (the Windows -// system stores, the macOS keychain, or the distro CA-bundle files). Overrides are honored ahead of -// the native store, in priority order: CALOG_CA_BUNDLE (authoritative and EXCLUSIVE -- trust only -// that bundle, so it can pin to a private CA, and fail closed if it cannot be loaded), then the -// OpenSSL-standard SSL_CERT_FILE / SSL_CERT_DIR. Returns true only if a trust source actually -// loaded (not merely because an override variable is set), so a verified request can fail with a -// clear "no trust store" error instead of a misleading per-certificate verification failure. -static bool httpLoadTrustStore(SSL_CTX *ctx) { - const char *pin; - const char *envFile; - const char *envDir; - bool loaded; - - // A pinned bundle is authoritative and exclusive: trust EXACTLY it and nothing else, and fail - // closed if it will not load. Load return value (not mere presence of the variable) decides. - pin = getenv("CALOG_CA_BUNDLE"); - if (pin != NULL) { - return SSL_CTX_load_verify_locations(ctx, pin, NULL) == 1; - } - loaded = false; - // The OpenSSL-standard operator override. Load each explicitly and confirm it, so a broken - // SSL_CERT_FILE still surfaces the clear no-trust-store error rather than counting as loaded. - // (An SSL_CERT_DIR is a lazy hash-dir lookup, so its load cannot be pre-confirmed; a set-but- - // empty directory is the one case that still counts as loaded, which is fail-closed-safe.) - envFile = getenv("SSL_CERT_FILE"); - if (envFile != NULL && SSL_CTX_load_verify_locations(ctx, envFile, NULL) == 1) { - loaded = true; - } - envDir = getenv("SSL_CERT_DIR"); - if (envDir != NULL && SSL_CTX_load_verify_locations(ctx, NULL, envDir) == 1) { - loaded = true; - } -#if defined(_WIN32) - if (httpLoadWindowsRoots(ctx)) { - loaded = true; - } -#else -#if defined(__APPLE__) && defined(CALOG_MAC_KEYCHAIN_TRUST) - if (httpLoadMacRoots(ctx)) { - loaded = true; - } -#endif - if (httpLoadProbedPaths(ctx)) { - loaded = true; - } -#endif - return loaded; -} - - -#if defined(_WIN32) -// Load trust anchors from the Windows system certificate stores (ROOT = trusted roots, CA = -// intermediates). Each store certificate is DER, so it round-trips through OpenSSL's d2i_X509 into -// ctx's store. Returns true once any certificate was added. -static bool httpLoadWindowsRoots(SSL_CTX *ctx) { - static const char *const stores[] = { "ROOT", "CA" }; - X509_STORE *store; - size_t i; - bool loaded; - - store = SSL_CTX_get_cert_store(ctx); - loaded = false; - for (i = 0; i < sizeof(stores) / sizeof(stores[0]); i++) { - HCERTSTORE sys; - PCCERT_CONTEXT cert; - - sys = CertOpenSystemStoreA(0, stores[i]); - if (sys == NULL) { - continue; - } - cert = NULL; - while ((cert = CertEnumCertificatesInStore(sys, cert)) != NULL) { - const unsigned char *der; - X509 *x; - - der = cert->pbCertEncoded; - x = d2i_X509(NULL, &der, (long)cert->cbCertEncoded); - if (x == NULL) { - continue; - } - // X509_STORE_add_cert up-refs on success, so our reference is always released here; a - // duplicate (already present) returns 0 and is simply skipped. - if (X509_STORE_add_cert(store, x) == 1) { - loaded = true; - } - X509_free(x); - } - CertCloseStore(sys, 0); - } - // Duplicate-add attempts push errors onto OpenSSL's thread-local queue; clear them so a later - // SSL_get_error is not misled by this bookkeeping. - ERR_clear_error(); - return loaded; -} -#endif - - static char httpLower(char c) { if (c >= 'A' && c <= 'Z') { return (char)(c - 'A' + 'a'); @@ -1747,7 +1514,7 @@ static int32_t httpTlsHandshake(HttpConnT *conn, const HttpUrlT *url, CalogValue // with a misleading per-certificate error, so surface a clear one here instead. (An https // request can pass insecure=true to skip verification.) SSL_CTX_set_verify(conn->ctx, SSL_VERIFY_PEER, NULL); - if (!httpLoadTrustStore(conn->ctx)) { + if (!calogTrustLoad(conn->ctx)) { return calogFail(result, calogErrUnsupportedE, "http: no system CA trust store found (set SSL_CERT_FILE or CALOG_CA_BUNDLE, or pass insecure=true)"); } } diff --git a/libs/calogNet.c b/libs/calogNet.c index b56b6046..bb7f23af 100644 --- a/libs/calogNet.c +++ b/libs/calogNet.c @@ -5,6 +5,7 @@ #define _GNU_SOURCE #include "calogNet.h" +#include "calogTrust.h" #include "calogHandle.h" #include "calogInternal.h" @@ -21,6 +22,7 @@ #include #include +#include // Handle type tags, distinct across the whole registry so a stray handle of the wrong kind // fails to resolve (e.g. a listener passed to tcpSend). @@ -68,9 +70,22 @@ static int32_t enetHost(CalogValueT *args, int32_t argCount, CalogValueT *result static int32_t enetSend(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t enetService(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static void netCloser(uint32_t type, void *resource); -static int32_t netOpenBound(uint16_t port, int socktype, bool doListen, CalogValueT *result, CalogSocketT *fdOut); +static int32_t netOpenBound(const char *host, uint16_t port, int socktype, bool doListen, CalogValueT *result, CalogSocketT *fdOut); static bool netPortOk(int64_t port); static int netResolve(const char *host, uint16_t port, int socktype, bool passive, struct addrinfo **out); +// Whether a value is usable as an options map. A keyed aggregate obviously is; so is an empty one, +// because a script writing { host = maybeNil } ends up with {}, which no engine can tell from an +// empty list. Rejecting that would mean every caller had to branch around a nil option, which is a +// papercut that has already drawn blood twice here. A non-empty *list* is still refused, because +// that is a real mistake rather than an absent option. +static bool netOptsUsable(const CalogValueT *value) { + if (value->type != calogAggE) { + return false; + } + return calogAggIsKeyed(value->as.agg) || (value->as.agg->pairCount == 0 && value->as.agg->arrayCount == 0); +} + + static const CalogValueT *netOptField(const CalogValueT *map, const char *name); static int32_t netSocketClose(NetLibT *lib, int64_t handleId, uint32_t type1, uint32_t type2, CalogValueT *result, const char *message); static void netSocketFree(NetSocketT *sock); @@ -83,6 +98,7 @@ static int32_t tcpConnect(CalogValueT *args, int32_t argCount, CalogValueT *resu static int32_t tcpListen(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t tcpRecv(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t tcpSend(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); +static int32_t tcpStartTls(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t udpClose(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t udpOpen(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t udpRecvFrom(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); @@ -96,6 +112,7 @@ static const NetNativeT gNetNatives[] = { { "tcpAccept", tcpAccept }, { "tcpSend", tcpSend }, { "tcpRecv", tcpRecv }, + { "tcpStartTls", tcpStartTls }, { "tcpClose", tcpClose }, { "udpOpen", udpOpen }, { "udpSendTo", udpSendTo }, @@ -208,9 +225,12 @@ static void netCloser(uint32_t type, void *resource) { } -// Create a socket bound to the given local port (0 = ephemeral), optionally listening. +// Create a socket bound to the given local address and port (0 = ephemeral), optionally listening. +// A NULL host binds every interface, which is what a public listener wants; naming "127.0.0.1" +// binds loopback only, which is what a service sitting behind a reverse proxy wants -- there, the +// proxy is the security boundary, and a wildcard bind would let the network reach straight past it. // Returns calogOkE with *fdOut set, or an error with result populated. -static int32_t netOpenBound(uint16_t port, int socktype, bool doListen, CalogValueT *result, CalogSocketT *fdOut) { +static int32_t netOpenBound(const char *host, uint16_t port, int socktype, bool doListen, CalogValueT *result, CalogSocketT *fdOut) { struct addrinfo *res; struct addrinfo *rp; CalogSocketT fd; @@ -219,7 +239,7 @@ static int32_t netOpenBound(uint16_t port, int socktype, bool doListen, CalogVal *fdOut = CALOG_INVALID_SOCKET; yes = 1; - rc = netResolve(NULL, port, socktype, true, &res); + rc = netResolve(host, port, socktype, true, &res); if (rc != 0) { return calogFail(result, calogErrArgE, gai_strerror(rc)); } @@ -307,6 +327,42 @@ static int32_t netSocketClose(NetLibT *lib, int64_t handleId, uint32_t type1, ui // the server's own writes (a ServerHello/Certificate that never drains) -- none of which a per-recv // SO_RCVTIMEO would catch. Restores blocking mode on success so tcpRecv/tcpSend behave normally. // Returns false on timeout or a hard handshake error (the caller then closes the socket). +// The client half of netTlsAccept: the same bounded, non-blocking handshake loop driving +// SSL_connect instead of SSL_accept, so a relay that stalls mid-handshake cannot pin the calling +// thread either. +static bool netTlsConnect(SSL *ssl, CalogSocketT fd, int timeoutMs) { + int64_t deadline; + + deadline = (int64_t)calogMonotonicMillis() + timeoutMs; + calogSockSetNonblock(fd, 1); + for (;;) { + struct pollfd pfd; + int64_t remaining; + int rc; + int err; + rc = SSL_connect(ssl); + if (rc == 1) { + calogSockSetNonblock(fd, 0); + return true; + } + err = SSL_get_error(ssl, rc); + if (err != SSL_ERROR_WANT_READ && err != SSL_ERROR_WANT_WRITE) { + return false; + } + remaining = deadline - (int64_t)calogMonotonicMillis(); + if (remaining <= 0) { + return false; + } + pfd.fd = fd; + pfd.events = (short)((err == SSL_ERROR_WANT_WRITE) ? POLLOUT : POLLIN); + pfd.revents = 0; + if (calogPoll(&pfd, 1, (int)(remaining > INT32_MAX ? INT32_MAX : remaining)) <= 0) { + return false; + } + } +} + + static bool netTlsAccept(SSL *ssl, CalogSocketT fd, int timeoutMs) { int64_t deadline; @@ -558,28 +614,36 @@ static int32_t tcpConnect(CalogValueT *args, int32_t argCount, CalogValueT *resu static int32_t tcpListen(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { NetLibT *lib; const CalogValueT *opts; + const char *host; SSL_CTX *ctx; CalogSocketT fd; int32_t status; - lib = (NetLibT *)userData; + lib = (NetLibT *)userData; + host = NULL; calogValueNil(result); if (argCount < 1 || argCount > 2 || args[0].type != calogIntE) { return calogFail(result, calogErrArgE, "tcpListen expects (port [, opts])"); } - if (argCount == 2 && (args[1].type != calogAggE || !calogAggIsKeyed(args[1].as.agg))) { + if (argCount == 2 && !netOptsUsable(&args[1])) { return calogFail(result, calogErrArgE, "tcpListen: opts must be a map"); } if (!netPortOk(args[0].as.i)) { return calogFail(result, calogErrArgE, "tcpListen: port out of range"); } // TLS is opt-in: { tls = true, cert = "...pem", key = "...pem" } builds a server SSL_CTX that the - // listener owns; tcpAccept then wraps each accepted connection in a TLS session. + // listener owns; tcpAccept then wraps each accepted connection in a TLS session. { host = "..." } + // narrows the bind from every interface to one address. opts = argCount == 2 ? &args[1] : NULL; ctx = NULL; if (opts != NULL) { const CalogValueT *tls; const CalogValueT *cert; + const CalogValueT *bind; + bind = netOptField(opts, "host"); + if (bind != NULL && bind->type == calogStringE) { + host = bind->as.s.bytes; + } tls = netOptField(opts, "tls"); cert = netOptField(opts, "cert"); if ((tls != NULL && tls->type == calogBoolE && tls->as.b) || cert != NULL) { @@ -591,7 +655,7 @@ static int32_t tcpListen(CalogValueT *args, int32_t argCount, CalogValueT *resul } } } - status = netOpenBound((uint16_t)args[0].as.i, SOCK_STREAM, true, result, &fd); + status = netOpenBound(host, (uint16_t)args[0].as.i, SOCK_STREAM, true, result, &fd); if (status != calogOkE) { if (ctx != NULL) { SSL_CTX_free(ctx); @@ -696,6 +760,88 @@ static int32_t tcpSend(CalogValueT *args, int32_t argCount, CalogValueT *result, } +static int32_t tcpStartTls(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { + NetLibT *lib; + NetSocketT *sock; + const CalogValueT *opts; + const CalogValueT *field; + SSL_CTX *ctx; + SSL *ssl; + bool insecure; + + lib = (NetLibT *)userData; + insecure = false; + calogValueNil(result); + if (argCount < 2 || argCount > 3 || args[0].type != calogIntE || args[1].type != calogStringE) { + return calogFail(result, calogErrArgE, "tcpStartTls expects (handle, host [, opts])"); + } + if (argCount == 3 && !netOptsUsable(&args[2])) { + return calogFail(result, calogErrArgE, "tcpStartTls: opts must be a map"); + } + sock = (NetSocketT *)calogHandleGet(lib->handles, args[0].as.i, NET_TYPE_TCP); + if (sock == NULL) { + return calogFail(result, calogErrArgE, "tcpStartTls: invalid handle"); + } + // Upgrading twice would leak the first session and is always a protocol bug in the caller. + if (sock->ssl != NULL) { + return calogFail(result, calogErrArgE, "tcpStartTls: this connection is already TLS"); + } + opts = argCount == 3 ? &args[2] : NULL; + if (opts != NULL) { + field = netOptField(opts, "insecure"); + if (field != NULL && field->type == calogBoolE) { + insecure = field->as.b; + } + } + ctx = SSL_CTX_new(TLS_client_method()); + if (ctx == NULL) { + return calogFail(result, calogErrOomE, "tcpStartTls: could not create the TLS context"); + } + SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); + if (insecure) { + SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, NULL); + } else { + // Verification is the default and it fails closed: without anchors the call errors here + // rather than succeeding against an unverified peer. + if (!calogTrustLoad(ctx)) { + SSL_CTX_free(ctx); + return calogFail(result, calogErrUnsupportedE, "tcpStartTls: no system CA trust store found (set SSL_CERT_FILE or CALOG_CA_BUNDLE, or pass insecure=true)"); + } + SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL); + } + ssl = SSL_new(ctx); + if (ssl == NULL) { + SSL_CTX_free(ctx); + return calogFail(result, calogErrOomE, "tcpStartTls: could not create the TLS session"); + } + // The host is what the certificate is checked against, and it is also the SNI name: a relay + // sharing an address with others answers with the wrong certificate without it. + if (!insecure) { + // Bind verification to the requested host, so a valid certificate issued for a DIFFERENT + // one is still rejected -- the same pairing calogHttp uses. + SSL_set_hostflags(ssl, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS); + if (SSL_set1_host(ssl, args[1].as.s.bytes) != 1) { + SSL_free(ssl); + SSL_CTX_free(ctx); + return calogFail(result, calogErrArgE, "tcpStartTls: could not set the expected host name"); + } + } + (void)SSL_set_tlsext_host_name(ssl, args[1].as.s.bytes); + SSL_set_fd(ssl, (int)sock->fd); + (void)BIO_set_close(SSL_get_rbio(ssl), BIO_NOCLOSE); + if (!netTlsConnect(ssl, sock->fd, NET_TLS_HANDSHAKE_MS)) { + SSL_free(ssl); + SSL_CTX_free(ctx); + return calogFail(result, calogErrArgE, "tcpStartTls: TLS handshake failed, timed out, or the certificate was rejected"); + } + // The socket owns both from here; tcpClose frees the session and then the context. + sock->ssl = ssl; + sock->tlsCtx = ctx; + calogValueBool(result, true); + return calogOkE; +} + + static int32_t udpClose(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { NetLibT *lib; @@ -721,7 +867,7 @@ static int32_t udpOpen(CalogValueT *args, int32_t argCount, CalogValueT *result, if (!netPortOk(args[0].as.i)) { return calogFail(result, calogErrArgE, "udpOpen: port out of range"); } - status = netOpenBound((uint16_t)args[0].as.i, SOCK_DGRAM, false, result, &fd); + status = netOpenBound(NULL, (uint16_t)args[0].as.i, SOCK_DGRAM, false, result, &fd); if (status != calogOkE) { return status; } diff --git a/libs/calogTrust.c b/libs/calogTrust.c new file mode 100644 index 00000000..c0f7941e --- /dev/null +++ b/libs/calogTrust.c @@ -0,0 +1,245 @@ +// calogTrust.c -- trust anchor loading for TLS clients (see calogTrust.h). Moved out of +// calogHttp.c on 2026-09-11 so the tcp transport's client-side TLS shares one implementation with +// the http client rather than carrying a second copy of it. + +#include "calogTrust.h" + +#include +#include + +#include +#include + +// Native trust-store access is platform specific. Windows reads the system certificate stores via +// wincrypt.h, whose macros collide with OpenSSL's X.509 API, so they are undefined right after +// inclusion. macOS (only when built against the Apple SDK) reads the keychain via the Security +// framework. Every POSIX target also probes the well-known CA-bundle files, which needs unistd.h. +#if defined(_WIN32) +#include +#include +#undef X509_NAME +#undef X509_EXTENSIONS +#undef PKCS7_ISSUER_AND_SERIAL +#undef PKCS7_SIGNER_INFO +#undef OCSP_REQUEST +#undef OCSP_RESPONSE +#else +#include +#if defined(__APPLE__) && defined(CALOG_MAC_KEYCHAIN_TRUST) +#include +#include +#endif +#endif + +#ifndef _WIN32 +static bool trustLoadProbedPaths(SSL_CTX *ctx); +#endif +#if defined(__APPLE__) && defined(CALOG_MAC_KEYCHAIN_TRUST) +static bool trustLoadMacRoots(SSL_CTX *ctx); +#endif +#if defined(_WIN32) +static bool trustLoadWindowsRoots(SSL_CTX *ctx); +#endif + + +#if defined(__APPLE__) && defined(CALOG_MAC_KEYCHAIN_TRUST) +// Load trust anchors from the macOS system keychain via the Security framework. This needs the +// Apple SDK, so calog's SDK-less cross build leaves it disabled (falling back to +// trustLoadProbedPaths); it is compiled only for a native macOS build made with +// -DCALOG_MAC_KEYCHAIN_TRUST that links -framework Security -framework CoreFoundation. Each anchor +// is DER, so it round-trips through OpenSSL's d2i_X509 into ctx's store. Returns true once any +// anchor was added. +static bool trustLoadMacRoots(SSL_CTX *ctx) { + X509_STORE *store; + CFArrayRef anchors; + CFIndex count; + CFIndex i; + bool loaded; + + store = SSL_CTX_get_cert_store(ctx); + anchors = NULL; + loaded = false; + if (SecTrustCopyAnchorCertificates(&anchors) != errSecSuccess || anchors == NULL) { + return false; + } + count = CFArrayGetCount(anchors); + for (i = 0; i < count; i++) { + SecCertificateRef cert; + CFDataRef der; + const unsigned char *bytes; + X509 *x; + + cert = (SecCertificateRef)CFArrayGetValueAtIndex(anchors, i); + if (cert == NULL) { + continue; + } + der = SecCertificateCopyData(cert); + if (der == NULL) { + continue; + } + bytes = CFDataGetBytePtr(der); + x = d2i_X509(NULL, &bytes, (long)CFDataGetLength(der)); + CFRelease(der); + if (x == NULL) { + continue; + } + if (X509_STORE_add_cert(store, x) == 1) { + loaded = true; + } + X509_free(x); + } + CFRelease(anchors); + ERR_clear_error(); + return loaded; +} +#endif + + +#ifndef _WIN32 +// Load trust anchors from the CA-bundle files (and hash directories) that Linux distributions and +// the BSDs/macOS ship. The well-known locations are tried in order -- one bundle is enough (a +// system generally has exactly one), but a hash directory is also tried for distros that ship only +// that. access() gates each try so a missing path does not push errors onto OpenSSL's error queue. +// Returns true once any anchors loaded. (CALOG_CA_BUNDLE / SSL_CERT_* overrides are handled, ahead +// of this probe, by calogTrustLoad.) +static bool trustLoadProbedPaths(SSL_CTX *ctx) { + static const char *const files[] = { + "/etc/ssl/certs/ca-certificates.crt", // Debian, Ubuntu, Arch, Gentoo, Alpine + "/etc/pki/tls/certs/ca-bundle.crt", // Fedora, RHEL, CentOS + "/etc/ssl/ca-bundle.pem", // openSUSE + "/etc/pki/tls/cacert.pem", // OpenELEC + "/etc/ssl/cert.pem", // Alpine, macOS, OpenBSD, FreeBSD + "/usr/local/share/certs/ca-root-nss.crt", // FreeBSD (ports) + "/etc/openssl/certs/ca-certificates.crt" // NetBSD + }; + static const char *const dirs[] = { + "/etc/ssl/certs", // Debian, openSUSE (hashed) + "/etc/pki/tls/certs", // Fedora, RHEL + "/system/etc/security/cacerts" // Android + }; + size_t i; + bool loaded; + + loaded = false; + for (i = 0; i < sizeof(files) / sizeof(files[0]); i++) { + if (access(files[i], R_OK) != 0) { + continue; + } + if (SSL_CTX_load_verify_locations(ctx, files[i], NULL) == 1) { + loaded = true; + break; + } + } + if (!loaded) { + for (i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) { + if (access(dirs[i], R_OK) != 0) { + continue; + } + if (SSL_CTX_load_verify_locations(ctx, NULL, dirs[i]) == 1) { + loaded = true; + break; + } + } + } + return loaded; +} +#endif + + +// Populate ctx's certificate trust store from the host's native trust configuration. OpenSSL's +// compiled-in default paths point at the vendored build prefix, which does not exist at runtime, so +// they resolve nothing on their own; instead we consult the operating system directly (the Windows +// system stores, the macOS keychain, or the distro CA-bundle files). Overrides are honored ahead of +// the native store, in priority order: CALOG_CA_BUNDLE (authoritative and EXCLUSIVE -- trust only +// that bundle, so it can pin to a private CA, and fail closed if it cannot be loaded), then the +// OpenSSL-standard SSL_CERT_FILE / SSL_CERT_DIR. Returns true only if a trust source actually +// loaded (not merely because an override variable is set), so a verified request can fail with a +// clear "no trust store" error instead of a misleading per-certificate verification failure. +bool calogTrustLoad(SSL_CTX *ctx) { + const char *pin; + const char *envFile; + const char *envDir; + bool loaded; + + // A pinned bundle is authoritative and exclusive: trust EXACTLY it and nothing else, and fail + // closed if it will not load. Load return value (not mere presence of the variable) decides. + pin = getenv("CALOG_CA_BUNDLE"); + if (pin != NULL) { + return SSL_CTX_load_verify_locations(ctx, pin, NULL) == 1; + } + loaded = false; + // The OpenSSL-standard operator override. Load each explicitly and confirm it, so a broken + // SSL_CERT_FILE still surfaces the clear no-trust-store error rather than counting as loaded. + // (An SSL_CERT_DIR is a lazy hash-dir lookup, so its load cannot be pre-confirmed; a set-but- + // empty directory is the one case that still counts as loaded, which is fail-closed-safe.) + envFile = getenv("SSL_CERT_FILE"); + if (envFile != NULL && SSL_CTX_load_verify_locations(ctx, envFile, NULL) == 1) { + loaded = true; + } + envDir = getenv("SSL_CERT_DIR"); + if (envDir != NULL && SSL_CTX_load_verify_locations(ctx, NULL, envDir) == 1) { + loaded = true; + } +#if defined(_WIN32) + if (trustLoadWindowsRoots(ctx)) { + loaded = true; + } +#else +#if defined(__APPLE__) && defined(CALOG_MAC_KEYCHAIN_TRUST) + if (trustLoadMacRoots(ctx)) { + loaded = true; + } +#endif + if (trustLoadProbedPaths(ctx)) { + loaded = true; + } +#endif + return loaded; +} + + +#if defined(_WIN32) +// Load trust anchors from the Windows system certificate stores (ROOT = trusted roots, CA = +// intermediates). Each store certificate is DER, so it round-trips through OpenSSL's d2i_X509 into +// ctx's store. Returns true once any certificate was added. +static bool trustLoadWindowsRoots(SSL_CTX *ctx) { + static const char *const stores[] = { "ROOT", "CA" }; + X509_STORE *store; + size_t i; + bool loaded; + + store = SSL_CTX_get_cert_store(ctx); + loaded = false; + for (i = 0; i < sizeof(stores) / sizeof(stores[0]); i++) { + HCERTSTORE sys; + PCCERT_CONTEXT cert; + + sys = CertOpenSystemStoreA(0, stores[i]); + if (sys == NULL) { + continue; + } + cert = NULL; + while ((cert = CertEnumCertificatesInStore(sys, cert)) != NULL) { + const unsigned char *der; + X509 *x; + + der = cert->pbCertEncoded; + x = d2i_X509(NULL, &der, (long)cert->cbCertEncoded); + if (x == NULL) { + continue; + } + // X509_STORE_add_cert up-refs on success, so our reference is always released here; a + // duplicate (already present) returns 0 and is simply skipped. + if (X509_STORE_add_cert(store, x) == 1) { + loaded = true; + } + X509_free(x); + } + CertCloseStore(sys, 0); + } + // Duplicate-add attempts push errors onto OpenSSL's thread-local queue; clear them so a later + // SSL_get_error is not misled by this bookkeeping. + ERR_clear_error(); + return loaded; +} +#endif diff --git a/libs/calogTrust.h b/libs/calogTrust.h new file mode 100644 index 00000000..77be5284 --- /dev/null +++ b/libs/calogTrust.h @@ -0,0 +1,26 @@ +// calogTrust.h -- where a TLS client finds its trust anchors. +// +// One implementation, shared by every calog library that verifies a server certificate: the http +// client and the tcp transport's client-side TLS both call calogTrustLoad. It lived inside +// calogHttp.c until 2026-09-11, when the tcp transport needed it too; a second copy of "which +// bundle does this operating system keep its roots in" is the last thing that should exist twice. + +#ifndef CALOG_TRUST_H +#define CALOG_TRUST_H + +#include + +#include + +// Load the host's trust anchors into ctx. OpenSSL's own defaults resolve nothing on their own, so +// this consults the operating system directly (the Windows system stores, the macOS keychain, or +// the distro CA-bundle files). Overrides are honoured ahead of the native store, highest priority +// first: CALOG_CA_BUNDLE (authoritative and EXCLUSIVE, so it can pin to a private CA and fails +// closed if it will not load), then the OpenSSL-standard SSL_CERT_FILE / SSL_CERT_DIR. +// +// Returns true only if a trust source actually loaded -- not merely because an override variable is +// set -- so a caller can fail with a clear "no trust store" error rather than a misleading +// per-certificate verification failure. +bool calogTrustLoad(SSL_CTX *ctx); + +#endif diff --git a/tests/testCrypto.c b/tests/testCrypto.c index 45e6af9c..45616f25 100644 --- a/tests/testCrypto.c +++ b/tests/testCrypto.c @@ -1,6 +1,6 @@ // testCrypto.c -- exercises the crypto library: SHA-256/SHA-1 hashes, HMAC-SHA-256, base64 -// and hex codecs (with round-trips and binary safety), random bytes, and UUIDs, driven from -// a Lua context. +// and hex codecs (with round-trips and binary safety), random bytes, UUIDs, PBKDF2 key +// derivation and constant-time comparison, driven from a Lua context. #define _POSIX_C_SOURCE 200809L @@ -14,7 +14,7 @@ #define CHECK(cond, msg) checkImpl((cond), (msg), __FILE__, __LINE__) #define PUMP_LIMIT 4000 -#define RESULT_SLOTS 16 +#define RESULT_SLOTS 24 static CalogT *calog = NULL; static _Atomic int64_t results[RESULT_SLOTS]; @@ -134,6 +134,17 @@ int main(void) { "local ok = pcall(function() cryptoHexDecode('xyz') end)\n" "report(13, ok and 0 or 1)\n" // invalid hex is catchable "report(14, cryptoBase64Decode('YQ==\\n') == 'a' and 1 or 0)\n" // trailing whitespace does not add spurious NULs + "report(15, cryptoPbkdf2('password', 'salt', 4096, 32) == 'c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a' and 1 or 0)\n" // known PBKDF2-HMAC-SHA256 vector + "report(16, #cryptoPbkdf2('password', 'salt', 1000, 64))\n" // a byte length asks for twice as many hex chars + "report(17, cryptoPbkdf2('p', 'salt1', 1000, 16) ~= cryptoPbkdf2('p', 'salt2', 1000, 16) and 1 or 0)\n" // the salt actually salts + "local lowIters = pcall(function() cryptoPbkdf2('p', 's', 10, 32) end)\n" + "report(18, lowIters and 0 or 1)\n" // an iteration count too low to be a password hash is refused + "local shortKey = pcall(function() cryptoPbkdf2('p', 's', 1000, 4) end)\n" + "report(19, shortKey and 0 or 1)\n" // so is a key too short to be one + "report(20, cryptoEquals('abc', 'abc') and 1 or 0)\n" + "report(21, cryptoEquals('abc', 'abd') and 0 or 1)\n" + "report(22, cryptoEquals('abc', 'abcd') and 0 or 1)\n" // differing lengths are unequal, not an error + "report(23, cryptoEquals('a\\0b', 'a\\0b') and 1 or 0)\n" // binary-safe over embedded NULs "done()"); pumpUntilDone(1); @@ -151,6 +162,15 @@ int main(void) { CHECK(atomic_load(&results[12]) == 1, "cryptoHexEncode is binary-safe over embedded NULs"); CHECK(atomic_load(&results[13]) == 1, "invalid hex raises a catchable error"); CHECK(atomic_load(&results[14]) == 1, "cryptoBase64Decode trims trailing whitespace without spurious trailing NULs"); + CHECK(atomic_load(&results[15]) == 1, "cryptoPbkdf2 matches the known PBKDF2-HMAC-SHA256 vector"); + CHECK(atomic_load(&results[16]) == 128, "cryptoPbkdf2 returns two hex chars per requested byte"); + CHECK(atomic_load(&results[17]) == 1, "cryptoPbkdf2 derives differently for different salts"); + CHECK(atomic_load(&results[18]) == 1, "cryptoPbkdf2 refuses an iteration count below the floor"); + CHECK(atomic_load(&results[19]) == 1, "cryptoPbkdf2 refuses a key shorter than the floor"); + CHECK(atomic_load(&results[20]) == 1, "cryptoEquals is true for identical strings"); + CHECK(atomic_load(&results[21]) == 1, "cryptoEquals is false for strings of equal length that differ"); + CHECK(atomic_load(&results[22]) == 1, "cryptoEquals is false for strings of different lengths"); + CHECK(atomic_load(&results[23]) == 1, "cryptoEquals is binary-safe over embedded NULs"); CHECK(atomic_load(&errorCount) == 0, "no uncaught errors"); calogDestroy(calog); diff --git a/vendor/postgres/src/test/locale/koi8-to-win1251/README b/vendor/postgres/src/test/locale/koi8-to-win1251/README deleted file mode 100644 index 07378030..00000000 --- a/vendor/postgres/src/test/locale/koi8-to-win1251/README +++ /dev/null @@ -1,6 +0,0 @@ -src/test/locale/koi8-to-win1251/README - -koi8-to-win1251 test. The database should be created in koi8 (createdb -E koi8), -test uses koi8-to-win1251 converting feature. -Created by Oleg Broytmann . Code for encodings -converting created by Tatsuo Ishii .