From d0d8f658b538e8d2528f19345d315043fd064fbe Mon Sep 17 00:00:00 2001 From: "svc-finitelabs[bot]" <269744575+svc-finitelabs[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:53:02 -0500 Subject: [PATCH] docs: document the typecheck gate and correct CLAUDE.md CLAUDE.md never mentioned typecheck. The target landed in 821ae5c and was gated into check by 32c9c3a, both after the file was last touched, so an agent reading it and running make all skipped the gate CI enforces. - Add the typecheck section, converged with the sibling repos, and note that it scopes to the whole repo because @alias resolves workspace-wide, which is why src/crypto/annotations.lua is never required and still matters. - Document make check as the gate; make all runs neither format-check nor typecheck. make help is stale on this and says so now. - random and openssl_wrapper have selftest() but no benchmark(); crypto.selftest guards on the shape rather than assuming it. - The DriverWorks feature table is regression-tested by the openssl-matrix CI job against real 0.8.5/0.9.2/0.11.1 rocks. openssl_wrapper.selftest() drives injected stand-ins, so it pins the gate's verdict, not the binding's behaviour. - bignum.is_accelerated has a fifth reason string; matching only the four documented ones misses it. - release.yml publishes both crypto.lua and crypto-portable.lua. - ed25519.sign also derives the public key on every call, so the 2.04x needs the public key cached as well as the expanded form. Holding only the expanded form still pays the scalar multiplication. - Add the missing tree entries, correct the CI section, and record that stylua covers src/ only with no .stylua.toml. Trims the design-defence passages; the measured hardware numbers stay. --- CLAUDE.md | 184 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 134 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8c761e3..c6c5b36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,18 +21,24 @@ lua-crypto/ │ ├── bignum.lua # Arbitrary-precision integers; OpenSSL-preferred modexp │ ├── srp.lua # SRP-6a client, RFC 5054 group 15 + SHA-512 (HAP) │ ├── openssl_wrapper.lua # Optional lua-openssl acceleration + graceful fallback +│ ├── annotations.lua # Shared LuaCATS aliases (workspace-wide; not require'd) │ └── utils/ │ ├── init.lua # Utils aggregator (bytes, benchmark) │ ├── bytes.lua # Byte/hex helpers │ └── benchmark.lua # Benchmarking utilities ├── vendor/ │ └── bitn.lua # Vendored lua-bitn (bundled into the single-file build) +├── tools/ +│ └── generate_srp_vectors.py # Regenerates the SRP vectors in srp.lua ├── .github/workflows/ -│ ├── build.yml # CI: lint, test matrix, build +│ ├── build.yml # CI: check, test matrix, openssl-matrix, build │ └── release.yml # Release automation +├── .luarc-typecheck.json # Hardened config for `make typecheck` (see below) +├── .luacheckrc ├── run_tests.sh # Main test runner ├── run_tests_matrix.sh # Multi-version test runner ├── run_benchmarks.sh # Benchmark runner +├── run_benchmarks_matrix.sh └── Makefile # Build automation ``` @@ -44,10 +50,76 @@ make test-sha512 # Run a specific module's self-test make test-matrix # Run across Lua versions make bench # Run benchmarks make format # Format code with stylua +make format-check # Verify formatting without rewriting make lint # Lint with luacheck +make typecheck # Check LuaCATS annotations with lua-language-server +make check # Full gate: format-check + lint + typecheck make build # Build single-file distribution (build/crypto.lua) ``` +`make check` is the gate CI runs. `make all` is `format lint test build`, which +rewrites `src/` in place and runs neither `format-check` nor `typecheck` — it is +not a substitute for `check`. `make help` is stale on this point: it describes +`check` as "format-check and lint" and omits typecheck. + +### typecheck + +`make typecheck` runs lua-language-server against the committed +`.luarc-typecheck.json`. It catches what luacheck does not: undefined or duplicate +`@alias`, returns that disagree with `@return`, fields missing from a `@class`. + +It checks the whole repo rather than `src/` alone. `@alias` resolves +workspace-wide — which is why `src/crypto/annotations.lua` is never `require`d by +anything and still matters — so a narrower scope gives *different* findings, not +fewer. + +`--configpath` displaces each individual setting the committed config declares, +not each table, so a knob is only closed if it is named. Suppression keys can be +enumerated from the diagnostics read sites — the paths below are inside a +lua-language-server source checkout, not this repo: + + grep -rhoE "config\.get\([^,]*, *'Lua\.[A-Za-z.]+'" \ + script/core/diagnostics/*.lua script/provider/diagnostic.lua + +Treat that as a floor, not a ceiling: its file scope is the shape of its blind +spot. Anything that gates file loading or rewrites source before analysis is read +elsewhere, and has to be enumerated separately from `script/plugin.lua` and +`script/workspace.lua`. `runtime.plugin` is the case that matters, and the grep +cannot surface it by construction. `check_worker.lua` does `require 'plugin'`, so +an `OnSetText` returning an empty edit blanks every file in the repo and the check +passes having analysed nothing. + +Two traps decide how a key gets declared, and neither is answered by the key's +type: + +Empty is not always inert, so read the read site. `neededFileStatus` and +`groupFileStatus` are per-key lookups that fall back to the built-in default, so +`{}` leaves behaviour untouched. `enableScheme` defaults to `["file"]`, which makes +`[]` silence the whole check exactly as a local `["git"]` would. It is declared as +`["file"]` for that reason. + +Immunity is per-code, so one planted probe does not measure a key. +`check_worker.lua`'s `downgrade_checks_to_opened` force-overwrites only codes whose +default status is `Any`, leaving everything defaulting to `Opened` under local +control, which is precisely the type-check group this gate exists for. An +`undefined-global` probe therefore reports `neededFileStatus` as inert while a +`return-type-mismatch` probe shows it silencing the check. Probe with a type-check +code. + +Any setting `.luarc-typecheck.json` does not name, under any table, is still +reachable from a local `.luarc.json`. Re-run both enumerations when upgrading the +server rather than assuming the list stayed complete. + +`vendor/` is both a `library` and an `ignoreDir`: `ignoreDir` keeps the vendored +code from being diagnosed here, `library` keeps its definitions resolvable. +`runtime.version` is pinned to LuaJIT because that is what Control4 runs. + +The server version is not pinned locally. `install-deps` takes whatever Homebrew +has while CI pins 3.19.0, so compare the version the target prints if a local +result disagrees with CI. + +Part of `check`, so CI enforces it. + ## Architecture ### Module design @@ -58,7 +130,9 @@ make build # Build single-file distribution (build/crypto.lua) - `crypto.version()` — build-injected version string - `crypto.selftest()` — runs every module's known-answer self-test -Each primitive module also exposes its own `selftest()` and `benchmark()`. +Each primitive module also exposes its own `selftest()`. Most, but not all, also +expose `benchmark()` — `random` and `openssl_wrapper` have none. `crypto.selftest()` +guards with `type(mod.selftest) == "function"` rather than assuming the shape. ### OpenSSL acceleration and fallback @@ -70,8 +144,9 @@ binding is unavailable or lacks the required feature. Enable via Routing is deliberate and hardwired, not runtime-probed: -- **Hashing and AEAD** (SHA-256/512, ChaCha20-Poly1305, AES-GCM, …) → - OpenSSL-preferred, pure-Lua fallback. +- **Hashing and AEAD** (SHA-256/512, BLAKE2, ChaCha20, ChaCha20-Poly1305, + AES-GCM) → OpenSSL-preferred, pure-Lua fallback. Standalone `poly1305` is not + among them and is never accelerated. - **x25519 / x448** → **always pure Lua.** The shipped `lua-openssl` builds (e.g. Control4 DriverWorks, lua-openssl 0.8.x) can import Curve25519/448 keys but cannot perform the raw scalar-multiplication/derive operations — a naive @@ -80,8 +155,7 @@ Routing is deliberate and hardwired, not runtime-probed: Verified on a Control4 controller (2026-08-07, lua-openssl 0.8.5): `pkey.new("ed25519")` fails outright, and while `pkey.read()` of an RFC 8410 DER succeeds, `sign()` on the resulting key returns nil. `Feature.OKP` exists - and probes for a completed sign/verify round-trip, so the unavailability is - declarative and checkable rather than a comment; ed25519 does not consult it + and probes for a completed sign/verify round-trip; ed25519 does not consult it because the answer is "never route" on every build we ship to. This is not an artefact of Control4's old binding: lua-openssl 0.11.1 over OpenSSL 3.6.3 rejects `pkey.new("ed25519")` with `not support ed25519!!!!` as well @@ -101,12 +175,16 @@ lazily, once. A version floor alone cannot answer "was this build compiled with carry a probe that exercises the real call and checks the answer. Known Control4 DriverWorks facts (measured 2026-08-07, lua-openssl 0.8.5 over -OpenSSL 3.1.4), pinned as regression cases in `openssl_wrapper.selftest()`: +OpenSSL 3.1.4). These are regression-tested by the `openssl-matrix` CI job, which +installs the real `0.8.5-1`, `0.9.2-2` and `0.11.1-1` rocks and asserts the feature +map against each. `openssl_wrapper.selftest()` also covers this ground, but it +drives *injected stand-in bindings* rather than a real one, so it pins the gate's +verdict for a version string rather than the binding's behaviour: | Feature | Supported | Note | |---|---|---| | `AAD` | no | 0.8.5 < 0.9.2 floor. On 0.8.5 `cipher:update(aad, true)` ignores the flag and encrypts the AAD as plaintext, so ChaCha20-Poly1305 correctly stays pure Lua there. | -| `BN` | yes | Modular exponentiation is spelled `powmod`, not `mod_exp`. | +| `BN` | yes | Modular exponentiation is spelled `powmod` on this build. The wrapper accepts either spelling (`bn.powmod` or `bn.mod_exp`) and both are pinned. | | `KDF` | yes | `kdf.derive` present, currently unused. | | `OKP` | no | `pkey.new("ed25519")` fails. | | `RANDOM` | yes | `random` and `rand_status` both present, `rand_status()` true, `random(n, true)` returns n distinct bytes (measured 2026-08-08). `random(0)` and negative lengths raise. | @@ -118,9 +196,8 @@ an explicit call is the only thing that turns acceleration on. **Call `crypto.use_openssl(true)` during driver init, before any crypto work and before any feature query.** -This has its own heading because the failure mode is quiet and expensive rather -than obvious. A driver that skips the call runs pure Lua everywhere -- measured -on the dev controller (2026-08-08), SHA-512 over 1 KiB goes from 0.016 ms to +A driver that skips the call runs pure Lua everywhere -- measured on the dev +controller (2026-08-08), SHA-512 over 1 KiB goes from 0.016 ms to 82.05 ms, a factor of about 5,100. `openssl_wrapper.features()` reports every feature false on hardware where four of the five are true, because it reports what `get` would return rather than what the binding can do. And @@ -138,6 +215,8 @@ if not ok then -- "the lua-openssl binding is not available on this host" -- "the lua-openssl binding does not support BN" -- "the lua-openssl binding failed bignum's multi-limb known-answer check ..." + -- "OpenSSL modular exponentiation is unavailable" (fallback when the + -- wrapper has no more specific reason; do not match only the four above) error("SRP unusable: " .. why) end ``` @@ -173,10 +252,8 @@ silently falling back to something that will hang the controller. That accessor delegates to `bignum.is_accelerated()`, which applies both of the conditions `mod_exp` applies: the feature gate *and* the multi-limb known-answer -check on the binding. Reading `openssl_wrapper.features().BN` directly is the -wrong precondition twice over -- it reports true for a binding `bignum` has -already decided not to trust, and it makes a HAP caller reach through another -module's internals for a property `crypto.srp` owns. +check on the binding. `openssl_wrapper.features().BN` is not a substitute — it +reports true for a binding `bignum` has already decided not to trust. ### What the asymmetric primitives cost on Control4 @@ -200,48 +277,40 @@ execute, which lands in driver startup and is cheap enough to ignore. Two consequences for anything building HAP on top of this: -- **Expand the long-term key once.** `sign_expanded` is 2.04x faster than - `sign`, and `expand_private_key` is free at this resolution, so a controller - that re-signs with the same key on every connection should hold the expanded - form. A full Pair-Verify is two scalar multiplications, one sign and one - verify: `2(0.459) + 0.786 + 1.583 = 3.29 s`. That is workable only with a - persistent session, so the cost is paid once per connection rather than once - per app launch. -- **Do not run the chain synchronously.** These block the Lua thread, and a - single 1.583 s verify is a long time to hold it -- long enough to starve other - drivers, which is the same failure the unaccelerated `mod_exp` above produces. - Pair-Verify almost certainly needs its steps spread across timer callbacks. - That is a driver concern rather than a library one, but it follows directly - from these numbers. +- **Cache the expanded key _and_ the public key.** `sign_expanded` is 2.04x faster + than `sign`, but `expand_private_key` is free at this resolution, so the 0.816 s + difference is not the expansion. `ed25519.sign` also derives the public key on + every call — `pt_scalarbase` + `pt_pack` — before delegating to + `sign_expanded(expanded, public_key, message)`. A caller that holds only the + expanded form still pays that scalar multiplication and sees none of the 2.04x. + Hold both. A full Pair-Verify is two scalar multiplications, one sign and one + verify: `2(0.459) + 0.786 + 1.583 = 3.29 s`, which is workable only with a + persistent session. +- **Do not run the chain synchronously.** A single 1.583 s verify holds the Lua + thread long enough to starve other drivers, the same failure the unaccelerated + `mod_exp` produces above. Pair-Verify needs its steps spread across timer + callbacks. ### Randomness is a capability, not an optimisation `crypto.random` returns cryptographically secure bytes or raises. There is no -weak fallback anywhere in the library: `math.random` is C `rand()` on 5.1 and -LuaJIT, and on 5.4+ seeding it from the clock actively downgrades a generator the -runtime had already seeded well, so the old -`math.randomseed(os.time() + os.clock() * 1000000)` idiom was worse than making -no call at all. At driver startup it was worth roughly 20 bits. +weak fallback anywhere in the library: the `math.randomseed(os.time() + +os.clock() * 1000000)` idiom was worth roughly 20 bits at driver startup, and on +5.4+ it actively downgrades a generator the runtime had already seeded well. Sources in order: `openssl.random(n, true)` behind `Feature.RANDOM`, then `/dev/urandom`, then failure. Both are live on a Control4 controller -- the 0.8.5 binding's RNG works, and `/dev/urandom` is readable from inside the driver sandbox (measured 2026-08-08). -Two design points worth not re-litigating: - -- It resolves through `openssl_wrapper.get_ungated`, not `get`. Everywhere else - the acceleration flag picks between two *correct* implementations; here it - would pick between a correct one and a broken one, so the flag does not gate - it. -- The probe verifies rather than assumes: `rand_status()` must be true and two - full-width draws must differ. A binding whose RNG is stubbed out or wired to a - constant passes a version check and a length check but not that one. +It resolves through `openssl_wrapper.get_ungated`, not `get`, so the acceleration +flag does not gate it: everywhere else the flag picks between two correct +implementations, here it would pick between a correct one and a broken one. Callers holding their own entropy are unaffected -- `ed25519.sign(seed, ...)`, -`x25519.diffie_hellman(priv, ...)` and `session:set_private(a)` all take key -material directly. Hosts with neither source can install one with -`random.set_source(fn)`. +`x25519.diffie_hellman(priv, ...)` and the SRP `Session:set_private(a)` +(`src/crypto/srp.lua`) all take key material directly. Hosts with neither source +can install one with `random.set_source(draw, name)`. ### bitn dependency @@ -277,14 +346,29 @@ injected from git tags during release. ## CI/CD -- **build.yml**: on push/PR to main — stylua format check, luacheck lint, test - matrix (Lua 5.1–5.4, LuaJIT 2.0/2.1), and single-file build. -- **release.yml**: on version tags (`v*`) — builds and publishes a release with - the `crypto.lua` artifact. +- **build.yml**: on push/PR to `main` or `master`. + - `check` — `make check` (format-check, luacheck, typecheck against + lua-language-server 3.19.0). + - `test` — `make test-all` across Lua 5.1–5.4 and LuaJIT 2.0/2.1. + - `openssl-matrix` — installs the real `lua-openssl` rocks `0.8.5-1`, `0.9.2-2` + and `0.11.1-1` and runs the suite against each with acceleration both on and + off. This is the only place the DriverWorks feature table above is checked + against a real binding rather than a stand-in, so a change to + `openssl_wrapper` that passes `check` and `test` can still fail here. + - `build` — single-file distributions. +- **release.yml**: on version tags (`v*`) — publishes **both** `build/crypto.lua` + and `build/crypto-portable.lua`. (build.yml's artifact upload keeps only + `crypto.lua`; the release carries both.) ## Code Style - 2-space indentation - 120 column width - Double quotes preferred -- LuaDoc annotations for all public functions +- LuaCATS annotations on public functions + +stylua and luacheck are invoked with these as CLI flags from the Makefile and +cover `src/` only; there is no `.stylua.toml`. `.luacheckrc` sets +`max_line_length = false`, so the 120-column limit is enforced by stylua alone. +`typecheck` deliberately runs over the whole repo instead — see above. +Annotations are validated where they exist but are nowhere required.