diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9367d6a..ab80b3e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,8 +74,101 @@ jobs: run: | make test-all + # The Lua matrix above proves portability across interpreters, but every leg of + # it runs with no lua-openssl installed, so it only ever exercises the pure-Lua + # paths. This job covers the other axis: which *binding* is present. It matters + # because capability differs between builds in ways a version number alone does + # not describe, and because the ChaCha20-Poly1305 OpenSSL branch was unreachable + # for its whole existence while the pure-Lua tests stayed green. + # + # The expected feature maps below are measurements, not guesses: + # 0.8.5 is the binding shipped on Control4 DriverWorks (verified on a + # controller 2026-08-07, over OpenSSL 3.1.4). Its cipher:update(aad, + # true) ignores the AAD flag and encrypts the AAD as plaintext, which + # reproduces identically on this runner's OpenSSL, so the failure is a + # property of the binding version rather than of the controller. + # 0.9.2 is the exact floor at which AAD starts working. + # 0.11.1 is current upstream. + # Ed25519 is unsupported on all three, which is why crypto.ed25519 never routes + # to OpenSSL. + openssl-matrix: + needs: check + runs-on: ubuntu-latest + strategy: + # Report every binding's result; one failing version must not mask another. + fail-fast: false + matrix: + include: + - rock: '0.8.5-1' + label: 'Control4 DriverWorks build' + expect_aad: 'false' + - rock: '0.9.2-2' + label: 'AAD support floor' + expect_aad: 'true' + - rock: '0.11.1-1' + label: 'current upstream' + expect_aad: 'true' + + name: lua-openssl ${{ matrix.rock }} (${{ matrix.label }}) + steps: + - uses: actions/checkout@v4 + + - name: Setup Lua + uses: leafo/gh-actions-lua@v11 + with: + luaVersion: '5.4' + + - name: Setup LuaRocks + uses: leafo/gh-actions-luarocks@v5 + with: + luarocksVersion: "3.12.2" + + - name: Install lua-openssl ${{ matrix.rock }} + run: luarocks install openssl ${{ matrix.rock }} + + - name: Assert the feature gate resolves as measured + env: + EXPECT_AAD: ${{ matrix.expect_aad }} + run: | + lua -e ' + package.path = "./src/?.lua;./src/?/init.lua;./vendor/?.lua;" .. package.path + local crypto = require("crypto") + crypto.use_openssl(true) + local openssl = require("openssl") + local features = crypto.openssl_wrapper.features() + local expected = { + AAD = os.getenv("EXPECT_AAD") == "true", + BN = true, + KDF = true, + -- No lua-openssl build we ship against can sign with an Ed25519 + -- key, so this must stay false; if it ever flips, the routing + -- decision in crypto.ed25519 deserves revisiting. + OKP = false, + } + local ok = true + print("binding reports version: " .. tostring((openssl.version()))) + for _, name in ipairs({ "AAD", "BN", "KDF", "OKP" }) do + local got, want = features[name] == true, expected[name] + print(string.format(" %-4s got=%s want=%s", name, tostring(got), tostring(want))) + if got ~= want then + ok = false + end + end + if not ok then + error("feature gate did not resolve as measured for this binding") + end + ' + + - name: Run tests with OpenSSL acceleration enabled + env: + CRYPTO_USE_OPENSSL: '1' + run: make test-all + + - name: Run tests with OpenSSL acceleration disabled + run: make test-all + build: - needs: test + needs: [test, openssl-matrix] runs-on: ubuntu-latest name: Build Combined Module steps: diff --git a/CLAUDE.md b/CLAUDE.md index ce703e1..8c761e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,11 @@ lua-crypto/ │ ├── aes_gcm.lua # AES-GCM AEAD │ ├── x25519.lua # Curve25519 Diffie-Hellman (always pure Lua) │ ├── x448.lua # Curve448 Diffie-Hellman (always pure Lua) +│ ├── ed25519.lua # Ed25519 signatures, RFC 8032 (always pure Lua) +│ ├── hkdf.lua # HKDF-Extract/Expand, RFC 5869 (SHA-256/512) +│ ├── random.lua # CSPRNG bytes; raises rather than returning weak ones +│ ├── 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 │ └── utils/ │ ├── init.lua # Utils aggregator (bytes, benchmark) @@ -71,6 +76,172 @@ Routing is deliberate and hardwired, not runtime-probed: (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 "use OpenSSL if present" path would silently fail, so these never route to it. +- **ed25519** → **always pure Lua**, same policy and for the same reason. + 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 + 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 + (measured 2026-08-08), so the pure-Lua route is the current state of the + binding rather than a workaround for one embedded build. +- **hkdf** → no route of its own. It is a thin layer over `hmac_sha256` / + `hmac_sha512`, which already prefer OpenSSL, so it inherits acceleration + transitively. `Feature.KDF` is declared but unused; see the rationale comment + in `hkdf.lua`. + +### Feature gating + +`openssl_wrapper.get(Feature.X)` returns the module only when the binding +satisfies feature X. Features declare `{ min_version, probe? }` and resolve +lazily, once. A version floor alone cannot answer "was this build compiled with +`openssl.bn`?", so capabilities that vary between builds of the same version +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()`: + +| 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`. | +| `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. | + +### Nothing is accelerated until `crypto.use_openssl(true)` is called + +`CRYPTO_USE_OPENSSL` is not set in the DriverWorks environment, so on Control4 +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 +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 +`srp.is_accelerated()` returns false, so a HAP caller following the guidance +below fails closed on a controller that was perfectly capable. + +Both readings produce the same `false` and need opposite responses, so +`bignum.is_accelerated()` and `srp.is_accelerated()` return a second value +naming which one it is: + +```lua +local ok, why = crypto.srp.is_accelerated() +if not ok then + -- "OpenSSL acceleration is not enabled: call crypto.use_openssl(true) ..." + -- "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 ..." + error("SRP unusable: " .. why) +end +``` + +Fail closed on the boolean, log the reason. The first string is a one-line fix; +the rest are not. + +### Why bignum must use OpenSSL on Control4 + +Measured on a controller (2026-08-07), pure-Lua `mod_exp` over RFC 5054 group 15, +timed across exponents of 4/8/16/32/64/96 bits and fitted (R² = 0.9962): + +``` +ms = 4982 + 669 * exponent_bits +``` + +| exponent | pure Lua | `bn.powmod` | ratio | +|---|---|---|---| +| 256-bit (SRP `A = g^a mod N`) | ~176 s (2.9 min) | 5.08 ms | ~34,000x | +| 3072-bit (full width) | ~2061 s (34 min) | 60.92 ms | ~34,000x | + +A full Pair-Setup client does three exponentiations, so pure Lua is on the order +of **15 minutes** against **under 0.05 s** with OpenSSL. Worse, it is not merely +slow: a direct attempt at a 256-bit exponent blocked the driver's Lua thread long +enough that the driver was reset before finishing, and while blocked the +controller stopped servicing other drivers' Lua too. + +So on Control4 the pure-Lua path is a **correctness reference and a portability +fallback, not a shippable code path**. `Feature.BN` resolving true is effectively +a precondition for HAP pairing on this hardware. Anything built on `crypto.srp` +should check **`crypto.srp.is_accelerated()`** and fail loudly rather than +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. + +### What the asymmetric primitives cost on Control4 + +X25519 and Ed25519 are always pure Lua (see the routing list above), so unlike +SRP their cost does not move with the acceleration flag. These are what HAP +Pair-Verify is built out of, measured with `build/crypto-portable.lua` on the +dev controller (2026-08-08, OS 4.2.1.757028-res, lua-openssl 0.8.5). Every +figure was taken in the same call as its RFC vector check, so they time a +correct implementation rather than a fast wrong one. + +| operation | per op | vector | +|---|---|---| +| X25519 scalar multiplication | 0.459 s | RFC 7748 6.1 | +| Ed25519 sign, cold from seed | 1.602 s | RFC 8032 7.1 | +| Ed25519 sign, pre-expanded | 0.786 s | RFC 8032 7.1 | +| Ed25519 `expand_private_key` | < 0.001 s | | +| Ed25519 verify | 1.583 s | | + +Loading the 519 KB portable build costs 0.065 s to parse plus 0.009 s to +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. + +### 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. + +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. + +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)`. ### bitn dependency diff --git a/Makefile b/Makefile index 65ef503..ef17773 100644 --- a/Makefile +++ b/Makefile @@ -156,6 +156,27 @@ lint: exit 1; \ fi +# Type-check annotations with the Lua language server +# +# `install-deps` already installs lua-language-server, but nothing ran it, so +# the LuaCATS annotations were only checked by whoever happened to have the +# server wired into their editor. It catches a different class of problem than +# luacheck -- duplicate or undefined `@alias`, return counts that disagree with +# `@return`, fields missing from a `@class` -- so it is a separate target. +# +# Deliberately NOT part of `check`: a handful of type-narrowing and +# deliberate-bad-argument diagnostics remain, and turning CI red on those is a +# separate decision from making the check runnable. +.PHONY: typecheck +typecheck: + @if command -v lua-language-server >/dev/null 2>&1; then \ + echo "Running lua-language-server..."; \ + lua-language-server --check "$(CURDIR)/src" --checklevel=Warning --logpath="$(CURDIR)/build/luals"; \ + else \ + echo "lua-language-server not found. Install with: make install-deps"; \ + exit 1; \ + fi + .PHONY: check check: format-check lint @echo "Code quality checks complete." @@ -190,6 +211,7 @@ help: @echo " make format - Format code with stylua" @echo " make format-check - Check code formatting" @echo " make lint - Lint code with luacheck" + @echo " make typecheck - Check annotations with lua-language-server" @echo "" @echo "Setup:" @echo " make install-deps - Install development dependencies" diff --git a/README.md b/README.md index e381b68..0283dc0 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,40 @@ portable enough to run inside sandboxed Lua hosts such as Control4 DriverWorks. | MAC | Poly1305 | `crypto.poly1305` | | Diffie-Hellman | X25519 | `crypto.x25519` | | Diffie-Hellman | X448 | `crypto.x448` | +| Signature | Ed25519 (RFC 8032) | `crypto.ed25519` | +| Key derivation | HKDF over SHA-256/SHA-512 (RFC 5869) | `crypto.hkdf` | +| PAKE | SRP-6a client, RFC 5054 group 15 + SHA-512 | `crypto.srp` | +| Big integers | Arbitrary precision, OpenSSL-preferred modexp | `crypto.bignum` | +| Randomness | CSPRNG bytes, or a hard failure | `crypto.random` | + +## Randomness + +Every private key this library generates comes from `crypto.random`, which draws +from `openssl.random(n, true)` or `/dev/urandom` and **raises when it can find +neither**. It will not fall back to `math.random`, which is not a CSPRNG on any +Lua implementation: on 5.1 and LuaJIT it is C `rand()`, and on 5.4+ seeding it +from the clock actively downgrades a generator the runtime had already seeded +well. A driver that fails to pair is a bug report; a driver that pairs with a +guessable long-term identity is a compromised device that looks healthy. + +Unlike the accelerated primitives, this is not gated on `crypto.use_openssl()` — +that flag chooses between two correct implementations elsewhere, and it must not +be able to select a weaker source of entropy here. + +```lua +local random = require("crypto.random") + +if not random.available() then + -- Neither source exists on this host; supply one rather than pairing weakly. + random.set_source(function(n) return my_platform_csprng(n) end, "platform") +end + +local seed = random.bytes(32) +``` + +Callers that already have entropy can keep bypassing generation entirely: +`ed25519.sign(seed, ...)`, `x25519.diffie_hellman(priv, ...)` and +`session:set_private(a)` all take key material directly and are unchanged. ## OpenSSL acceleration @@ -44,9 +78,46 @@ crypto.use_openssl(true) -- safe: falls back to pure Lua when unavailable Acceleration is applied per primitive and degrades gracefully: if the binding is missing, or a particular operation is not supported by it, the pure-Lua path is -used instead. **The Curve25519/448 Diffie-Hellman functions always use the -pure-Lua implementations** regardless of this flag, because the shipped -`lua-openssl` builds cannot perform the raw X25519/X448 operations. +used instead. **The Curve25519/448 Diffie-Hellman functions and Ed25519 signing +always use the pure-Lua implementations** regardless of this flag: the shipped +`lua-openssl` builds cannot perform the raw X25519/X448 operations, and no +tested build can sign with an Ed25519 key (0.11.1 over OpenSSL 3.6.3 raises +`not support ed25519`). + +Capability is resolved per feature rather than assumed from a version number, +because builds of the same version differ. You can inspect what the current host +actually supports: + +```lua +crypto.use_openssl(true) +local features = crypto.openssl_wrapper.features() +-- { AAD = false, BN = true, KDF = true, OKP = false, RANDOM = true } -- e.g. Control4 +``` + +For SRP the difference is not a matter of taste, so `crypto.srp` exposes its own +precondition instead of making callers read another module's feature map: + +```lua +if not crypto.srp.is_accelerated() then + -- 3072-bit modexp in pure Lua is ~176 s for the client exponent on a Control4 + -- controller, against ~5 ms via bn.powmod, and Lua execution is serialised + -- across drivers there, so it blocks the whole controller. Refuse instead. +end +``` + +Measured behaviour of the bindings covered by CI: + +| Binding | AAD | BN | KDF | OKP | Notes | +| --- | --- | --- | --- | --- | --- | +| 0.8.5 (Control4 DriverWorks) | no | yes | yes | no | `cipher:update(aad, true)` ignores the flag and encrypts the AAD as plaintext, so AEAD stays pure Lua here | +| 0.9.2 | yes | yes | yes | no | first version where AAD works | +| 0.11.1 (current upstream) | yes | yes | yes | no | | + +`RANDOM` is true on all three. It is resolved outside the `use_openssl` flag, +since entropy is a capability rather than an optimisation. + +Note that `bn`'s modular exponentiation is named `powmod`, not `mod_exp`, on +every build tested. ## Installation @@ -96,6 +167,28 @@ local alice_priv, alice_pub = crypto.x25519.generate_keypair() local bob_priv, bob_pub = crypto.x25519.generate_keypair() local shared_a = crypto.x25519.diffie_hellman(alice_priv, bob_pub) local shared_b = crypto.x25519.diffie_hellman(bob_priv, alice_pub) +``` + +```lua +-- Ed25519 signatures +local seed, public_key = crypto.ed25519.generate_keypair() +local signature = crypto.ed25519.sign(seed, "message") +assert(crypto.ed25519.verify(public_key, "message", signature)) + +-- When signing repeatedly with one long-term key, expand it once. This skips +-- both the SHA-512 of the seed and the public-key scalar multiplication. +local expanded = crypto.ed25519.expand_private_key(seed) +local sig2 = crypto.ed25519.sign_expanded(expanded, public_key, "message") +``` + +```lua +-- HKDF key derivation +local prk = crypto.hkdf.extract("sha512", "Control-Salt", shared_secret) +local read_key = crypto.hkdf.expand("sha512", prk, "ClientEncrypt-main", 32) +local write_key = crypto.hkdf.expand("sha512", prk, "ServerEncrypt-main", 32) + +-- or in one call +local key = crypto.hkdf.hkdf_sha512(salt, shared_secret, info, 32) assert(shared_a == shared_b) ``` diff --git a/run_tests.sh b/run_tests.sh index ed78dfd..313010a 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -10,7 +10,8 @@ # ./run_tests.sh sha256 x25519 # Run only sha256 and x25519 # # Available modules: sha256, sha512, blake2, chacha20, chacha20_poly1305, -# poly1305, aes_gcm, x25519, x448 +# poly1305, aes_gcm, hkdf, random, bignum, srp, x25519, x448, +# ed25519, openssl_wrapper set -e # Exit on any error @@ -48,7 +49,7 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) lua_path="$script_dir/?.lua;$script_dir/?/init.lua;$script_dir/src/?.lua;$script_dir/src/?/init.lua;$script_dir/vendor/?.lua;$LUA_PATH" # Parse command line arguments to determine which modules to run -all_modules=("sha256" "sha512" "blake2" "chacha20" "chacha20_poly1305" "poly1305" "aes_gcm" "x25519" "x448") +all_modules=("sha256" "sha512" "blake2" "chacha20" "chacha20_poly1305" "poly1305" "aes_gcm" "hkdf" "random" "bignum" "srp" "x25519" "x448" "ed25519" "openssl_wrapper") default_modules=("${all_modules[@]}") modules_to_run=("$@") @@ -137,8 +138,14 @@ run_selftest "ChaCha20" "chacha20" "crypto.chacha20" run_selftest "ChaCha20-Poly1305" "chacha20_poly1305" "crypto.chacha20_poly1305" run_selftest "Poly1305" "poly1305" "crypto.poly1305" run_selftest "AES-GCM" "aes_gcm" "crypto.aes_gcm" +run_selftest "HKDF" "hkdf" "crypto.hkdf" +run_selftest "Randomness" "random" "crypto.random" +run_selftest "Bignum" "bignum" "crypto.bignum" +run_selftest "SRP-6a" "srp" "crypto.srp" run_selftest "X25519" "x25519" "crypto.x25519" run_selftest "X448" "x448" "crypto.x448" +run_selftest "Ed25519" "ed25519" "crypto.ed25519" +run_selftest "OpenSSL gating" "openssl_wrapper" "crypto.openssl_wrapper" passed_count=${#passed_modules[@]} failed_count=${#failed_modules[@]} diff --git a/src/crypto/annotations.lua b/src/crypto/annotations.lua new file mode 100644 index 0000000..7585b0d --- /dev/null +++ b/src/crypto/annotations.lua @@ -0,0 +1,32 @@ +--- Shared LuaCATS type aliases. +--- +--- This file contains no code and is never `require`d. It exists so that types +--- used by more than one module have exactly one definition: the Lua language +--- server resolves `@alias` workspace-wide, so a name defined in two modules is +--- a `duplicate-doc-alias` warning even when both definitions agree, while a +--- name used but defined nowhere is `undefined-doc-name` at every use site. +--- +--- Because nothing requires it, `amalg` (which traces an actual run of +--- `crypto.init`) does not bundle it and the shipped builds are unchanged. +--- +--- Do not add runtime code here. Types used by a single module stay in that +--- module, next to what they describe. + +-- ---------------------------------------------------------------------------- +-- Curve25519 field arithmetic +-- ---------------------------------------------------------------------------- +-- x25519 and ed25519 compute over the same prime field p = 2^255 - 19 with the +-- same 16-limb representation, so these describe one type used by two modules +-- rather than two similar ones. + +--- @alias FieldElement integer[] 16-element array (indices 1-16) representing a field element +--- @alias ProductArray integer[] 31-element array (indices 1-31) for multiplication products + +-- ---------------------------------------------------------------------------- +-- 64-bit values on 32-bit-safe runtimes +-- ---------------------------------------------------------------------------- +-- Lua 5.1/5.2 have no 64-bit integers and 5.3+ `//` semantics differ, so the +-- 64-bit primitives (SHA-512, BLAKE2b) carry 64-bit quantities as a pair of +-- 32-bit halves. Used by `utils/bytes`, `sha512` and `blake2`. + +--- @alias Int64HighLow { [1]: integer, [2]: integer } 64-bit value as {high, low} 32-bit halves diff --git a/src/crypto/bignum.lua b/src/crypto/bignum.lua new file mode 100644 index 0000000..d48a37c --- /dev/null +++ b/src/crypto/bignum.lua @@ -0,0 +1,1804 @@ +--- @module "crypto.bignum" +--- Arbitrary-precision unsigned integer arithmetic, sized for 3072-bit modular +--- exponentiation (SRP-6a / RFC 5054 group 15). +--- +--- The hot path is `mod_exp`, which uses Montgomery multiplication plus a +--- sliding-window exponentiation. See the notes above `mod_exp_montgomery` for +--- why those two were chosen over the naive "square and multiply with a full +--- divmod after every step". +--- +--- Representation +--- -------------- +--- A big number is a plain Lua array of `LIMB_BITS`-bit limbs, least +--- significant first, normalized so that the most significant limb is non-zero. +--- Zero is the empty table. This is the *canonical* representation and the only +--- type any public function ever accepts or returns. +--- +--- @class crypto.bignum +local bignum = {} + +local utils = require("crypto.utils") +local bytes = utils.bytes +local benchmark_op = utils.benchmark.benchmark_op +local openssl_wrapper = require("crypto.openssl_wrapper") + +-- Local references for performance +local floor = math.floor +local string_byte = string.byte +local string_char = string.char +local string_format = string.format +local string_rep = string.rep +local string_sub = string.sub +local string_upper = string.upper +local table_concat = table.concat + +-- ============================================================================ +-- CONSTANTS +-- ============================================================================ + +--- @alias BigNum integer[] Little-endian array of 24-bit limbs; {} is zero. + +-- Limb width. +-- +-- OVERFLOW BOUND (the whole portability story lives here). On Lua 5.1, 5.2 and +-- LuaJIT every number is an IEEE double, so integers are exact only up to 2^53. +-- A 3072-bit operand is n = 3072/24 = 128 limbs, and a column of a schoolbook +-- multiply accumulates n products each < 2^(2*24), which needs +-- 2*24 + ceil(log2(128)) = 48 + 7 = 55 bits: too wide. So this module never +-- lets a column accumulate. Every multiply-accumulate loop propagates its carry +-- on *every* iteration, which caps the accumulator at +-- +-- INVARIANT: t <= (BASE-1) + (BASE-1)^2 + (BASE-1) = BASE^2 - 1 = 2^48 - 1 +-- +-- independently of the operand length, leaving 5 bits (a factor of 32) of +-- headroom below 2^53. The three shapes that must respect it are `mul_raw`, +-- `mont_mul` and the multiply-subtract step of `divmod_raw`; each carries +-- inline and each is annotated below. +-- +-- 24 bits also divides evenly into both bytes (3) and hex digits (6), which +-- removes all cross-boundary bit fiddling from the conversion routines. +local LIMB_BITS = 24 +local LIMB_BYTES = 3 +local LIMB_HEX = 6 +local BASE = 16777216 -- 2^24 +local BASE_HALF = 8388608 -- 2^23 +--- Exact reciprocal of BASE: a power of two, so `x * INV_BASE` is bit-identical +--- to `x / BASE` for every integer x < 2^53, but avoids a hardware divide in +--- the inner loops. +local INV_BASE = 1 / 16777216 + +--- Powers of two up to the limb width, for bit extraction without 5.3+ shifts. +local POW2 = {} +for i = 0, LIMB_BITS do + POW2[i] = 2 ^ i +end + +--- Largest Lua number that survives `from_number` exactly on a double-only VM. +local MAX_SAFE_NUMBER = 9007199254740992 -- 2^53 + +--- Known-answer vector used to verify an OpenSSL binding before trusting it. +--- Deliberately multi-limb so a binding that mis-parses long hex is caught. +local ACCEL_CHECK_BASE = "c0ffee0123456789abcdef0123456789abcdef0123456789ab" +local ACCEL_CHECK_EXP = "1234567890abcdef1234" +local ACCEL_CHECK_MOD = "fffffffffffffffffffffffffffffffffffffffeffffee37" +local ACCEL_CHECK_RESULT = "e2f14166c00a4c44a535f801534727158dcfce58a82049b7" + +-- ============================================================================ +-- INTERNAL: CORE HELPERS +-- ============================================================================ + +--- Strip high-order zero limbs so the value is canonical. +--- @param t BigNum Limb array, modified in place +--- @return BigNum t The same array, normalized +local function normalize(t) + local n = #t + while n > 0 and t[n] == 0 do + t[n] = nil + n = n - 1 + end + return t +end + +--- Create a zero-filled limb array of a fixed length. +--- @param n integer Number of limbs +--- @return integer[] limbs +local function zeros(n) + local t = {} + for i = 1, n do + t[i] = 0 + end + return t +end + +--- Copy a big number into a fixed-length limb array, zero-extended. +--- @param a BigNum Source value +--- @param n integer Target limb count (must be >= #a) +--- @return integer[] limbs +local function pad(a, n) + if #a > n then + error("bignum: value does not fit in " .. n .. " limbs") + end + local t = {} + for i = 1, n do + t[i] = a[i] or 0 + end + return t +end + +--- Compare two normalized limb arrays. +--- @param a BigNum First value +--- @param b BigNum Second value +--- @return integer cmp -1 if a < b, 0 if equal, 1 if a > b +local function compare_raw(a, b) + local na, nb = #a, #b + if na ~= nb then + return na < nb and -1 or 1 + end + for i = na, 1, -1 do + local ai, bi = a[i], b[i] + if ai ~= bi then + return ai < bi and -1 or 1 + end + end + return 0 +end + +--- Add two normalized limb arrays. +--- @param a BigNum First addend +--- @param b BigNum Second addend +--- @return BigNum sum +local function add_raw(a, b) + local na, nb = #a, #b + if nb > na then + -- Keep `a` as the longer operand so the loop below can index `b` sparsely. + -- `nb` is deliberately not reassigned: it is not read after this point. + a, b, na = b, a, nb + end + local r = {} + local carry = 0 + for i = 1, na do + local x = a[i] + (b[i] or 0) + carry + if x >= BASE then + r[i] = x - BASE + carry = 1 + else + r[i] = x + carry = 0 + end + end + if carry ~= 0 then + r[na + 1] = carry + end + return r +end + +--- Subtract b from a, assuming a >= b. +--- @param a BigNum Minuend +--- @param b BigNum Subtrahend (must not exceed a) +--- @return BigNum difference +local function sub_raw(a, b) + local r = {} + local borrow = 0 + local na = #a + for i = 1, na do + local x = a[i] - (b[i] or 0) - borrow + if x < 0 then + r[i] = x + BASE + borrow = 1 + else + r[i] = x + borrow = 0 + end + end + if borrow ~= 0 then + error("bignum: subtraction would be negative") + end + return normalize(r) +end + +--- Multiply two normalized limb arrays (schoolbook, operand scanning). +--- The carry is propagated on every inner iteration, so no accumulator ever +--- exceeds BASE^2 - 1 = 2^48 - 1 regardless of operand length. +--- @param a BigNum First factor +--- @param b BigNum Second factor +--- @return BigNum product +local function mul_raw(a, b) + local na, nb = #a, #b + if na == 0 or nb == 0 then + return {} + end + local r = zeros(na + nb) + for i = 1, nb do + local bi = b[i] + if bi ~= 0 then + local carry = 0 + local k = i - 1 + for j = 1, na do + -- <= (BASE-1) + (BASE-1)^2 + (BASE-1) = BASE^2 - 1 + local x = r[k + j] + a[j] * bi + carry + carry = floor(x * INV_BASE) + r[k + j] = x - carry * BASE + end + local idx = k + na + 1 + while carry ~= 0 do + local x = r[idx] + carry + carry = floor(x * INV_BASE) + r[idx] = x - carry * BASE + idx = idx + 1 + end + end + end + return normalize(r) +end + +--- Divide by a single-limb divisor. +--- @param a BigNum Dividend +--- @param d integer Divisor, 1 <= d < BASE +--- @return BigNum quotient +--- @return integer remainder +local function divmod_small(a, d) + local q = {} + local r = 0 + for i = #a, 1, -1 do + -- r < d < BASE so x < BASE^2 + local x = r * BASE + a[i] + local qi = floor(x / d) + q[i] = qi + r = x - qi * d + end + return normalize(q), r +end + +--- Full division with remainder (Knuth algorithm D, base 2^24). +--- @param a BigNum Dividend +--- @param b BigNum Divisor (must be non-zero) +--- @return BigNum quotient +--- @return BigNum remainder +local function divmod_raw(a, b) + local n = #b + if n == 0 then + error("bignum: division by zero") + end + if compare_raw(a, b) < 0 then + local r = {} + for i = 1, #a do + r[i] = a[i] + end + return {}, r + end + if n == 1 then + local q, r = divmod_small(a, b[1]) + return q, r == 0 and {} or { r } + end + + -- Normalize so the divisor's top limb is >= BASE/2. + local shift = 1 + local top = b[n] + while top < BASE_HALF do + top = top * 2 + shift = shift * 2 + end + + local v = {} + local carry = 0 + for i = 1, n do + local x = b[i] * shift + carry + carry = floor(x * INV_BASE) + v[i] = x - carry * BASE + end + + local m = #a - n + local u = {} + carry = 0 + for i = 1, #a do + local x = a[i] * shift + carry + carry = floor(x * INV_BASE) + u[i] = x - carry * BASE + end + u[#a + 1] = carry + + local vn, vn1 = v[n], v[n - 1] + local q = {} + for j = m, 0, -1 do + -- num < BASE^2, so the estimate stays exact in a double. + local num = u[j + n + 1] * BASE + u[j + n] + local qhat = floor(num / vn) + local rhat = num - qhat * vn + if qhat >= BASE then + qhat = BASE - 1 + rhat = num - qhat * vn + end + while rhat < BASE and qhat * vn1 > rhat * BASE + u[j + n - 1] do + qhat = qhat - 1 + rhat = rhat + vn + end + + -- Multiply and subtract; borrow carried every iteration keeps p < BASE^2. + local borrow = 0 + for i = 1, n do + local p = qhat * v[i] + borrow + local phigh = floor(p * INV_BASE) + local x = u[j + i] - (p - phigh * BASE) + if x < 0 then + x = x + BASE + phigh = phigh + 1 + end + u[j + i] = x + borrow = phigh + end + local x = u[j + n + 1] - borrow + if x < 0 then + -- qhat was one too large (probability ~2/BASE); add the divisor back. + u[j + n + 1] = x + BASE + qhat = qhat - 1 + local c = 0 + for i = 1, n do + local y = u[j + i] + v[i] + c + if y >= BASE then + u[j + i] = y - BASE + c = 1 + else + u[j + i] = y + c = 0 + end + end + u[j + n + 1] = (u[j + n + 1] + c) % BASE + else + u[j + n + 1] = x + end + q[j + 1] = qhat + end + + -- Undo the normalization on the remainder. + local r = {} + local rem = 0 + for i = n, 1, -1 do + local x = rem * BASE + u[i] + local ri = floor(x / shift) + r[i] = ri + rem = x - ri * shift + end + + return normalize(q), normalize(r) +end + +--- Read one bit of an exponent, without 5.3+ shift operators. +--- @param e BigNum Value to inspect +--- @param i integer Zero-based bit index +--- @return integer bit 0 or 1 +local function get_bit(e, i) + local limb = e[floor(i / LIMB_BITS) + 1] + if limb == nil then + return 0 + end + return floor(limb / POW2[i % LIMB_BITS]) % 2 +end + +--- Bit length of a normalized limb array. +--- @param a BigNum Value +--- @return integer bits 0 for zero +local function bit_length_raw(a) + local n = #a + if n == 0 then + return 0 + end + local bits = (n - 1) * LIMB_BITS + local top = a[n] + while top > 0 do + bits = bits + 1 + top = floor(top / 2) + end + return bits +end + +-- ============================================================================ +-- INTERNAL: MONTGOMERY ARITHMETIC +-- ============================================================================ + +--- Modular inverse of an odd limb modulo BASE, by Newton iteration. +--- Every step is reduced mod BASE so no intermediate exceeds BASE^2. +--- @param m0 integer Odd value, 1 <= m0 < BASE +--- @return integer n0 (-m0^-1) mod BASE +local function mont_n0(m0) + local inv = 1 + -- Each round doubles the number of correct bits: 1 -> 2 -> 4 -> 8 -> 16 -> 32. + for _ = 1, 5 do + local t = (m0 * inv) % BASE + t = (2 - t) % BASE + inv = (inv * t) % BASE + end + if (m0 * inv) % BASE ~= 1 then + error("bignum: modulus is not odd") + end + return (BASE - inv) % BASE +end + +--- Build the Montgomery context for an odd modulus > 1. +--- +--- The context uses one limb more than the modulus needs (`s = #m + 1`) so that +--- 4*m < BASE^s always holds. That is the precondition under which CIOS output +--- stays below 2*m and a single conditional subtraction suffices; without the +--- spare limb a 3072-bit modulus (which is *exactly* 128 limbs wide) would +--- overflow the accumulator's top word. +--- +--- @param m BigNum Odd modulus, m > 1 +--- @return table ctx Fields: s, mp, n0, r1 (R mod m), r2 (R^2 mod m) +local function mont_context(m) + local s = #m + 1 + local mp = pad(m, s) + local n0 = mont_n0(mp[1]) + + -- R = BASE^s + local r = zeros(s + 1) + r[s + 1] = 1 + local _, r1 = divmod_raw(normalize(r), m) + local _, r2 = divmod_raw(mul_raw(r1, r1), m) + + return { s = s, mp = mp, n0 = n0, r1 = pad(r1, s), r2 = pad(r2, s), t = zeros(s + 2) } +end + +--- Montgomery multiplication: out = a * b * R^-1 mod m (CIOS). +--- +--- `out` may alias `a` or `b`: every read of the operands happens before the +--- single write-back at the end. +--- +--- @param ctx table Context from `mont_context` +--- @param a integer[] Left operand, exactly ctx.s limbs +--- @param b integer[] Right operand, exactly ctx.s limbs +--- @param out integer[] Destination, exactly ctx.s limbs +--- @return integer[] out +local function mont_mul(ctx, a, b, out) + local s = ctx.s + local mp = ctx.mp + local n0 = ctx.n0 + local t = ctx.t + + for i = 1, s + 2 do + t[i] = 0 + end + + for i = 1, s do + local bi = b[i] + local c = 0 + for j = 1, s do + -- <= (BASE-1) + (BASE-1)^2 + (BASE-1) = BASE^2 - 1 = 2^48 - 1 + local x = t[j] + a[j] * bi + c + c = floor(x * INV_BASE) + t[j] = x - c * BASE + end + local x = t[s + 1] + c + c = floor(x * INV_BASE) + t[s + 1] = x - c * BASE + t[s + 2] = c + + local mi = (t[1] * n0) % BASE + -- t[1] + mi*mp[1] is a multiple of BASE by construction of n0. + x = t[1] + mi * mp[1] + c = floor(x * INV_BASE) + for j = 2, s do + x = t[j] + mi * mp[j] + c + c = floor(x * INV_BASE) + t[j - 1] = x - c * BASE + end + x = t[s + 1] + c + c = floor(x * INV_BASE) + t[s] = x - c * BASE + t[s + 1] = t[s + 2] + c + end + + -- Result is < 2*m: one conditional subtraction brings it into range. + local subtract = t[s + 1] ~= 0 + if not subtract then + local cmp = 0 -- 0 means "equal to m so far", so subtract + for j = s, 1, -1 do + local tv, mv = t[j], mp[j] + if tv ~= mv then + cmp = tv > mv and 1 or -1 + break + end + end + subtract = cmp >= 0 + end + + if subtract then + local borrow = 0 + for j = 1, s do + local x = t[j] - mp[j] - borrow + if x < 0 then + out[j] = x + BASE + borrow = 1 + else + out[j] = x + borrow = 0 + end + end + else + for j = 1, s do + out[j] = t[j] + end + end + return out +end + +--- Pick a sliding-window width for an exponent of the given bit length. +--- @param bits integer Exponent bit length +--- @return integer w Window width +local function window_width(bits) + if bits <= 23 then + return 1 + elseif bits <= 79 then + return 3 + elseif bits <= 239 then + return 4 + end + return 5 +end + +--- Modular exponentiation via Montgomery multiplication + sliding window. +--- +--- Why: the textbook "square and multiply, then divmod" costs a full Knuth +--- division per step, and division is several times more expensive than the +--- multiplication it reduces. Montgomery replaces every reduction with a second +--- multiply-accumulate pass over the same limbs, so a modular square costs +--- 2*s^2 limb products and no division at all. On top of that a sliding window +--- of width w replaces ~bits/2 multiplications with ~bits/(w+1) of them: for a +--- 3072-bit exponent that is roughly 3072 squarings + ~512 multiplications +--- instead of 3072 + ~1536. +--- +--- Requires an odd modulus greater than 1, which the RFC 5054 safe primes are. +--- +--- @param base BigNum Base +--- @param exp BigNum Exponent, must be non-zero +--- @param m BigNum Odd modulus, m > 1 +--- @return BigNum result base^exp mod m +local function mod_exp_montgomery(base, exp, m) + local ctx = mont_context(m) + local s = ctx.s + + local _, reduced = divmod_raw(base, m) + local x = pad(reduced, s) + mont_mul(ctx, x, ctx.r2, x) -- x -> Montgomery form + + local bits = bit_length_raw(exp) + local w = window_width(bits) + + -- Odd powers x^1, x^3, ... x^(2^w - 1), all in Montgomery form. + local odd = { [1] = x } + if w > 1 then + local x2 = mont_mul(ctx, x, x, zeros(s)) + for k = 3, POW2[w] - 1, 2 do + odd[k] = mont_mul(ctx, odd[k - 2], x2, zeros(s)) + end + end + + local acc = pad(ctx.r1, s) -- Montgomery representation of 1 + local i = bits - 1 + while i >= 0 do + if get_bit(exp, i) == 0 then + mont_mul(ctx, acc, acc, acc) + i = i - 1 + else + local l = i - w + 1 + if l < 0 then + l = 0 + end + while get_bit(exp, l) == 0 do + l = l + 1 + end + local value = 0 + for k = i, l, -1 do + value = value * 2 + get_bit(exp, k) + end + for _ = 1, i - l + 1 do + mont_mul(ctx, acc, acc, acc) + end + mont_mul(ctx, acc, odd[value], acc) + i = l - 1 + end + end + + -- Leave the Montgomery domain: acc * 1 * R^-1. + local one = zeros(s) + one[1] = 1 + mont_mul(ctx, acc, one, acc) + return normalize(acc) +end + +--- Slow, obvious reference exponentiation: bitwise square-and-multiply with a +--- full division after every step. Kept as the correctness oracle that +--- `selftest()` cross-checks the Montgomery path against, and as the fallback +--- for the even moduli Montgomery cannot handle. +--- @param base BigNum Base +--- @param exp BigNum Exponent +--- @param m BigNum Modulus, m > 0 +--- @return BigNum result base^exp mod m +local function mod_exp_reference(base, exp, m) + local _, result = divmod_raw({ 1 }, m) + local _, b = divmod_raw(base, m) + for i = bit_length_raw(exp) - 1, 0, -1 do + local _, sq = divmod_raw(mul_raw(result, result), m) + result = sq + if get_bit(exp, i) == 1 then + local _, pr = divmod_raw(mul_raw(result, b), m) + result = pr + end + end + return result +end + +-- ============================================================================ +-- INTERNAL: OPENSSL ACCELERATION +-- ============================================================================ + +-- The canonical representation is ALWAYS the pure-Lua limb table. OpenSSL is +-- used only as an internal accelerator *inside* mod_exp: operands are converted +-- into openssl.bn handles (big-endian bytes in via bn.text), the modexp runs +-- there, and the result is converted straight back to a limb table (hex out via +-- bn.tohex). That is exactly the conversion path Feature.BN probes. +-- +-- Rationale: crypto.use_openssl() can be toggled at runtime, so if handles were +-- sometimes userdata and sometimes tables, a toggle mid-flight would produce +-- mixed-type operands and silent breakage. Keeping one canonical type makes the +-- accelerator invisible to callers -- results are identical with and without +-- it, and selftest() asserts exactly that. The conversion cost is a few hundred +-- bytes moved in and out versus a 3072-bit modexp, so it is noise. + +--- Binding whose behaviour has already been verified, and the verdict. +local _verified_binding = nil +local _verified_ok = false + +--- Resolve this build's modular-exponentiation entry point. +--- Control4's lua-openssl 0.8.5 spells it `powmod`; other builds use `mod_exp`. +--- @param bnlib table The openssl.bn table +--- @return function powmod +local function bn_powmod(bnlib) + if type(bnlib.powmod) == "function" then + return bnlib.powmod + end + return bnlib.mod_exp +end + +--- Build an openssl.bn handle from a canonical big number. +--- `bn.text` takes big-endian bytes; zero is passed as a single zero byte +--- rather than the empty string, which not every build accepts. +--- @param bnlib table The openssl.bn table +--- @param value BigNum Value to convert +--- @return any handle Opaque openssl.bn value +local function bn_from_bignum(bnlib, value) + if #value == 0 then + return bnlib.text("\0") + end + return bnlib.text(bignum.to_bytes(value)) +end + +--- Run one modular exponentiation through the OpenSSL binding. +--- @param openssl table Loaded lua-openssl module +--- @param base BigNum Base +--- @param exp BigNum Exponent +--- @param m BigNum Modulus +--- @return BigNum result Canonical limb table +local function mod_exp_openssl(openssl, base, exp, m) + local bnlib = openssl.bn + local result = bn_powmod(bnlib)(bn_from_bignum(bnlib, base), bn_from_bignum(bnlib, exp), bn_from_bignum(bnlib, m)) + local hex = bnlib.tohex(result) + if type(hex) ~= "string" or hex == "" then + error("bignum: openssl bn.tohex did not return hex") + end + return bignum.from_hex(hex) +end + +--- Decide whether a binding may be trusted for real work. +--- +--- `Feature.BN` proves the three entry points exist and round-trip on a +--- single-digit value; it cannot prove that this build's constructor parses a +--- long hex string the way this module writes it. So the first time a given +--- binding table is seen, run one multi-limb known-answer vector through it and +--- cache the verdict. A binding that fails silently falls back to pure Lua +--- rather than returning wrong answers. +--- +--- @param openssl table Loaded lua-openssl module +--- @return boolean usable +local function accelerator_ready(openssl) + if _verified_binding == openssl then + return _verified_ok + end + _verified_binding = openssl + _verified_ok = false + local ok, result = pcall( + mod_exp_openssl, + openssl, + bignum.from_hex(ACCEL_CHECK_BASE), + bignum.from_hex(ACCEL_CHECK_EXP), + bignum.from_hex(ACCEL_CHECK_MOD) + ) + if ok and type(result) == "table" and compare_raw(result, bignum.from_hex(ACCEL_CHECK_RESULT)) == 0 then + _verified_ok = true + end + return _verified_ok +end + +-- ============================================================================ +-- PUBLIC INTERFACE: CONSTRUCTION AND CONVERSION +-- ============================================================================ + +--- Create a big number from a big-endian byte string of any length. +--- @param str string Big-endian bytes ("" is zero) +--- @return BigNum bn +function bignum.from_bytes(str) + local n = #str + local r = {} + local k = 0 + local i = n + while i >= 1 do + local b0 = string_byte(str, i) + local b1 = i >= 2 and string_byte(str, i - 1) or 0 + local b2 = i >= 3 and string_byte(str, i - 2) or 0 + k = k + 1 + r[k] = b0 + b1 * 256 + b2 * 65536 + i = i - LIMB_BYTES + end + return normalize(r) +end + +--- Serialize a big number to big-endian bytes. +--- Without `length` the encoding is minimal, so zero serializes to "" and +--- `from_bytes(to_bytes(x)) == x` for every x. With `length` the result is +--- left-padded with zero bytes to exactly that many bytes, which is what SRP +--- needs when hashing values modulo N. +--- @param bn BigNum Value +--- @param length? integer Exact output length in bytes +--- @return string str Big-endian bytes +function bignum.to_bytes(bn, length) + local parts = {} + for k = #bn, 1, -1 do + local v = bn[k] + local b2 = floor(v / 65536) + parts[#parts + 1] = string_char(b2, floor(v / 256) % 256, v % 256) + end + local raw = table_concat(parts) + local first = 1 + local total = #raw + while first <= total and string_byte(raw, first) == 0 do + first = first + 1 + end + local trimmed = string_sub(raw, first) + if length == nil then + return trimmed + end + if #trimmed > length then + error("bignum: value needs " .. #trimmed .. " bytes, cannot fit in " .. length) + end + return string_rep("\0", length - #trimmed) .. trimmed +end + +--- Parse a hexadecimal string (either case, any length, no prefix). +--- @param hex string Hex digits ("" or "0" is zero) +--- @return BigNum bn +function bignum.from_hex(hex) + local r = {} + local k = 0 + local i = #hex + while i >= 1 do + local j = i - LIMB_HEX + 1 + if j < 1 then + j = 1 + end + local chunk = string_sub(hex, j, i) + local value = tonumber(chunk, 16) + if value == nil then + error("bignum: invalid hex digits '" .. chunk .. "'") + end + k = k + 1 + r[k] = value + i = j - 1 + end + return normalize(r) +end + +--- Serialize to lowercase hex with no leading zeros ("0" for zero). +--- @param bn BigNum Value +--- @return string hex +function bignum.to_hex(bn) + local n = #bn + if n == 0 then + return "0" + end + local parts = { string_format("%x", bn[n]) } + for k = n - 1, 1, -1 do + parts[#parts + 1] = string_format("%06x", bn[k]) + end + return table_concat(parts) +end + +--- Create a big number from a non-negative Lua integer. +--- @param n integer Value in [0, 2^53] +--- @return BigNum bn +function bignum.from_number(n) + if type(n) ~= "number" or n < 0 or n ~= floor(n) then + error("bignum: from_number requires a non-negative integer") + end + if n > MAX_SAFE_NUMBER then + error("bignum: from_number is limited to 2^53; use from_hex or from_bytes") + end + local r = {} + local k = 0 + while n > 0 do + k = k + 1 + r[k] = n % BASE + n = floor(n / BASE) + end + return r +end + +--- The value zero. +--- @return BigNum bn +function bignum.zero() + return {} +end + +--- The value one. +--- @return BigNum bn +function bignum.one() + return { 1 } +end + +--- Duplicate a big number; the copy shares no state with the original. +--- @param bn BigNum Value +--- @return BigNum copy +function bignum.copy(bn) + local r = {} + for i = 1, #bn do + r[i] = bn[i] + end + return r +end + +-- ============================================================================ +-- PUBLIC INTERFACE: INSPECTION +-- ============================================================================ + +--- Test whether a value is zero. +--- @param bn BigNum Value +--- @return boolean is_zero +function bignum.is_zero(bn) + return #bn == 0 +end + +--- Number of significant bits (0 for zero). +--- @param bn BigNum Value +--- @return integer bits +function bignum.bit_length(bn) + return bit_length_raw(bn) +end + +--- Number of bytes in the minimal big-endian encoding (0 for zero). +--- @param bn BigNum Value +--- @return integer count +function bignum.byte_length(bn) + local bits = bit_length_raw(bn) + return floor((bits + 7) / 8) +end + +--- Three-way comparison. +--- @param a BigNum First value +--- @param b BigNum Second value +--- @return integer cmp -1 if a < b, 0 if a == b, 1 if a > b +function bignum.compare(a, b) + return compare_raw(a, b) +end + +--- Equality test. +--- @param a BigNum First value +--- @param b BigNum Second value +--- @return boolean equal +function bignum.equals(a, b) + return compare_raw(a, b) == 0 +end + +-- ============================================================================ +-- PUBLIC INTERFACE: ARITHMETIC +-- ============================================================================ + +--- Addition. +--- @param a BigNum First addend +--- @param b BigNum Second addend +--- @return BigNum sum +function bignum.add(a, b) + return add_raw(a, b) +end + +--- Subtraction. Errors when b > a, since values are unsigned. +--- @param a BigNum Minuend +--- @param b BigNum Subtrahend +--- @return BigNum difference +function bignum.sub(a, b) + return sub_raw(a, b) +end + +--- Multiplication. +--- @param a BigNum First factor +--- @param b BigNum Second factor +--- @return BigNum product +function bignum.mul(a, b) + return mul_raw(a, b) +end + +--- Division with remainder. +--- @param a BigNum Dividend +--- @param b BigNum Divisor (must be non-zero) +--- @return BigNum quotient floor(a / b) +--- @return BigNum remainder a - quotient * b +function bignum.divmod(a, b) + return divmod_raw(a, b) +end + +--- Remainder of a divided by b. +--- @param a BigNum Dividend +--- @param b BigNum Modulus (must be non-zero) +--- @return BigNum remainder +function bignum.mod(a, b) + local _, r = divmod_raw(a, b) + return r +end + +--- Modular addition. +--- @param a BigNum First addend +--- @param b BigNum Second addend +--- @param m BigNum Modulus (must be non-zero) +--- @return BigNum result (a + b) mod m +function bignum.mod_add(a, b, m) + local _, r = divmod_raw(add_raw(a, b), m) + return r +end + +--- Modular subtraction, always returning a non-negative residue. +--- SRP computes B - k*g^x, where the subtraction can go negative, so this wraps +--- rather than erroring. +--- @param a BigNum Minuend +--- @param b BigNum Subtrahend +--- @param m BigNum Modulus (must be non-zero) +--- @return BigNum result (a - b) mod m +function bignum.mod_sub(a, b, m) + local _, ra = divmod_raw(a, m) + local _, rb = divmod_raw(b, m) + if compare_raw(ra, rb) >= 0 then + return sub_raw(ra, rb) + end + return sub_raw(add_raw(ra, m), rb) +end + +--- Modular multiplication. +--- @param a BigNum First factor +--- @param b BigNum Second factor +--- @param m BigNum Modulus (must be non-zero) +--- @return BigNum result (a * b) mod m +function bignum.mod_mul(a, b, m) + local _, r = divmod_raw(mul_raw(a, b), m) + return r +end + +--- Modular exponentiation -- the hot path for SRP-6a. +--- +--- Dispatch order: a verified OpenSSL binding if acceleration is enabled, +--- otherwise Montgomery + sliding window for odd moduli, otherwise the slow +--- reference path. All three return identical values. +--- +--- @param base BigNum Base +--- @param exp BigNum Exponent +--- @param m BigNum Modulus (must be non-zero) +--- @return BigNum result base^exp mod m +function bignum.mod_exp(base, exp, m) + if #m == 0 then + error("bignum: mod_exp modulus must be non-zero") + end + if #m == 1 and m[1] == 1 then + return {} + end + if #exp == 0 then + return { 1 } + end + + local openssl = openssl_wrapper.get(openssl_wrapper.Feature.BN) + if openssl ~= nil and accelerator_ready(openssl) then + local ok, result = pcall(mod_exp_openssl, openssl, base, exp, m) + if ok and type(result) == "table" then + return result + end + end + + if m[1] % 2 == 1 then + return mod_exp_montgomery(base, exp, m) + end + -- Montgomery needs an odd modulus; even moduli are not used by SRP. + return mod_exp_reference(base, exp, m) +end + +--- Whether `mod_exp` will use the OpenSSL backend. +--- +--- Answers the question callers actually have -- "will a 3072-bit exponentiation +--- finish in milliseconds or in minutes?" -- so it applies the same two +--- conditions `mod_exp` does: acceleration enabled with a binding that passes +--- `Feature.BN`, *and* a binding that reproduces the multi-limb known-answer +--- vector. Checking the feature gate alone would report true for a binding this +--- module has already decided not to trust. +--- +--- The verdict is not static: it changes with `crypto.use_openssl()`. +--- +--- A false verdict comes with the reason, because the two common causes need +--- opposite responses and are otherwise indistinguishable: a host that cannot +--- accelerate has to be designed around, while a caller that never enabled +--- acceleration just has to make one call. The reason is always present when +--- `accelerated` is false. +--- +--- @return boolean accelerated True if OpenSSL will handle modular exponentiation +--- @return string|nil reason Why it will not, when it will not +function bignum.is_accelerated() + local openssl = openssl_wrapper.get(openssl_wrapper.Feature.BN) + if openssl == nil then + return false, + openssl_wrapper.unavailable_reason(openssl_wrapper.Feature.BN) or "OpenSSL modular exponentiation is unavailable" + end + if not accelerator_ready(openssl) then + return false, + "the lua-openssl binding failed bignum's multi-limb known-answer check, " + .. "so it is not trusted for modular exponentiation" + end + return true +end + +-- ============================================================================ +-- TEST VECTORS AND VALIDATION +-- ============================================================================ + +-- Every expected value below was produced independently with CPython's +-- arbitrary-precision integers (`python3 -c "print(pow(g, x, N))"` and friends) +-- and committed here as a literal. + +--- RFC 5054 Appendix A / RFC 3526 group 15: the 3072-bit safe prime N. +--- Its generator is g = 5. +local RFC5054_N_HEX = table.concat({ + "ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74", + "020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f1437", + "4fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7ed", + "ee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf05", + "98da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb", + "9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3b", + "e39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf695581718", + "3995497cea956ae515d2261898fa051015728e5a8aaac42dad33170d04507a33", + "a85521abdf1cba64ecfb850458dbef0a8aea71575d060c7db3970f85a6e1e4c7", + "abf5ae8cdb0933d71e8c94e04a25619dcee3d2261ad2ee6bf12ffa06d98a0864", + "d87602733ec86a64521f2b18177b200cbbe117577a615d6c770988c0bad946e2", + "08e24fa074e5ab3143db5bfce0fd108e4b82d120a93ad2caffffffffffffffff", +}) +local SRP_EXP_A_HEX = "60975527035cf2ad1989806f0407210bc81edc04e2762a56afd529ddda2d4393" +local SRP_RESULT_A_HEX = table.concat({ + "fab6f5d2615d1e323512e7991cc37443f487da604ca8c9230fcb04e541dce628", + "0b27ca4680b0374f179dc3bdc7553fe62459798c701ad864a91390a28c93b644", + "adbf9c00745b942b79f9012a21b9b78782319d83a1f8362866fbd6f46bfc0ddb", + "2e1ab6e4b45a9906b82e37f05d6f97f6a3eb6e182079759c4f6847837b62321a", + "c1b4fa68641fcb4bb98dd697a0c73641385f4bab25b793584cc39fc8d48d4bd8", + "67a9a3c10f8ea12170268e34fe3bbe6ff89998d60da2f3e4283cbec1393d52af", + "724a57230c604e9fbce583d7613e6bffd67596ad121a8707eec4694495703368", + "6a155f644d5c5863b48f61bdbf19a53eab6dad0a186b8c152e5f5d8cad4b0ef8", + "aa4ea5008834c3cd342e5e0f167ad04592cd8bd279639398ef9e114dfaaab919", + "e14e850989224ddd98576d79385d2210902e9f9b1f2d86cfa47ee244635465f7", + "1058421a0184be51dd10cc9d079e6f1604e7aa9b7cf7883c7d4ce12b06ebe160", + "81e23f27a231d18432d7d1bb55c28ae21ffcf005f57528d15a88881bb3bbb7fe", +}) +local SRP_EXP_B_HEX = table.concat({ + "e487cb59d31ac550471e81f00f6928e01dda08e974a004f49e61f5d105284d20", + "3fbb0e0e1e0b1a1c9d8e7f60514243342a1b0c9f8e7d6c5b4a39281706f5e4d3", + "c2b1a09f8e7d6c5b4a3928170615243342b1a09f8e7d6c5b4a39281706152433", + "42b1a09f8e7d6c5b4a3928170615243342b1a09f8e7d6c5b4a39281706152433", + "42b1a09f8e7d6c5b4a3928170615243342b1a09f8e7d6c5b4a39281706152433", + "42b1a09f8e7d6c5b4a3928170615243342b1a09f8e7d6c5b4a39281706152433", + "42b1a09f8e7d6c5b4a3928170615243342b1a09f8e7d6c5b4a39281706152433", + "42b1a09f8e7d6c5b4a3928170615243342b1a09f8e7d6c5b4a3928170615243f", + "42b1a09f8e7d6c5b4a3928170615243342b1a09f8e7d6c5b4a39281706152433", + "42b1a09f8e7d6c5b4a3928170615243342b1a09f8e7d6c5b4a39281706152433", + "42b1a09f8e7d6c5b4a3928170615243342b1a09f8e7d6c5b4a39281706152433", + "42b1a09f8e7d6c5b4a3928170615243342b1a09f8e7d6c5b4a3928170615243f", +}) +local SRP_RESULT_B_HEX = table.concat({ + "1e9606e73774d51f84e1eefb8e8ce7b07a0e204b76dc57d55968d4159a32f760", + "4e36da94747fc9f995e920741169aa810cc25e4a4b7df654bb8e48275e063d6c", + "744f1e3bfcd18b84e97a69d39ac775b466cc1b0928f0ee55d79e05329a942ca6", + "af50f660d4af6129e9e378119843836ea8b7c0aa1035d6e33ebf3defd0512ebf", + "c6dbb3e111a8630cf6d444e98e23fd5219747dcc537da5742fe3b3262a61e6b4", + "6fa328d398d104bc3735f5e0f883b4c8fe1b3ee5b77ba2222b9b4cff974e060d", + "9b52bc56a4edaf6b88a149b06eace7a40f68348fd84a28c95a524da2846cd738", + "4ce58188f44f27c279b5e1279754d82794a67db88ed67a44eddb189094f7830d", + "4748cecd898d44387558e41293561752775c44360fc5b57fa386470b2019da88", + "0637a5443c2165f23b2f914b33b601edc8e2aff5dd916387e4c186d495a790da", + "5f4b2e82b2a881479a4c819086e5fd284fe5144c2cc259d7b5c5c085430268ea", + "664826f9fdb4e4183b8613b772c50a56a97f1a13c0471d562151b41fcfbd3559", +}) +local SRP_BASE_OVER_N_HEX = table.concat({ + "ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74", + "020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f1437", + "4fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7ed", + "ee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf05", + "98da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb", + "9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3b", + "e39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf695581718", + "3995497cea956ae515d2261898fa051015728e5a8aaac42dad33170d04507a33", + "a85521abdf1cba64ecfb850458dbef0a8aea71575d060c7db3970f85a6e1e4c7", + "abf5ae8cdb0933d71e8c94e04a25619dcee3d2261ad2ee6bf12ffa06d98a0864", + "d87602733ec86a64521f2b18177b200cbbe117577a615d6c770988c0bad946e2", + "08e24fa074e5ab3143db5bfce0fd108e4b82d120a93ad2cb0000000000000006", +}) +local SRP_RESULT_OVER_HEX = table.concat({ + "34fd4d3beb508957d7a1b34394835595739fcebae187bc7cf18d8dbee1f8cdd7", + "fbb20f4d870b7f9f9d13dad25006445975cf81c4fffa0bd26bb399c2b235657c", + "a7aae7ac6bc6a78cba6ddfd1cc971505b6acbdbbadf8c8346006590588209cbe", + "3b21c7141166f53da71b9d739c785aa1c722ff5ad6bf0d8556e1836570c3e62d", + "b1fe8c715b84867c31faa7621b315130de124f38b250052217658a2535e0ca89", + "014101aec9e8ca705a37b8956dc5a629f80a542c4f02e01150ddd44228c76ab6", + "77e930f3875e57c5cf87a3ac3b16f94ed2427aa5c4028cb5b7dfd568057f1285", + "46a28a0cdbf79120c2a955956c7f4ad6220929ae1f76954d347de3bd57d05e31", + "ca711fa27ab77fc85473d4ba7e89dcca338a0e5c06009edeb5788cc9bc840add", + "6f3f740da521f6b3be5a1ac0e4d1b8acb500ebe95c99cf713b972ee1f6e4bf29", + "af7c5f4b88d5893f2deab5df5cb6e7e3ab516d388a9bcdfda0867b6bc93197b9", + "519e5b25ffa39abb52b18dc6e7e78f601443ae575f1bc59ffc2d86a06af4cf04", +}) +local VEC_X_HEX = "f0e1d2c3b4a5968778695a4b3c2d1e0fdeadbeefcafebabe0123456789abcdef" +local VEC_Y_HEX = "fedcba987654321fedcba9876543210abcdef0123456789" +local VEC_M_HEX = "fffffffffffffffffffffffffffffffeffffffffffffffff" +local VEC_ADD_HEX = "f0e1d2c3b4a59687885725f4c3926131dd8a79884152ecceacf13468acf13578" +local VEC_SUB_HEX = "f0e1d2c3b4a59687687b8ea1b4c7daeddfd1045754aa88ad5555566666666666" +local VEC_MUL_HEX = table.concat({ + "efcfc0c2d5fa2f84db52db751fdba8927fd0cfb89d03a6e23469667bcbb09e04", + "0c96803be0e492284a6a2290002002425d85431fb375de7", +}) +local VEC_QUO_HEX = "f1f51dfdb8e1c16d7" +local VEC_REM_HEX = "4e56a81e2677fc8622ba2a844cc25a8440c2e32a28013e0" +local VEC_MOD_X_HEX = "78695a4b3c2d1e10cf8f91b37fa45145f205182b3e516476" +local VEC_MOD_ADD_HEX = "885725f4c3926132ce6c4c4bf5f883569dd3072c6196cbff" +local VEC_MOD_SUB_HEX = "9784715e4b3825112f4d28e4f6afe0c9b9c8d6d5e4f40312" +local VEC_MOD_MUL_HEX = "c5789ec2c73be1123b996b5bf34a44eba5d1145d374fc548" +local VEC_MOD_EXP_HEX = "e6663770f70809f8c052b35cc00534b8d5b2c013a9d4874c" +local CROSS_CHECK_VECTORS = { + { + base = "502d7ab70ebf0d087251c67d5c934eb0ef4a70d5c51a00ee54f73e1e08903425", + exp = "f35d4f200a780822124a0b081179e3d4a0d35510ab6e959e2c", + mod = "8e99ae1be2d241aeeb84195f9b108f9cf349d985159faa81e4481dfa13d53823", + result = "72dcd8f95d33f62a754cf474d81b6d956f6679ebc5728a1ef6933847d8770e26", + }, + { + base = "25ae403fe777a8a29bdd6a5789cc8bbaab9d2464af1aacb53ae32135898e6bfa", + exp = "80b8f4c2e5ddf71127f4eb3aa7a10564fce9969b05172e80ba", + mod = "9304db81ec231dd86cdebb4eda07e4970efba2ff86aa0709e9a1dcde6d0d33c5", + result = "13449129cb254007fe2810d95a9b437feebc78a31c1e2fd5073ec2ef6b4df5e6", + }, + { + base = "69bb23c104796692e0b335cace9437938c7f88608b5b34eac5ee3bb98f7ffe43", + exp = "29dd6ffbfa8abc5492e855efc01b31186cb4b55dc01dcb6cf0", + mod = "b751b52d96495d031ff4ab3d7784abe057a84ec928c26106bb0412086eed0137", + result = "a654ff028a94c301f72b0aefacd2c5c65499787ee00c5f15a0d022587ad07b99", + }, +} + +--- Run comprehensive self-test with known-answer test vectors +--- +--- Covers conversions, schoolbook arithmetic across limb boundaries, modular +--- arithmetic, and modular exponentiation up to full 3072-bit SRP operands. +--- It also asserts the two invariants this module is built around: that the +--- optimised Montgomery path agrees with the slow reference path, and that the +--- OpenSSL-accelerated path is bit-identical to the pure-Lua one. +--- +--- @return boolean result True if all tests pass, false otherwise +function bignum.selftest() + print("Running bignum test vectors...") + + local from_hex = bignum.from_hex + local to_hex = bignum.to_hex + local from_number = bignum.from_number + local equals = bignum.equals + + local N = from_hex(RFC5054_N_HEX) + local G = from_number(5) + local X = from_hex(VEC_X_HEX) + local Y = from_hex(VEC_Y_HEX) + local M = from_hex(VEC_M_HEX) + + -- Snapshot the OpenSSL state so the accelerator tests cannot leak. + local saved_loaded = package.loaded["openssl"] + local saved_preload = package.preload["openssl"] + + --- Build a stand-in lua-openssl binding. Real lua-openssl is not installed + --- here, so this validates the *routing* and the bytes-in/hex-out conversion, + --- not real OpenSSL arithmetic: the stand-in's modular exponentiation + --- delegates to this module's own slow reference path. + --- @param options table `spelling` is "powmod" or "mod_exp"; `broken` returns wrong + --- answers for everything; `small_only` returns right answers for single-limb + --- operands and wrong ones above that, which is what `Feature.BN`'s probe cannot + --- see and `accelerator_ready`'s multi-limb vector exists to catch + --- @return table binding + --- @return function calls Returns how many times the exponentiation was invoked + local function make_binding(options) + local calls = 0 + local bnlib = {} + bnlib.text = function(raw) + -- Matches lua-openssl's bn.text: big-endian bytes in. + return { value = bignum.from_bytes(raw) } + end + bnlib.tohex = function(handle) + return string_upper(to_hex(handle.value)) + end + bnlib[options.spelling or "powmod"] = function(base, exp, modulus) + calls = calls + 1 + if options.broken then + return { value = from_number(1) } + end + if options.small_only and (#base.value > 1 or #exp.value > 1 or #modulus.value > 1) then + return { value = from_number(1) } + end + return { value = mod_exp_reference(base.value, exp.value, modulus.value) } + end + local binding = { + version = function() + return "0.9.2" + end, + bn = bnlib, + } + return binding, function() + return calls + end + end + + --- Install a stand-in binding (or force acceleration off) and re-probe. + --- @param binding table|nil Stand-in module, or nil for the pure-Lua path + local function install(binding) + package.loaded["openssl"] = binding + if binding == nil then + -- Force require("openssl") to fail regardless of what this host has. + -- Each selftest stubs the same loader independently, which the language + -- server reads as redefining one field; that is the intent here. + --- @diagnostic disable-next-line: duplicate-set-field + package.preload["openssl"] = function() + error("simulated absent binding") + end + else + package.preload["openssl"] = nil + end + openssl_wrapper.use(binding ~= nil) + end + + -- Make the whole run deterministic: the vectors below exercise pure Lua. + install(nil) + + local tests = { + -- ---------------------------------------------------------------- bytes + { + name = "from_bytes/to_bytes round-trip", + test = function() + local raw = bytes.from_hex(VEC_X_HEX) + return bignum.to_bytes(from_hex(VEC_X_HEX)) == raw and to_hex(bignum.from_bytes(raw)) == VEC_X_HEX + end, + }, + { + name = "from_bytes ignores leading zero bytes", + test = function() + return equals(bignum.from_bytes(bytes.from_hex("00000001ff")), from_number(511)) + end, + }, + { + name = "to_bytes pads on the left when length is given", + test = function() + return bignum.to_bytes(from_number(511), 4) == bytes.from_hex("000001ff") + end, + }, + { + name = "to_bytes preserves leading zeros through a round-trip", + test = function() + local padded = bignum.to_bytes(from_hex("0001ff"), 8) + return #padded == 8 and padded == bytes.from_hex("00000000000001ff") + end, + }, + { + name = "to_bytes rejects a length that cannot hold the value", + test = function() + return pcall(bignum.to_bytes, X, 8) == false + end, + }, + { + name = "to_bytes of a 3072-bit value is exactly 384 bytes", + test = function() + return #bignum.to_bytes(N) == 384 and #bignum.to_bytes(N, 384) == 384 + end, + }, + { + name = "zero round-trips through bytes", + test = function() + local zero = bignum.zero() + return bignum.is_zero(zero) + and bignum.to_bytes(zero) == "" + and bignum.to_bytes(zero, 4) == bytes.from_hex("00000000") + and bignum.is_zero(bignum.from_bytes("")) + and bignum.is_zero(bignum.from_bytes(bytes.from_hex("0000"))) + end, + }, + -- ------------------------------------------------------------------ hex + { + name = "from_hex/to_hex round-trip on a 3072-bit value", + test = function() + return to_hex(N) == RFC5054_N_HEX and bignum.bit_length(N) == 3072 + end, + }, + { + name = "from_hex accepts uppercase and odd-length input", + test = function() + return equals(from_hex("ABCDEF"), from_hex("abcdef")) and to_hex(from_hex("fff")) == "fff" + end, + }, + { + name = 'to_hex of zero is "0" and round-trips', + test = function() + return to_hex(bignum.zero()) == "0" and bignum.is_zero(from_hex("0")) and bignum.is_zero(from_hex("")) + end, + }, + { + name = "from_hex rejects non-hex input", + test = function() + return pcall(from_hex, "12zz34") == false + end, + }, + -- --------------------------------------------------------------- number + { + name = "from_number across limb boundaries", + test = function() + return to_hex(from_number(0)) == "0" + and to_hex(from_number(1)) == "1" + and to_hex(from_number(16777215)) == "ffffff" + and to_hex(from_number(16777216)) == "1000000" + and to_hex(from_number(4294967296)) == "100000000" + end, + }, + { + name = "from_number rejects negative, fractional and oversized input", + test = function() + return pcall(from_number, -1) == false + and pcall(from_number, 1.5) == false + and pcall(from_number, 2 ^ 60) == false + end, + }, + -- ----------------------------------------------------------- inspection + { + name = "bit_length and byte_length", + test = function() + return bignum.bit_length(bignum.zero()) == 0 + and bignum.byte_length(bignum.zero()) == 0 + and bignum.bit_length(X) == 256 + and bignum.byte_length(X) == 32 + and bignum.bit_length(Y) == 188 + and bignum.byte_length(Y) == 24 + and bignum.byte_length(N) == 384 + end, + }, + { + name = "compare and equals", + test = function() + return bignum.compare(X, Y) == 1 + and bignum.compare(Y, X) == -1 + and bignum.compare(X, X) == 0 + and equals(X, bignum.copy(X)) + and bignum.compare(from_number(16777216), from_number(16777215)) == 1 + end, + }, + { + name = "copy is independent of the original", + test = function() + local original = from_hex("0102030405060708090a") + local duplicate = bignum.copy(original) + duplicate[1] = 0 + return not equals(original, duplicate) and to_hex(original) == "102030405060708090a" + end, + }, + -- ----------------------------------------------------------- arithmetic + { + name = "add - unequal lengths (known answer)", + test = function() + return to_hex(bignum.add(X, Y)) == VEC_ADD_HEX and to_hex(bignum.add(Y, X)) == VEC_ADD_HEX + end, + }, + { + name = "add - carry propagates across every limb", + test = function() + local all_ones = from_hex("ffffffffffffffffffffffffffffffffffffffffffffffff") + return to_hex(bignum.add(all_ones, from_number(1))) == "1000000000000000000000000000000000000000000000000" + end, + }, + { + name = "add - 3072-bit carry (N + 7)", + test = function() + return to_hex(bignum.add(N, from_number(7))) == SRP_BASE_OVER_N_HEX + end, + }, + { + name = "add - identity with zero", + test = function() + return equals(bignum.add(X, bignum.zero()), X) and equals(bignum.add(bignum.zero(), X), X) + end, + }, + { + name = "sub - unequal lengths (known answer)", + test = function() + return to_hex(bignum.sub(X, Y)) == VEC_SUB_HEX + end, + }, + { + name = "sub - borrow propagates across every limb", + test = function() + local power = from_hex("1000000000000000000000000") + local below = from_hex("ffffffffffffffffffffffff") + return to_hex(bignum.sub(power, below)) == "1" and bignum.is_zero(bignum.sub(X, X)) + end, + }, + { + name = "sub - rejects a negative result", + test = function() + return pcall(bignum.sub, Y, X) == false + end, + }, + { + name = "mul - unequal lengths (known answer)", + test = function() + return to_hex(bignum.mul(X, Y)) == VEC_MUL_HEX and to_hex(bignum.mul(Y, X)) == VEC_MUL_HEX + end, + }, + { + name = "mul - all-ones squared exercises every carry", + test = function() + local all_ones = from_hex("ffffffffffffffffffffffffffffffffffffffffffffffff") + local expected = "fffffffffffffffffffffffffffffffffffffffffffffffe" + .. "000000000000000000000000000000000000000000000001" + return to_hex(bignum.mul(all_ones, all_ones)) == expected + end, + }, + { + name = "mul - power of two times its predecessor", + test = function() + local power = from_hex("1000000000000000000000000") + local below = from_hex("ffffffffffffffffffffffff") + return to_hex(bignum.mul(power, below)) == "ffffffffffffffffffffffff000000000000000000000000" + end, + }, + { + name = "mul - zero and one", + test = function() + return bignum.is_zero(bignum.mul(X, bignum.zero())) + and bignum.is_zero(bignum.mul(bignum.zero(), X)) + and equals(bignum.mul(X, bignum.one()), X) + end, + }, + { + name = "divmod - multi-limb (known answer)", + test = function() + local quotient, remainder = bignum.divmod(X, Y) + return to_hex(quotient) == VEC_QUO_HEX and to_hex(remainder) == VEC_REM_HEX + end, + }, + { + name = "divmod - reconstructs the dividend", + test = function() + local quotient, remainder = bignum.divmod(X, Y) + return equals(bignum.add(bignum.mul(quotient, Y), remainder), X) and bignum.compare(remainder, Y) < 0 + end, + }, + { + name = "divmod - single-limb divisor", + test = function() + local quotient, remainder = bignum.divmod(from_hex("1000000000000000000000001"), from_number(255)) + return equals( + bignum.add(bignum.mul(quotient, from_number(255)), remainder), + from_hex("1000000000000000000000001") + ) and bignum.compare(remainder, from_number(255)) < 0 + end, + }, + { + name = "divmod - divisor larger than dividend", + test = function() + local quotient, remainder = bignum.divmod(Y, X) + return bignum.is_zero(quotient) and equals(remainder, Y) + end, + }, + { + name = "divmod - exact division leaves no remainder", + test = function() + local product = bignum.mul(X, Y) + local quotient, remainder = bignum.divmod(product, Y) + return equals(quotient, X) and bignum.is_zero(remainder) + end, + }, + { + name = "divmod - rejects a zero divisor", + test = function() + return pcall(bignum.divmod, X, bignum.zero()) == false + end, + }, + { + name = "mod - known answer", + test = function() + return to_hex(bignum.mod(X, M)) == VEC_MOD_X_HEX + end, + }, + -- ------------------------------------------------------ modular helpers + { + name = "mod_add - known answer", + test = function() + return to_hex(bignum.mod_add(X, Y, M)) == VEC_MOD_ADD_HEX + end, + }, + { + name = "mod_sub - wraps to a non-negative residue when b > a", + test = function() + return to_hex(bignum.mod_sub(Y, X, M)) == VEC_MOD_SUB_HEX + end, + }, + { + name = "mod_sub - plain difference when a >= b", + test = function() + return equals(bignum.mod_sub(X, Y, M), bignum.mod(bignum.sub(X, Y), M)) + and bignum.is_zero(bignum.mod_sub(X, X, M)) + end, + }, + { + name = "mod_mul - known answer", + test = function() + return to_hex(bignum.mod_mul(X, Y, M)) == VEC_MOD_MUL_HEX + end, + }, + -- ------------------------------------------------------------- mod_exp + { + name = "mod_exp - 4^13 mod 497 = 445", + test = function() + return equals(bignum.mod_exp(from_number(4), from_number(13), from_number(497)), from_number(445)) + end, + }, + { + name = "mod_exp - 2^10 mod 1000 = 24 (even modulus, reference path)", + test = function() + return equals(bignum.mod_exp(from_number(2), from_number(10), from_number(1000)), from_number(24)) + and equals(bignum.mod_exp(from_number(3), from_number(7), from_number(1000)), from_number(187)) + end, + }, + { + name = "mod_exp - exponent zero yields one", + test = function() + return equals(bignum.mod_exp(X, bignum.zero(), M), bignum.one()) + and equals(bignum.mod_exp(bignum.zero(), bignum.zero(), M), bignum.one()) + end, + }, + { + name = "mod_exp - base zero yields zero", + test = function() + return bignum.is_zero(bignum.mod_exp(bignum.zero(), from_number(5), from_number(7))) + and bignum.is_zero(bignum.mod_exp(bignum.zero(), Y, M)) + end, + }, + { + name = "mod_exp - modulus one yields zero", + test = function() + return bignum.is_zero(bignum.mod_exp(X, Y, bignum.one())) + and bignum.is_zero(bignum.mod_exp(from_number(123456789), bignum.zero(), bignum.one())) + end, + }, + { + name = "mod_exp - rejects a zero modulus", + test = function() + return pcall(bignum.mod_exp, X, Y, bignum.zero()) == false + end, + }, + { + name = "mod_exp - 192-bit modulus (known answer)", + test = function() + return to_hex(bignum.mod_exp(X, Y, M)) == VEC_MOD_EXP_HEX + end, + }, + { + name = "mod_exp - RFC 5054 group 15: 5^a mod N, 256-bit exponent", + test = function() + return to_hex(bignum.mod_exp(G, from_hex(SRP_EXP_A_HEX), N)) == SRP_RESULT_A_HEX + end, + }, + { + name = "mod_exp - RFC 5054 group 15: 5^b mod N, full 3072-bit exponent", + test = function() + return to_hex(bignum.mod_exp(G, from_hex(SRP_EXP_B_HEX), N)) == SRP_RESULT_B_HEX + end, + }, + { + name = "mod_exp - RFC 5054 group 15: base greater than the modulus", + test = function() + local base = bignum.add(N, from_number(7)) + return to_hex(bignum.mod_exp(base, from_hex(SRP_EXP_A_HEX), N)) == SRP_RESULT_OVER_HEX + end, + }, + { + name = "mod_exp - Montgomery path matches the slow reference path", + test = function() + for _, vector in ipairs(CROSS_CHECK_VECTORS) do + local base, exp = from_hex(vector.base), from_hex(vector.exp) + local modulus = from_hex(vector.mod) + local fast = mod_exp_montgomery(base, exp, modulus) + local slow = mod_exp_reference(base, exp, modulus) + if to_hex(fast) ~= vector.result or to_hex(slow) ~= vector.result then + return false + end + end + return true + end, + }, + -- ------------------------------------------------------ acceleration + { + name = "accelerated mod_exp is identical to pure Lua (bn.powmod spelling)", + test = function() + install(nil) + local pure = bignum.mod_exp(X, Y, M) + local binding, calls = make_binding({ spelling = "powmod" }) + install(binding) + local accelerated = bignum.mod_exp(X, Y, M) + install(nil) + -- calls() > 1 proves the routing fired: once to verify, once for real. + return calls() > 1 and equals(accelerated, pure) and to_hex(accelerated) == VEC_MOD_EXP_HEX + end, + }, + { + name = "accelerated mod_exp is identical to pure Lua (bn.mod_exp spelling)", + test = function() + install(nil) + local pure = bignum.mod_exp(G, from_hex(SRP_EXP_A_HEX), N) + local binding, calls = make_binding({ spelling = "mod_exp" }) + install(binding) + local accelerated = bignum.mod_exp(G, from_hex(SRP_EXP_A_HEX), N) + install(nil) + return calls() > 1 and equals(accelerated, pure) and to_hex(accelerated) == SRP_RESULT_A_HEX + end, + }, + { + name = "accelerated results are canonical limb tables, not handles", + test = function() + local binding = make_binding({}) + install(binding) + local accelerated = bignum.mod_exp(X, Y, M) + install(nil) + -- Same canonical type either way, so a use_openssl() toggle mid-flight + -- can never produce mixed-type operands. + return type(accelerated) == "table" and equals(bignum.mod_mul(accelerated, bignum.one(), M), accelerated) + end, + }, + { + name = "a binding that returns wrong answers is rejected, not trusted", + test = function() + local binding = make_binding({ broken = true }) + install(binding) + local result = bignum.mod_exp(X, Y, M) + install(nil) + return to_hex(result) == VEC_MOD_EXP_HEX + end, + }, + { + name = "an absent binding falls back to pure Lua", + test = function() + install(nil) + openssl_wrapper.use(true) + local result = bignum.mod_exp(from_number(4), from_number(13), from_number(497)) + install(nil) + return equals(result, from_number(445)) + end, + }, + -- ----------------------------------------------- is_accelerated reporting + -- A caller told only "false" cannot tell a host that will never accelerate + -- from one where nobody called use_openssl(true), and the two need opposite + -- responses. Each case below pins the phrase that distinguishes them. + { + name = "is_accelerated blames the flag when acceleration was never enabled", + test = function() + install(nil) -- also sets use(false) + local accelerated, reason = bignum.is_accelerated() + return accelerated == false + and type(reason) == "string" + and reason:find("crypto.use_openssl(true)", 1, true) ~= nil + end, + }, + { + name = "is_accelerated blames the host when the binding is absent", + test = function() + install(nil) + openssl_wrapper.use(true) + local accelerated, reason = bignum.is_accelerated() + install(nil) + return accelerated == false + and type(reason) == "string" + and reason:find("not available", 1, true) ~= nil + and reason:find("use_openssl", 1, true) == nil + end, + }, + { + -- The binding computes 4^13 mod 497 correctly, so Feature.BN's probe + -- passes; it is wrong on the multi-limb vector, so mod_exp will not use + -- it. is_accelerated must agree with mod_exp, not with the feature gate. + name = "is_accelerated blames the known-answer check for a single-limb-only binding", + test = function() + local binding = make_binding({ small_only = true }) + install(binding) + local gated = openssl_wrapper.get(openssl_wrapper.Feature.BN) ~= nil + local accelerated, reason = bignum.is_accelerated() + local result = bignum.mod_exp(X, Y, M) + install(nil) + return gated == true + and accelerated == false + and type(reason) == "string" + and reason:find("known-answer check", 1, true) ~= nil + and to_hex(result) == VEC_MOD_EXP_HEX + end, + }, + { + name = "is_accelerated is true with no reason for a trusted binding", + test = function() + local binding = make_binding({}) + install(binding) + local accelerated, reason = bignum.is_accelerated() + install(nil) + return accelerated == true and reason == nil + end, + }, + } + + local passed = 0 + for _, test in ipairs(tests) do + local ok, result = pcall(test.test) + if ok and result == true then + print(" ✅ PASS: " .. test.name) + passed = passed + 1 + else + print(" ❌ FAIL: " .. test.name .. (ok and "" or (" - " .. tostring(result)))) + end + end + + -- Restore the module-load default so later tests in this process are unaffected. + package.loaded["openssl"] = saved_loaded + package.preload["openssl"] = saved_preload + openssl_wrapper.use(os.getenv("CRYPTO_USE_OPENSSL") == "1" or os.getenv("CRYPTO_USE_OPENSSL") == "true") + + print(string_format("\nBignum result: %d/%d tests passed\n", passed, #tests)) + return passed == #tests +end + +--- Run performance benchmarks +--- +--- The headline number is a 3072-bit modular exponentiation with a full-size +--- 3072-bit exponent: that is the SRP-6a server operation, and it decides +--- whether the pure-Lua path is shippable on an embedded controller. Iteration +--- counts are small because a single such operation takes seconds. +function bignum.benchmark() + local N = bignum.from_hex(RFC5054_N_HEX) + local G = bignum.from_number(5) + local exp_short = bignum.from_hex(SRP_EXP_A_HEX) + local exp_full = bignum.from_hex(SRP_EXP_B_HEX) + local wide = bignum.mul(N, N) + + print("Modular exponentiation (RFC 5054 group 15, 3072-bit N):") + benchmark_op("mod_exp 3072-bit exponent", function() + bignum.mod_exp(G, exp_full, N) + end, 2) + + benchmark_op("mod_exp 256-bit exponent", function() + bignum.mod_exp(G, exp_short, N) + end, 5) + + print("\nCore arithmetic:") + benchmark_op("mul 3072 x 3072 bits", function() + bignum.mul(N, N) + end, 200) + + benchmark_op("divmod 6144 / 3072 bits", function() + bignum.divmod(wide, N) + end, 100) + + benchmark_op("mod_mul 3072-bit", function() + bignum.mod_mul(N, N, N) + end, 100) +end + +return bignum diff --git a/src/crypto/ed25519.lua b/src/crypto/ed25519.lua new file mode 100644 index 0000000..65050c1 --- /dev/null +++ b/src/crypto/ed25519.lua @@ -0,0 +1,1274 @@ +--- @module "crypto.ed25519" +--- Ed25519 (RFC 8032) EdDSA signatures in portable pure Lua. +--- +--- PureEdDSA over edwards25519 with SHA-512, no context and no prehashing +--- (RFC 8032 section 5.1). The field arithmetic is the TweetNaCl 16x16-bit limb +--- representation shared with `crypto.x25519`; point arithmetic uses extended +--- twisted Edwards coordinates (X, Y, Z, T). +--- +--- Portability: no Lua 5.3+ integer syntax is used, every intermediate value is +--- exactly representable as an IEEE double, so the module behaves identically on +--- Lua 5.1/5.2/5.3/5.4/5.5 and LuaJIT 2.0/2.1. +--- @class crypto.ed25519 +local ed25519 = {} + +local bit32 = require("bitn").bit32 + +local random = require("crypto.random") +local sha512_mod = require("crypto.sha512") +local utils = require("crypto.utils") +local bytes = utils.bytes +local benchmark_op = utils.benchmark.benchmark_op + +-- Local references for performance +local bit32_raw_band = bit32.raw_band +local bit32_raw_bor = bit32.raw_bor +local bit32_raw_bxor = bit32.raw_bxor +local bit32_raw_rshift = bit32.raw_rshift +local floor = math.floor +local sha512 = sha512_mod.sha512 +local string_byte = string.byte +local string_char = string.char +local string_rep = string.rep +local string_sub = string.sub +local table_concat = table.concat + +-- ============================================================================ +-- CURVE25519 FIELD ARITHMETIC (shared field with X25519: p = 2^255 - 19) +-- ============================================================================ + +-- `FieldElement` and `ProductArray` are shared with x25519 (same field, same +-- limb layout) and are defined once in `annotations.lua`. + +--- @alias ByteArray integer[] Array of byte values (indices start at 1) +--- @alias EdPoint FieldElement[] 4-element array {X, Y, Z, T} in extended twisted Edwards coordinates + +--- Initialize a 16-element field element with zeros +--- @return FieldElement fe Initialized field element +local function create_field_element() + local arr = {} + for i = 1, 16 do + arr[i] = 0 + end + return arr +end + +--- Initialize a 31-element product array with zeros +--- @return ProductArray arr Initialized array +local function create_product_array() + local arr = {} + for i = 1, 31 do + arr[i] = 0 + end + return arr +end + +--- Initialize an n-element byte array with zeros +--- @param n integer Number of elements +--- @return ByteArray arr Initialized array +local function create_byte_array(n) + local arr = {} + for i = 1, n do + arr[i] = 0 + end + return arr +end + +--- Initialize an extended twisted Edwards point (all four coordinates zeroed) +--- @return EdPoint p Initialized point +local function create_point() + return { create_field_element(), create_field_element(), create_field_element(), create_field_element() } +end + +-- Pre-allocated product array for fe_mul() to avoid repeated allocation +local mul_prod = create_product_array() + +-- Pre-allocated arrays for fe_pack() to avoid repeated allocation +local pack_t = create_field_element() +local pack_m = create_field_element() + +-- Pre-allocated arrays for fe_inv() / fe_pow2523() +local inv_c = create_field_element() +local pow_c = create_field_element() + +-- Pre-allocated byte buffers used by par25519() / fe_eq() +local cmp_a = create_byte_array(32) +local cmp_b = create_byte_array(32) + +--- Carry/reduce a field element so every limb ends up in [0, 2^16) +--- +--- Overflow bound: `floor(v * 1/0x10000)` is exact for any |v| < 2^53 because +--- 1/0x10000 is a power of two, so the multiply is error-free. Callers keep +--- limbs well under that (see fe_mul). +--- @param out integer[] Array to perform carry on +local function fe_carry(out) + for i = 1, 16 do + local v = out[i] + 0x10000 + local c = floor(v * 0.0000152587890625) -- 1/0x10000 = 0.0000152587890625 + if i < 16 then + out[i + 1] = out[i + 1] + c - 1 + else + out[1] = out[1] + 38 * (c - 1) + end + out[i] = v - c * 0x10000 + end +end + +--- Conditional swap of two limb arrays based on a bit value (branch-free) +--- @param a integer[] First array +--- @param b integer[] Second array +--- @param bit integer Bit value (0 or 1) +local function fe_cswap(a, b, bit) + for i = 1, 16 do + a[i], b[i] = a[i] * ((bit - 1) % 2) + b[i] * bit, b[i] * ((bit - 1) % 2) + a[i] * bit + end +end + +--- Unpack a 32-byte little-endian value into a limb array (clears the top bit) +--- @param out integer[] Output limb array +--- @param a integer[] Input byte array (32 bytes) +local function fe_unpack(out, a) + for i = 1, 16 do + out[i] = a[2 * i - 1] + a[2 * i] * 0x100 + end + out[16] = bit32_raw_band(out[16], 0x7fff) +end + +-- Pre-allocated prime constant for fe_pack() +local PRIME = { + 0xffed, + 0xffff, + 0xffff, + 0xffff, + 0xffff, + 0xffff, + 0xffff, + 0xffff, + 0xffff, + 0xffff, + 0xffff, + 0xffff, + 0xffff, + 0xffff, + 0xffff, + 0x7fff, +} + +--- Pack a limb array into 32 little-endian bytes with full modular reduction +--- @param out integer[] Output byte array (32 bytes) +--- @param a integer[] Input limb array +local function fe_pack(out, a) + -- Reuse pre-allocated arrays + local t, m = pack_t, pack_m + for i = 1, 16 do + t[i] = a[i] + end + fe_carry(t) + fe_carry(t) + fe_carry(t) + for _ = 1, 2 do + m[1] = t[1] - PRIME[1] + for i = 2, 16 do + local prev = m[i - 1] + m[i] = t[i] - PRIME[i] - (floor(prev * 0.0000152587890625) % 2) + m[i - 1] = (prev + 0x10000) % 0x10000 + end + local c = floor(m[16] * 0.0000152587890625) % 2 + fe_cswap(t, m, 1 - c) + end + for i = 1, 16 do + local ti = t[i] + out[2 * i - 1] = ti % 0x100 + out[2 * i] = floor(ti * 0.00390625) -- 1/256 + end +end + +--- Add two field elements +--- @param out integer[] Output array +--- @param a integer[] First input array +--- @param b integer[] Second input array +local function fe_add(out, a, b) + for i = 1, 16 do + out[i] = a[i] + b[i] + end +end + +--- Subtract two field elements +--- @param out integer[] Output array +--- @param a integer[] First input array +--- @param b integer[] Second input array +local function fe_sub(out, a, b) + for i = 1, 16 do + out[i] = a[i] - b[i] + end +end + +--- Multiply two field elements modulo 2^255 - 19 +--- +--- Overflow bound: inputs are always either fe_mul/fe_carry outputs (limbs in +--- [0, 2^16)) or at most a sum of two such values (|limb| < 2^18). The +--- schoolbook accumulator therefore stays below 16 * 2^18 * 2^18 = 2^40, and the +--- 38x fold-down of the high half keeps it below 39 * 2^40 < 2^46 << 2^53, so +--- every intermediate is exact in IEEE doubles on 5.1/5.2/LuaJIT. +--- @param out integer[] Output array +--- @param a integer[] First input array +--- @param b integer[] Second input array +local function fe_mul(out, a, b) + -- Reuse pre-allocated array and clear it + local prod = mul_prod + for i = 1, 31 do + prod[i] = 0 + end + -- Schoolbook multiplication + for i = 1, 16 do + local ai = a[i] + for j = 1, 16 do + prod[i + j - 1] = prod[i + j - 1] + ai * b[j] + end + end + -- Reduce mod 2^255-19 (multiply high limbs by 38 and add to low) + for i = 1, 15 do + prod[i] = prod[i] + 38 * prod[i + 16] + end + for i = 1, 16 do + out[i] = prod[i] + end + fe_carry(out) + fe_carry(out) +end + +--- Square a field element +--- @param out integer[] Output array +--- @param a integer[] Input array +local function fe_sq(out, a) + fe_mul(out, a, a) +end + +--- Copy a field element +--- @param out integer[] Output array +--- @param a integer[] Input array +local function fe_copy(out, a) + for i = 1, 16 do + out[i] = a[i] + end +end + +--- Compute the modular inverse a^(p-2) using Fermat's little theorem +--- @param out integer[] Output array +--- @param a integer[] Input array +local function fe_inv(out, a) + local c = inv_c + fe_copy(c, a) + for i = 253, 0, -1 do + fe_mul(c, c, c) + if i ~= 2 and i ~= 4 then + fe_mul(c, c, a) + end + end + fe_copy(out, c) +end + +--- Compute a^((p-5)/8), the candidate square root exponent used by decompression +--- @param out integer[] Output array +--- @param a integer[] Input array +local function fe_pow2523(out, a) + local c = pow_c + fe_copy(c, a) + for i = 250, 0, -1 do + fe_mul(c, c, c) + if i ~= 1 then + fe_mul(c, c, a) + end + end + fe_copy(out, c) +end + +--- Test two field elements for equality (compares canonical packed encodings) +--- @param a integer[] First field element +--- @param b integer[] Second field element +--- @return boolean equal True when a == b in the field +local function fe_eq(a, b) + fe_pack(cmp_a, a) + fe_pack(cmp_b, b) + for i = 1, 32 do + if cmp_a[i] ~= cmp_b[i] then + return false + end + end + return true +end + +--- Return the least significant bit of the canonical encoding of a field element +--- @param a integer[] Input field element +--- @return integer parity 0 or 1 +local function fe_parity(a) + fe_pack(cmp_a, a) + return bit32_raw_band(cmp_a[1], 1) +end + +-- ============================================================================ +-- EDWARDS25519 POINT ARITHMETIC (extended twisted Edwards coordinates) +-- ============================================================================ + +-- Curve constant d = -121665/121666 (mod 2^255-19) +local D = { + 0x78a3, + 0x1359, + 0x4dca, + 0x75eb, + 0xd8ab, + 0x4141, + 0x0a4d, + 0x0070, + 0xe898, + 0x7779, + 0x4079, + 0x8cc7, + 0xfe73, + 0x2b6f, + 0x6cee, + 0x5203, +} + +-- 2*d (mod 2^255-19) +local D2 = { + 0xf159, + 0x26b2, + 0x9b94, + 0xebd6, + 0xb156, + 0x8283, + 0x149a, + 0x00e0, + 0xd130, + 0xeef3, + 0x80f2, + 0x198e, + 0xfce7, + 0x56df, + 0xd9dc, + 0x2406, +} + +-- Base point x-coordinate +local BASE_X = { + 0xd51a, + 0x8f25, + 0x2d60, + 0xc956, + 0xa7b2, + 0x9525, + 0xc760, + 0x692c, + 0xdc5c, + 0xfdd6, + 0xe231, + 0xc0a4, + 0x53fe, + 0xcd6e, + 0x36d3, + 0x2169, +} + +-- Base point y-coordinate (4/5) +local BASE_Y = { + 0x6658, + 0x6666, + 0x6666, + 0x6666, + 0x6666, + 0x6666, + 0x6666, + 0x6666, + 0x6666, + 0x6666, + 0x6666, + 0x6666, + 0x6666, + 0x6666, + 0x6666, + 0x6666, +} + +-- sqrt(-1) mod 2^255-19 +local SQRT_M1 = { + 0xa0b0, + 0x4a0e, + 0x1b27, + 0xc4ee, + 0xe478, + 0xad2f, + 0x1806, + 0x2f43, + 0xd7a7, + 0x3dfb, + 0x0099, + 0x2b4d, + 0xdf0b, + 0x4fc1, + 0x2480, + 0x2b83, +} + +local GF0 = create_field_element() +local GF1 = create_field_element() +GF1[1] = 1 + +-- Base point B in extended coordinates: (X, Y, 1, X*Y) +local BASE_POINT = create_point() +fe_copy(BASE_POINT[1], BASE_X) +fe_copy(BASE_POINT[2], BASE_Y) +fe_copy(BASE_POINT[3], GF1) +fe_mul(BASE_POINT[4], BASE_X, BASE_Y) + +-- Pre-allocated scratch for pt_add() +local pa_a = create_field_element() +local pa_b = create_field_element() +local pa_c = create_field_element() +local pa_d = create_field_element() +local pa_e = create_field_element() +local pa_f = create_field_element() +local pa_g = create_field_element() +local pa_h = create_field_element() +local pa_t = create_field_element() + +-- Pre-allocated scratch for pt_pack() +local pp_tx = create_field_element() +local pp_ty = create_field_element() +local pp_zi = create_field_element() + +-- Pre-allocated scratch for pt_unpack_neg() +local un_t = create_field_element() +local un_chk = create_field_element() +local un_num = create_field_element() +local un_den = create_field_element() +local un_den2 = create_field_element() +local un_den4 = create_field_element() +local un_den6 = create_field_element() + +-- Pre-allocated working points +local wp_p = create_point() +local wp_q = create_point() +local wp_r = create_point() +local wp_base = create_point() + +--- Add two points in extended twisted Edwards coordinates: p := p + q +--- +--- Safe to call with p == q (every coordinate is read before any is written). +--- @param p EdPoint Accumulator, overwritten with the sum +--- @param q EdPoint Point to add +local function pt_add(p, q) + local a, b, c, d, e, f, g, h, t = pa_a, pa_b, pa_c, pa_d, pa_e, pa_f, pa_g, pa_h, pa_t + fe_sub(a, p[2], p[1]) + fe_sub(t, q[2], q[1]) + fe_mul(a, a, t) + fe_add(b, p[1], p[2]) + fe_add(t, q[1], q[2]) + fe_mul(b, b, t) + fe_mul(c, p[4], q[4]) + fe_mul(c, c, D2) + fe_mul(d, p[3], q[3]) + fe_add(d, d, d) + fe_sub(e, b, a) + fe_sub(f, d, c) + fe_add(g, d, c) + fe_add(h, b, a) + + fe_mul(p[1], e, f) + fe_mul(p[2], h, g) + fe_mul(p[3], g, f) + fe_mul(p[4], e, h) +end + +--- Conditionally swap two points based on a bit value (branch-free) +--- @param p EdPoint First point +--- @param q EdPoint Second point +--- @param bit integer Bit value (0 or 1) +local function pt_cswap(p, q, bit) + for i = 1, 4 do + fe_cswap(p[i], q[i], bit) + end +end + +--- Copy a point +--- @param out EdPoint Destination point +--- @param p EdPoint Source point +local function pt_copy(out, p) + for i = 1, 4 do + fe_copy(out[i], p[i]) + end +end + +--- Compress a point into its 32-byte little-endian encoding +--- @param out integer[] Output byte array (32 bytes) +--- @param p EdPoint Point to compress +local function pt_pack(out, p) + fe_inv(pp_zi, p[3]) + fe_mul(pp_tx, p[1], pp_zi) + fe_mul(pp_ty, p[2], pp_zi) + fe_pack(out, pp_ty) + out[32] = bit32_raw_bxor(out[32], fe_parity(pp_tx) * 128) +end + +--- Scalar multiplication: out := s * q (double-and-add over all 256 bits) +--- +--- The base point argument `q` is destroyed by the conditional swaps. +--- @param out EdPoint Output point (must be a different table than q) +--- @param q EdPoint Input point, clobbered +--- @param s integer[] 32-byte little-endian scalar +local function pt_scalarmult(out, q, s) + fe_copy(out[1], GF0) + fe_copy(out[2], GF1) + fe_copy(out[3], GF1) + fe_copy(out[4], GF0) + for i = 255, 0, -1 do + local byte_idx = floor(i * 0.125) + 1 -- i / 8 + 1 + local bit = bit32_raw_band(bit32_raw_rshift(s[byte_idx], i % 8), 1) + pt_cswap(out, q, bit) + pt_add(q, out) + pt_add(out, out) + pt_cswap(out, q, bit) + end +end + +--- Scalar multiplication of the Ed25519 base point: out := s * B +--- @param out EdPoint Output point (must not be the shared base scratch point) +--- @param s integer[] 32-byte little-endian scalar +local function pt_scalarbase(out, s) + pt_copy(wp_base, BASE_POINT) + pt_scalarmult(out, wp_base, s) +end + +--- Decompress a 32-byte encoding into the NEGATED point -(x, y) +--- +--- Negating on decompression is what lets verification compute R + [k]A with a +--- single point addition (TweetNaCl's `unpackneg`). +--- @param out EdPoint Output point +--- @param p integer[] 32-byte encoded point +--- @return boolean ok False when the encoding is not a valid curve point +local function pt_unpack_neg(out, p) + local t, chk, num, den = un_t, un_chk, un_num, un_den + local den2, den4, den6 = un_den2, un_den4, un_den6 + + fe_copy(out[3], GF1) + fe_unpack(out[2], p) + fe_sq(num, out[2]) + fe_mul(den, num, D) + fe_sub(num, num, out[3]) + fe_add(den, out[3], den) + + fe_sq(den2, den) + fe_sq(den4, den2) + fe_mul(den6, den4, den2) + fe_mul(t, den6, num) + fe_mul(t, t, den) + + fe_pow2523(t, t) + fe_mul(t, t, num) + fe_mul(t, t, den) + fe_mul(t, t, den) + fe_mul(out[1], t, den) + + fe_sq(chk, out[1]) + fe_mul(chk, chk, den) + if not fe_eq(chk, num) then + fe_mul(out[1], out[1], SQRT_M1) + end + + fe_sq(chk, out[1]) + fe_mul(chk, chk, den) + if not fe_eq(chk, num) then + return false + end + + if fe_parity(out[1]) == bit32_raw_rshift(p[32], 7) then + fe_sub(out[1], GF0, out[1]) + end + + fe_mul(out[4], out[1], out[2]) + return true +end + +-- ============================================================================ +-- SCALAR ARITHMETIC MODULO THE GROUP ORDER L +-- ============================================================================ + +-- L = 2^252 + 27742317777372353535851937790883648493, little-endian bytes +local L = { + 0xed, + 0xd3, + 0xf5, + 0x5c, + 0x1a, + 0x63, + 0x12, + 0x58, + 0xd6, + 0x9c, + 0xf7, + 0xa2, + 0xde, + 0xf9, + 0xde, + 0x14, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0x10, +} + +-- Pre-allocated 64-limb accumulator for mod_l() +local modl_x = create_byte_array(64) + +--- Reduce a 64-limb little-endian value modulo L, writing 32 bytes to `out` +--- +--- Overflow bound: limbs stay under ~2^21 in magnitude, so the largest product +--- `16 * x[i] * L[j]` is below 16 * 2^21 * 2^8 = 2^33, far under 2^53. +--- @param out integer[] Output byte array (32 bytes) +--- @param x integer[] 64-limb accumulator, clobbered +local function mod_l(out, x) + for i = 63, 32, -1 do + local carry = 0 + local j = i - 32 + while j < i - 12 do + x[j + 1] = x[j + 1] + carry - 16 * x[i + 1] * L[j - (i - 32) + 1] + carry = floor((x[j + 1] + 128) * 0.00390625) -- (x + 128) >> 8 + x[j + 1] = x[j + 1] - carry * 256 + j = j + 1 + end + x[j + 1] = x[j + 1] + carry + x[i + 1] = 0 + end + local carry = 0 + local top = floor(x[32] * 0.0625) -- x[31] >> 4 + for j = 0, 31 do + x[j + 1] = x[j + 1] + carry - top * L[j + 1] + carry = floor(x[j + 1] * 0.00390625) + x[j + 1] = x[j + 1] % 256 + end + for j = 0, 31 do + x[j + 1] = x[j + 1] - carry * L[j + 1] + end + for i = 0, 31 do + x[i + 2] = x[i + 2] + floor(x[i + 1] * 0.00390625) + out[i + 1] = x[i + 1] % 256 + end +end + +--- Reduce a 64-byte string modulo L +--- @param s string 64-byte little-endian value +--- @return integer[] scalar 32-byte reduced scalar as a byte array +local function reduce_hash(s) + local x = modl_x + for i = 1, 64 do + x[i] = string_byte(s, i) + end + local out = create_byte_array(32) + mod_l(out, x) + return out +end + +--- Compute (r + k * a) mod L +--- @param r integer[] 32-byte little-endian value +--- @param k integer[] 32-byte little-endian value +--- @param a integer[] 32-byte little-endian value +--- @return integer[] scalar 32-byte reduced result as a byte array +local function scalar_muladd(r, k, a) + local x = modl_x + for i = 1, 64 do + x[i] = 0 + end + for i = 1, 32 do + x[i] = r[i] + end + for i = 1, 32 do + local ki = k[i] + if ki ~= 0 then + for j = 1, 32 do + x[i + j - 1] = x[i + j - 1] + ki * a[j] + end + end + end + local out = create_byte_array(32) + mod_l(out, x) + return out +end + +--- Test whether a 32-byte little-endian scalar is strictly less than L +--- @param s integer[] 32-byte scalar +--- @return boolean canonical True when s < L +local function scalar_is_canonical(s) + for i = 32, 1, -1 do + if s[i] > L[i] then + return false + elseif s[i] < L[i] then + return true + end + end + return false -- s == L is not canonical either +end + +-- ============================================================================ +-- BYTE HELPERS +-- ============================================================================ + +--- Convert string to byte array +--- @param s string Input string +--- @param offset? integer 1-based offset into the string (default: 1) +--- @param len? integer Number of bytes to take (default: to end of string) +--- @return integer[] byte_array Byte array +local function string_to_bytes(s, offset, len) + offset = offset or 1 + len = len or (#s - offset + 1) + local b = {} + for i = 1, len do + b[i] = string_byte(s, offset + i - 1) + end + return b +end + +--- Convert byte array to string +--- @param b integer[] Byte array +--- @param len integer Length +--- @return string result Output string +local function bytes_to_string(b, len) + local result_bytes = {} + for i = 1, len do + result_bytes[i] = string_char(b[i] or 0) + end + return table_concat(result_bytes) +end + +--- Apply the RFC 8032 clamping rules to the low half of SHA-512(seed) +--- @param a integer[] 32-byte scalar, modified in place +local function clamp(a) + a[1] = bit32_raw_band(a[1], 248) + a[32] = bit32_raw_bor(bit32_raw_band(a[32], 127), 64) +end + +-- ============================================================================ +-- ED25519 PUBLIC INTERFACE +-- ============================================================================ + +--- Generate a random Ed25519 private key (seed) +--- +--- The seed is drawn from `crypto.random`, which raises rather than falling back +--- to a weak generator when the host has no CSPRNG. For Ed25519 that matters +--- more than for an ephemeral key: this seed is a long-term signing identity, so +--- a guessable one lets an attacker impersonate this device indefinitely. +--- @return string seed 32-byte private key seed +function ed25519.generate_private_key() + return random.bytes(32) +end + +--- Expand a 32-byte seed into the 64-byte signing key material +--- +--- Returns `a || prefix` where `h = SHA-512(seed)`, `a = clamp(h[1..32])` and +--- `prefix = h[33..64]`. Callers that sign repeatedly with one long-term key can +--- cache this and use `sign_expanded` to skip the per-signature SHA-512(seed). +--- @param seed string 32-byte private key seed +--- @return string expanded 64-byte expanded key (clamped scalar || prefix) +function ed25519.expand_private_key(seed) + assert(type(seed) == "string" and #seed == 32, "Seed must be exactly 32 bytes") + + local h = sha512(seed) + local a = string_to_bytes(h, 1, 32) + clamp(a) + return bytes_to_string(a, 32) .. string_sub(h, 33, 64) +end + +--- Derive the Ed25519 public key from a 32-byte seed +--- @param seed string 32-byte private key seed +--- @return string public_key 32-byte public key +function ed25519.derive_public_key(seed) + assert(type(seed) == "string" and #seed == 32, "Seed must be exactly 32 bytes") + + local expanded = ed25519.expand_private_key(seed) + local a = string_to_bytes(expanded, 1, 32) + local pk = create_byte_array(32) + + pt_scalarbase(wp_p, a) + pt_pack(pk, wp_p) + return bytes_to_string(pk, 32) +end + +--- Generate an Ed25519 key pair +--- @return string seed 32-byte private key seed +--- @return string public_key 32-byte public key +function ed25519.generate_keypair() + local seed = ed25519.generate_private_key() + local public_key = ed25519.derive_public_key(seed) + return seed, public_key +end + +--- Sign a message with a pre-expanded private key +--- +--- Produces byte-identical output to `ed25519.sign` for the same key/message. +--- @param expanded string 64-byte expanded key from `expand_private_key` +--- @param public_key string 32-byte public key matching the expanded key +--- @param message string Message to sign (any length, may be empty) +--- @return string signature 64-byte signature (R || S) +function ed25519.sign_expanded(expanded, public_key, message) + assert(type(expanded) == "string" and #expanded == 64, "Expanded key must be exactly 64 bytes") + assert(type(public_key) == "string" and #public_key == 32, "Public key must be exactly 32 bytes") + assert(type(message) == "string", "Message must be a string") + + local a = string_to_bytes(expanded, 1, 32) + local prefix = string_sub(expanded, 33, 64) + + -- r = SHA-512(prefix || M) mod L, R = [r]B + local r = reduce_hash(sha512(prefix .. message)) + local r_packed = create_byte_array(32) + pt_scalarbase(wp_p, r) + pt_pack(r_packed, wp_p) + local r_str = bytes_to_string(r_packed, 32) + + -- k = SHA-512(R || A || M) mod L, S = (r + k * a) mod L + local k = reduce_hash(sha512(r_str .. public_key .. message)) + local s = scalar_muladd(r, k, a) + + return r_str .. bytes_to_string(s, 32) +end + +--- Sign a message with a 32-byte seed +--- @param seed string 32-byte private key seed +--- @param message string Message to sign (any length, may be empty) +--- @return string signature 64-byte signature (R || S) +function ed25519.sign(seed, message) + assert(type(seed) == "string" and #seed == 32, "Seed must be exactly 32 bytes") + assert(type(message) == "string", "Message must be a string") + + local expanded = ed25519.expand_private_key(seed) + local a = string_to_bytes(expanded, 1, 32) + local pk = create_byte_array(32) + pt_scalarbase(wp_p, a) + pt_pack(pk, wp_p) + + return ed25519.sign_expanded(expanded, bytes_to_string(pk, 32), message) +end + +--- Verify an Ed25519 signature +--- +--- Never raises: malformed public keys or signatures (wrong length, wrong type, +--- undecodable point, non-canonical S >= L) simply return false. +--- @param public_key string 32-byte public key +--- @param message string Signed message +--- @param signature string 64-byte signature (R || S) +--- @return boolean valid True when the signature is valid +function ed25519.verify(public_key, message, signature) + if type(public_key) ~= "string" or type(message) ~= "string" or type(signature) ~= "string" then + return false + end + if #public_key ~= 32 or #signature ~= 64 then + return false + end + + local s = string_to_bytes(signature, 33, 32) + if not scalar_is_canonical(s) then + return false + end + + local pk = string_to_bytes(public_key, 1, 32) + if not pt_unpack_neg(wp_q, pk) then + return false + end + + -- k = SHA-512(R || A || M) mod L + local k = reduce_hash(sha512(string_sub(signature, 1, 32) .. public_key .. message)) + + -- p = [k](-A) + [S]B, which must equal R + pt_scalarmult(wp_p, wp_q, k) + pt_scalarbase(wp_r, s) + pt_add(wp_p, wp_r) + + local check = create_byte_array(32) + pt_pack(check, wp_p) + + local diff = 0 + for i = 1, 32 do + diff = bit32_raw_bor(diff, bit32_raw_bxor(check[i], string_byte(signature, i))) + end + return diff == 0 +end + +-- ============================================================================ +-- TEST VECTORS AND VALIDATION +-- ============================================================================ + +--- Test vectors from RFC 8032 section 7.1 (Ed25519) +local test_vectors = { + { + name = "RFC 8032 TEST 1", + seed = bytes.from_hex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"), + public_key = bytes.from_hex("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"), + message = "", + signature = bytes.from_hex( + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" + .. "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b" + ), + }, + { + name = "RFC 8032 TEST 2", + seed = bytes.from_hex("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"), + public_key = bytes.from_hex("3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"), + message = bytes.from_hex("72"), + signature = bytes.from_hex( + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da" + .. "085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00" + ), + }, + { + name = "RFC 8032 TEST 3", + seed = bytes.from_hex("c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7"), + public_key = bytes.from_hex("fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025"), + message = bytes.from_hex("af82"), + signature = bytes.from_hex( + "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac" + .. "18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a" + ), + }, + { + name = "RFC 8032 TEST 1024", + seed = bytes.from_hex("f5e5767cf153319517630f226876b86c8160cc583bc013744c6bf255f5cc0ee5"), + public_key = bytes.from_hex("278117fc144c72340f67d0f2316e8386ceffbf2b2428c9c51fef7c597f1d426e"), + message = bytes.from_hex( + "08b8b2b733424243760fe426a4b54908632110a66c2f6591eabd3345e3e4eb98" + .. "fa6e264bf09efe12ee50f8f54e9f77b1e355f6c50544e23fb1433ddf73be84d8" + .. "79de7c0046dc4996d9e773f4bc9efe5738829adb26c81b37c93a1b270b20329d" + .. "658675fc6ea534e0810a4432826bf58c941efb65d57a338bbd2e26640f89ffbc" + .. "1a858efcb8550ee3a5e1998bd177e93a7363c344fe6b199ee5d02e82d522c4fe" + .. "ba15452f80288a821a579116ec6dad2b3b310da903401aa62100ab5d1a36553e" + .. "06203b33890cc9b832f79ef80560ccb9a39ce767967ed628c6ad573cb116dbef" + .. "efd75499da96bd68a8a97b928a8bbc103b6621fcde2beca1231d206be6cd9ec7" + .. "aff6f6c94fcd7204ed3455c68c83f4a41da4af2b74ef5c53f1d8ac70bdcb7ed1" + .. "85ce81bd84359d44254d95629e9855a94a7c1958d1f8ada5d0532ed8a5aa3fb2" + .. "d17ba70eb6248e594e1a2297acbbb39d502f1a8c6eb6f1ce22b3de1a1f40cc24" + .. "554119a831a9aad6079cad88425de6bde1a9187ebb6092cf67bf2b13fd65f270" + .. "88d78b7e883c8759d2c4f5c65adb7553878ad575f9fad878e80a0c9ba63bcbcc" + .. "2732e69485bbc9c90bfbd62481d9089beccf80cfe2df16a2cf65bd92dd597b07" + .. "07e0917af48bbb75fed413d238f5555a7a569d80c3414a8d0859dc65a46128ba" + .. "b27af87a71314f318c782b23ebfe808b82b0ce26401d2e22f04d83d1255dc51a" + .. "ddd3b75a2b1ae0784504df543af8969be3ea7082ff7fc9888c144da2af58429e" + .. "c96031dbcad3dad9af0dcbaaaf268cb8fcffead94f3c7ca495e056a9b47acdb7" + .. "51fb73e666c6c655ade8297297d07ad1ba5e43f1bca32301651339e22904cc8c" + .. "42f58c30c04aafdb038dda0847dd988dcda6f3bfd15c4b4c4525004aa06eeff8" + .. "ca61783aacec57fb3d1f92b0fe2fd1a85f6724517b65e614ad6808d6f6ee34df" + .. "f7310fdc82aebfd904b01e1dc54b2927094b2db68d6f903b68401adebf5a7e08" + .. "d78ff4ef5d63653a65040cf9bfd4aca7984a74d37145986780fc0b16ac451649" + .. "de6188a7dbdf191f64b5fc5e2ab47b57f7f7276cd419c17a3ca8e1b939ae49e4" + .. "88acba6b965610b5480109c8b17b80e1b7b750dfc7598d5d5011fd2dcc5600a3" + .. "2ef5b52a1ecc820e308aa342721aac0943bf6686b64b2579376504ccc493d97e" + .. "6aed3fb0f9cd71a43dd497f01f17c0e2cb3797aa2a2f256656168e6c496afc5f" + .. "b93246f6b1116398a346f1a641f3b041e989f7914f90cc2c7fff357876e506b5" + .. "0d334ba77c225bc307ba537152f3f1610e4eafe595f6d9d90d11faa933a15ef1" + .. "369546868a7f3a45a96768d40fd9d03412c091c6315cf4fde7cb68606937380d" + .. "b2eaaa707b4c4185c32eddcdd306705e4dc1ffc872eeee475a64dfac86aba41c" + .. "0618983f8741c5ef68d3a101e8a3b8cac60c905c15fc910840b94c00a0b9d0" + ), + signature = bytes.from_hex( + "0aab4c900501b3e24d7cdf4663326a3a87df5e4843b2cbdb67cbf6e460fec350" + .. "aa5371b1508f9f4528ecea23c436d94b5e8fcd4f681e30a6ac00a9704a188a03" + ), + }, + { + name = "RFC 8032 TEST SHA(abc)", + seed = bytes.from_hex("833fe62409237b9d62ec77587520911e9a759cec1d19755b7da901b96dca3d42"), + public_key = bytes.from_hex("ec172b93ad5e563bf4932c70e1245034c35467ef2efd4d64ebf819683467e2bf"), + message = bytes.from_hex( + "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a" + .. "2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f" + ), + signature = bytes.from_hex( + "dc2a4459e7369633a52b1bf277839a00201009a3efbf3ecb69bea2186c26b589" + .. "09351fc9ac90b3ecfdfbc7c66431e0303dca179c138ac17ad9bef1177331a704" + ), + }, +} + +--- Flip the low bit of one byte of a string (empty strings become a single NUL) +--- @param s string Input string +--- @param index integer 1-based byte index to tamper with +--- @return string tampered Tampered string, guaranteed different from the input +local function flip_bit(s, index) + if #s == 0 then + return "\0" + end + index = ((index - 1) % #s) + 1 + local b = bit32_raw_bxor(string_byte(s, index), 1) + return string_sub(s, 1, index - 1) .. string_char(b) .. string_sub(s, index + 1) +end + +--- Add the group order L to the 32-byte little-endian S half of a signature +--- @param signature string 64-byte signature +--- @return string tampered Signature whose S component equals S + L (>= L) +local function add_l_to_s(signature) + local s = string_to_bytes(signature, 33, 32) + local carry = 0 + for i = 1, 32 do + local v = s[i] + L[i] + carry + s[i] = v % 256 + carry = floor(v * 0.00390625) + end + return string_sub(signature, 1, 32) .. bytes_to_string(s, 32) +end + +--- Run comprehensive self-test with RFC 8032 test vectors and functional tests +--- +--- This function validates the Ed25519 implementation against the known-answer +--- test vectors from RFC 8032 section 7.1. ALL tests must pass for the +--- implementation to be considered cryptographically safe. +--- +--- @return boolean result True if all tests pass, false otherwise +function ed25519.selftest() + local function test_vectors_suite() + print("Running Ed25519 test vectors...") + local passed = 0 + local total = 0 + + for i, test in ipairs(test_vectors) do + print(string.format("Test %d: %s (message %d bytes)", i, test.name, #test.message)) + + local checks = {} + + local derived = ed25519.derive_public_key(test.seed) + checks[1] = { name = "derive_public_key", ok = derived == test.public_key, got = derived, want = test.public_key } + + local signature = ed25519.sign(test.seed, test.message) + checks[2] = { name = "sign", ok = signature == test.signature, got = signature, want = test.signature } + + local expanded = ed25519.expand_private_key(test.seed) + local sig_expanded = ed25519.sign_expanded(expanded, test.public_key, test.message) + checks[3] = { + name = "sign_expanded matches sign", + ok = sig_expanded == test.signature and sig_expanded == signature, + got = sig_expanded, + want = test.signature, + } + + checks[4] = { + name = "verify accepts valid signature", + ok = ed25519.verify(test.public_key, test.message, test.signature) == true, + } + checks[5] = { + name = "verify rejects flipped message bit", + ok = ed25519.verify(test.public_key, flip_bit(test.message, 1), test.signature) == false, + } + checks[6] = { + name = "verify rejects flipped signature bit", + ok = ed25519.verify(test.public_key, test.message, flip_bit(test.signature, 40)) == false, + } + + for _, check in ipairs(checks) do + total = total + 1 + if check.ok then + print(" ✅ PASS: " .. check.name) + passed = passed + 1 + else + print(" ❌ FAIL: " .. check.name) + if check.want then + print(" Expected: " .. bytes.to_hex(check.want)) + print(" Got: " .. bytes.to_hex(check.got)) + end + end + end + print() + end + + print(string.format("Test vectors result: %d/%d tests passed", passed, total)) + print() + return passed == total + end + + local function functional_tests() + print("Running Ed25519 functional tests...") + local passed = 0 + local total = 0 + + local cases = { + { + name = "Key generation", + test = function() + local seed1, pub1 = ed25519.generate_keypair() + local seed2, pub2 = ed25519.generate_keypair() + assert(#seed1 == 32 and #pub1 == 32, "Keys should be 32 bytes") + assert(seed1 ~= seed2, "Different key generations should produce different seeds") + assert(pub1 ~= pub2, "Different key generations should produce different public keys") + end, + }, + { + name = "Public key derivation consistency", + test = function() + local seed = ed25519.generate_private_key() + assert(ed25519.derive_public_key(seed) == ed25519.derive_public_key(seed), "Derivation must be deterministic") + end, + }, + { + name = "Expanded key shape and determinism", + test = function() + local seed = test_vectors[1].seed + local expanded = ed25519.expand_private_key(seed) + assert(#expanded == 64, "Expanded key should be 64 bytes") + assert(expanded == ed25519.expand_private_key(seed), "Expansion must be deterministic") + local a1 = string_byte(expanded, 1) + local a32 = string_byte(expanded, 32) + assert(a1 % 8 == 0, "Low 3 bits of the scalar must be cleared") + assert(a32 < 128 and a32 >= 64, "Scalar must have bit 254 set and bit 255 cleared") + end, + }, + { + name = "Sign/verify roundtrip with a generated key", + test = function() + local seed, pub = ed25519.generate_keypair() + local msg = "The quick brown fox jumps over the lazy dog" + local sig = ed25519.sign(seed, msg) + assert(#sig == 64, "Signature should be 64 bytes") + assert(ed25519.verify(pub, msg, sig) == true, "Signature should verify") + assert(ed25519.verify(pub, msg .. "!", sig) == false, "Modified message must not verify") + end, + }, + { + name = "sign_expanded is byte-identical to sign", + test = function() + local seed, pub = ed25519.generate_keypair() + local expanded = ed25519.expand_private_key(seed) + for _, msg in ipairs({ "", "a", string_rep("z", 200) }) do + assert(ed25519.sign_expanded(expanded, pub, msg) == ed25519.sign(seed, msg), "Outputs must match") + end + end, + }, + { + name = "verify rejects a signature from another key", + test = function() + local seed_a = test_vectors[2].seed + local _, pub_b = ed25519.generate_keypair() + local msg = "cross-key check" + assert(ed25519.verify(pub_b, msg, ed25519.sign(seed_a, msg)) == false, "Wrong key must not verify") + end, + }, + { + name = "verify returns false for wrong-length signature", + test = function() + local v = test_vectors[3] + assert(ed25519.verify(v.public_key, v.message, "") == false, "Empty signature must be rejected") + assert( + ed25519.verify(v.public_key, v.message, string_sub(v.signature, 1, 63)) == false, + "Short signature must be rejected" + ) + assert(ed25519.verify(v.public_key, v.message, v.signature .. "\0") == false, "Long signature is rejected") + end, + }, + { + name = "verify returns false for wrong-length public key", + test = function() + local v = test_vectors[3] + assert(ed25519.verify("", v.message, v.signature) == false, "Empty public key must be rejected") + assert( + ed25519.verify(string_sub(v.public_key, 1, 31), v.message, v.signature) == false, + "Short public key must be rejected" + ) + assert(ed25519.verify(v.public_key .. "\0", v.message, v.signature) == false, "Long public key is rejected") + end, + }, + { + name = "verify returns false for undecodable public key", + test = function() + local v = test_vectors[3] + -- y = 2 is not the y-coordinate of any edwards25519 point + local bad = bytes.from_hex("0200000000000000000000000000000000000000000000000000000000000000") + assert(ed25519.verify(bad, v.message, v.signature) == false, "Undecodable point must be rejected") + end, + }, + { + name = "verify rejects non-canonical S >= L", + test = function() + local v = test_vectors[3] + local malleable = add_l_to_s(v.signature) + assert(malleable ~= v.signature, "Tampered signature should differ") + assert(ed25519.verify(v.public_key, v.message, malleable) == false, "S >= L must be rejected") + end, + }, + { + name = "verify returns false for non-string arguments", + test = function() + local v = test_vectors[3] + assert(ed25519.verify(nil, v.message, v.signature) == false, "nil public key must be rejected") + assert(ed25519.verify(v.public_key, nil, v.signature) == false, "nil message must be rejected") + assert(ed25519.verify(v.public_key, v.message, 42) == false, "non-string signature must be rejected") + end, + }, + } + + for _, case in ipairs(cases) do + total = total + 1 + local success, err = pcall(case.test) + if success then + print(" ✅ PASS: " .. case.name) + passed = passed + 1 + else + print(" ❌ FAIL: " .. case.name .. " - " .. tostring(err)) + end + end + + print(string.format("\nFunctional tests result: %d/%d tests passed", passed, total)) + print() + return passed == total + end + + local vectors_passed = test_vectors_suite() + local functional_passed = functional_tests() + + return vectors_passed and functional_passed +end + +--- Run performance benchmarks +--- +--- Benchmarks key pair generation, public key derivation, signing (both from a +--- raw seed and from a pre-expanded key) and verification. +function ed25519.benchmark() + local vector = test_vectors[3] + local seed = vector.seed + local public_key = vector.public_key + local message = vector.message + local signature = vector.signature + local expanded = ed25519.expand_private_key(seed) + + print("Key Operations:") + benchmark_op("generate_keypair", function() + ed25519.generate_keypair() + end, 10) + + benchmark_op("derive_public_key", function() + ed25519.derive_public_key(seed) + end, 10) + + benchmark_op("expand_private_key", function() + ed25519.expand_private_key(seed) + end, 100) + + print("\nSignature Operations:") + benchmark_op("sign", function() + ed25519.sign(seed, message) + end, 10) + + benchmark_op("sign_expanded", function() + ed25519.sign_expanded(expanded, public_key, message) + end, 10) + + benchmark_op("verify", function() + ed25519.verify(public_key, message, signature) + end, 10) +end + +return ed25519 diff --git a/src/crypto/hkdf.lua b/src/crypto/hkdf.lua new file mode 100644 index 0000000..249df50 --- /dev/null +++ b/src/crypto/hkdf.lua @@ -0,0 +1,509 @@ +--- @module "crypto.hkdf" +--- HKDF (HMAC-based Extract-and-Expand Key Derivation Function), RFC 5869. +--- +--- Supports SHA-256 and SHA-512. This is a thin layer over the HMAC primitives +--- in `crypto.sha256` / `crypto.sha512`, which already prefer OpenSSL when +--- acceleration is enabled, so HKDF inherits that acceleration without needing +--- a routing decision of its own -- see the note on `openssl.kdf` below. +--- +--- @usage +--- local hkdf = require("crypto.hkdf") +--- +--- -- one-shot: extract then expand +--- local key = hkdf.derive("sha512", salt, shared_secret, "Pair-Setup-Encrypt-Info", 32) +--- +--- -- or the two phases separately, when one PRK feeds several expansions +--- local prk = hkdf.extract("sha512", "Control-Salt", shared_secret) +--- local read_key = hkdf.expand("sha512", prk, "ClientEncrypt-main", 32) +--- local write_key = hkdf.expand("sha512", prk, "ServerEncrypt-main", 32) +--- +--- @class crypto.hkdf +local hkdf = {} + +local sha256 = require("crypto.sha256") +local sha512 = require("crypto.sha512") + +local utils = require("crypto.utils") +local bytes = utils.bytes +local benchmark_op = utils.benchmark.benchmark_op + +-- Local references for performance +local string_char = string.char +local string_rep = string.rep +local string_sub = string.sub +local table_concat = table.concat + +--- Supported hash functions and their parameters. +--- +--- `hmac` takes (key, data) and returns the raw MAC; `length` is HashLen in +--- RFC 5869 terms, which fixes both the PRK size and the 255*HashLen output +--- ceiling. +--- +--- @class HkdfHash +--- @field hmac fun(key: string, data: string): string HMAC over this hash +--- @field length integer HashLen in bytes + +--- @type table +local HASHES = { + sha256 = { hmac = sha256.hmac_sha256, length = 32 }, + sha512 = { hmac = sha512.hmac_sha512, length = 64 }, +} + +-- Note on routing to `openssl.kdf`: +-- +-- The wrapper exposes `Feature.KDF` and the Control4 build has it, but HKDF is +-- deliberately NOT routed there. `hmac_sha256`/`hmac_sha512` already return the +-- OpenSSL result when acceleration is on, so the pure-Lua cost of HKDF is the +-- glue around the HMACs, not the HMACs themselves. A HAP derivation is one +-- extract plus one 32-byte expand, i.e. two HMAC invocations total, so routing +-- it separately would buy nothing measurable while adding a second code path +-- that cannot be exercised on a host without the binding. `Feature.KDF` is +-- declared so the capability is queryable if that trade ever changes. + +--- Resolve a hash name to its parameters. +--- @param hash string Hash name: "sha256" or "sha512" +--- @return HkdfHash params +local function resolve_hash(hash) + local params = HASHES[hash] + if not params then + error("Unsupported HKDF hash: " .. tostring(hash) .. ' (expected "sha256" or "sha512")') + end + return params +end + +--- HKDF-Extract (RFC 5869 section 2.2). +--- +--- Concentrates the (possibly non-uniform) input keying material into a +--- pseudorandom key of exactly HashLen bytes. +--- +--- @param hash string Hash name: "sha256" or "sha512" +--- @param salt string? Optional salt; an empty or absent salt is replaced by HashLen zero bytes, per the RFC +--- @param ikm string Input keying material +--- @return string prk Pseudorandom key, HashLen bytes +function hkdf.extract(hash, salt, ikm) + local params = resolve_hash(hash) + assert(type(ikm) == "string", "ikm must be a string") + if salt == nil or #salt == 0 then + salt = string_rep("\0", params.length) + end + -- Note the argument order: the salt is the HMAC *key* and the IKM is the data. + return params.hmac(salt, ikm) +end + +--- HKDF-Expand (RFC 5869 section 2.3). +--- +--- Stretches a pseudorandom key into `length` bytes of output keying material, +--- bound to the supplied context string. +--- +--- @param hash string Hash name: "sha256" or "sha512" +--- @param prk string Pseudorandom key, normally the output of `extract` +--- @param info string? Optional context/application-specific information +--- @param length integer Number of output bytes; must be in 1..255*HashLen +--- @return string okm Output keying material, `length` bytes +function hkdf.expand(hash, prk, info, length) + local params = resolve_hash(hash) + assert(type(prk) == "string", "prk must be a string") + assert(type(length) == "number" and length >= 1 and length % 1 == 0, "length must be a positive integer") + local max_length = 255 * params.length + assert(length <= max_length, "length must not exceed 255*HashLen (" .. max_length .. " for " .. hash .. ")") + info = info or "" + + local blocks = {} + local previous = "" + local produced = 0 + local counter = 1 + -- T(0) = "", T(i) = HMAC(PRK, T(i-1) || info || i); OKM is the first L bytes + -- of T(1) || T(2) || ... + while produced < length do + previous = params.hmac(prk, previous .. info .. string_char(counter)) + blocks[counter] = previous + produced = produced + #previous + counter = counter + 1 + end + + return string_sub(table_concat(blocks), 1, length) +end + +--- HKDF: extract then expand in one call (RFC 5869 section 2). +--- @param hash string Hash name: "sha256" or "sha512" +--- @param salt string? Optional salt +--- @param ikm string Input keying material +--- @param info string? Optional context/application-specific information +--- @param length integer Number of output bytes +--- @return string okm Output keying material, `length` bytes +function hkdf.derive(hash, salt, ikm, info, length) + return hkdf.expand(hash, hkdf.extract(hash, salt, ikm), info, length) +end + +--- HKDF with SHA-256, extract and expand in one call. +--- @param salt string? Optional salt +--- @param ikm string Input keying material +--- @param info string? Optional context information +--- @param length integer Number of output bytes +--- @return string okm Output keying material +function hkdf.hkdf_sha256(salt, ikm, info, length) + return hkdf.derive("sha256", salt, ikm, info, length) +end + +--- HKDF with SHA-512, extract and expand in one call. +--- This is the variant HAP uses throughout Pair-Setup, Pair-Verify and session +--- key derivation. +--- @param salt string? Optional salt +--- @param ikm string Input keying material +--- @param info string? Optional context information +--- @param length integer Number of output bytes +--- @return string okm Output keying material +function hkdf.hkdf_sha512(salt, ikm, info, length) + return hkdf.derive("sha512", salt, ikm, info, length) +end + +-- ============================================================================ +-- TEST VECTORS AND VALIDATION +-- ============================================================================ + +--- SHA-256 vectors are RFC 5869 appendix A, cases A.1 to A.3 verbatim. The +--- RFC's remaining cases (A.4 to A.7) use SHA-1, which this library does not +--- implement, so they are omitted rather than adapted. +--- +--- The RFC publishes no SHA-512 vectors. The SHA-512 cases below were generated +--- with Node's `crypto.hkdfSync`, an independent OpenSSL-backed implementation; +--- that generator was first checked against A.1 to A.3 above, so it is a +--- validated oracle rather than an assumed-correct one. The PRK figures come +--- from a separate `crypto.createHmac` call, so extract and expand are pinned +--- independently of each other. +local test_vectors = { + { + name = "RFC 5869 A.1 - SHA-256, basic", + hash = "sha256", + ikm = string_rep(string_char(0x0b), 22), + salt = bytes.from_hex("000102030405060708090a0b0c"), + info = bytes.from_hex("f0f1f2f3f4f5f6f7f8f9"), + length = 42, + prk = "077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5", + okm = "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865", + }, + { + name = "RFC 5869 A.2 - SHA-256, longer inputs and output", + hash = "sha256", + ikm = bytes.from_hex( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + .. "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f" + .. "404142434445464748494a4b4c4d4e4f" + ), + salt = bytes.from_hex( + "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f" + .. "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f" + .. "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf" + ), + info = bytes.from_hex( + "b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf" + .. "d0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeef" + .. "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff" + ), + length = 82, + prk = "06a6b88c5853361a06104c9ceb35b45cef760014904671014a193f40c15fc244", + okm = "b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c" + .. "59045a99cac7827271cb41c65e590e09da3275600c2f09b8367793a9aca3db71" + .. "cc30c58179ec3e87c14c01d5c1f3434f1d87", + }, + { + name = "RFC 5869 A.3 - SHA-256, zero-length salt and info", + hash = "sha256", + ikm = string_rep(string_char(0x0b), 22), + salt = "", + info = "", + length = 42, + prk = "19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04", + okm = "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8", + }, + { + name = "SHA-512, A.1 inputs", + hash = "sha512", + ikm = string_rep(string_char(0x0b), 22), + salt = bytes.from_hex("000102030405060708090a0b0c"), + info = bytes.from_hex("f0f1f2f3f4f5f6f7f8f9"), + length = 42, + prk = "665799823737ded04a88e47e54a5890bb2c3d247c7a4254a8e61350723590a26" + .. "c36238127d8661b88cf80ef802d57e2f7cebcf1e00e083848be19929c61b4237", + okm = "832390086cda71fb47625bb5ceb168e4c8e26a1a16ed34d9fc7fe92c1481579338da362cb8d9f925d7cb", + }, + { + name = "SHA-512, 80-byte inputs, 82-byte output", + hash = "sha512", + ikm = bytes.from_hex( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + .. "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f" + .. "404142434445464748494a4b4c4d4e4f" + ), + salt = bytes.from_hex( + "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f" + .. "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f" + .. "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf" + ), + info = bytes.from_hex( + "b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf" + .. "d0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeef" + .. "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff" + ), + length = 82, + prk = "35672542907d4e142c00e84499e74e1de08be86535f924e022804ad775dde27e" + .. "c86cd1e5b7d178c74489bdbeb30712beb82d4f97416c5a94ea81ebdf3e629e4a", + okm = "ce6c97192805b346e6161e821ed165673b84f400a2b514b2fe23d84cd189ddf1" + .. "b695b48cbd1c8388441137b3ce28f16aa64ba33ba466b24df6cfcb021ecff235" + .. "f6a2056ce3af1de44d572097a8505d9e7a93", + }, + { + name = "SHA-512, zero-length salt and info", + hash = "sha512", + ikm = string_rep(string_char(0x0b), 22), + salt = "", + info = "", + length = 42, + prk = "fd200c4987ac491313bd4a2a13287121247239e11c9ef82802044b66ef357e5b" + .. "194498d0682611382348572a7b1611de54764094286320578a863f36562b0df6", + okm = "f5fa02b18298a72a8c23898a8703472c6eb179dc204c03425c970e3b164bf90fff22d04836d0e2343bac", + }, + { + name = "SHA-512, multi-block output (L=160 spans 3 blocks)", + hash = "sha512", + ikm = string_rep(string_char(0x0b), 22), + salt = bytes.from_hex("000102030405060708090a0b0c"), + info = bytes.from_hex("f0f1f2f3f4f5f6f7f8f9"), + length = 160, + prk = "665799823737ded04a88e47e54a5890bb2c3d247c7a4254a8e61350723590a26" + .. "c36238127d8661b88cf80ef802d57e2f7cebcf1e00e083848be19929c61b4237", + okm = "832390086cda71fb47625bb5ceb168e4c8e26a1a16ed34d9fc7fe92c148157933" + .. "8da362cb8d9f925d7cbcce0dff7098769cf15959867d571c1715450cb530137b" + .. "e3fb62f3cf32b84feba8f1eb1b563e20d9749b8640b8264c4b69b14ad5199115" + .. "e1d609c83c6940ce5b4214a0c79946983547a35cdcc17e0daf31b647dec0d0e6" + .. "142b1deaa036b348422068ca66631c0ca5586485a276a4336e1cde0e83159b5", + -- Note the odd hex-digit alignment above: the literal is concatenated in + -- 64-char chunks and verified as a whole against the 320-char expectation. + }, + { + name = "SHA-512, single-byte output", + hash = "sha512", + ikm = string_rep(string_char(0x0b), 22), + salt = bytes.from_hex("000102030405060708090a0b0c"), + info = bytes.from_hex("f0f1f2f3f4f5f6f7f8f9"), + length = 1, + prk = "665799823737ded04a88e47e54a5890bb2c3d247c7a4254a8e61350723590a26" + .. "c36238127d8661b88cf80ef802d57e2f7cebcf1e00e083848be19929c61b4237", + okm = "83", + }, +} + +--- A reproducible 64-byte stand-in for the SRP shared secret K, used by the HAP +--- vectors below. Defined as SHA-512("FL-3 HKDF test vector K") so the input is +--- checkable rather than an opaque blob. +local HAP_K = bytes.from_hex( + "5b5f50c034f8f0828d08477b4375da348dc40f84cd11dfd3bd13b9acabd4ca26" + .. "1adbe603042792d70da90d625ecd52c63672ac835d1d0c297542cf74533d2ff6" +) + +--- HAP and Apple Companion derivations, with the exact salt and info strings the +--- protocol uses. These are the derivations the Control4 Plex driver will +--- perform, so a regression in the salt/info handling shows up here as a wrong +--- key rather than as a pairing failure on hardware. +local hap_vectors = { + { + name = "HAP Pair-Setup session key", + salt = "Pair-Setup-Encrypt-Salt", + info = "Pair-Setup-Encrypt-Info", + okm = "58c07b44c43ab9dee1997d2755c4dd5a9cef49aa115cf5d04008761a1020924d", + }, + { + name = "HAP Pair-Setup controller signing material", + salt = "Pair-Setup-Controller-Sign-Salt", + info = "Pair-Setup-Controller-Sign-Info", + okm = "cef7085a25ab4b1f458a60b3256e2c99ba9fd68d635500de44b1127a6ecc2369", + }, + { + name = "HAP Pair-Setup accessory signing material", + salt = "Pair-Setup-Accessory-Sign-Salt", + info = "Pair-Setup-Accessory-Sign-Info", + okm = "9778c08f56e300911cc8a1243eb6c6fb60cb6746cdaf6d50222b864d80193b93", + }, + { + name = "HAP Pair-Verify session key", + salt = "Pair-Verify-Encrypt-Salt", + info = "Pair-Verify-Encrypt-Info", + okm = "a5029cacb7d64c6b4e7e56320a0ea8cb9bbf0f715743f03e53c59396fe33252e", + }, + { + name = "Companion session key, ClientEncrypt-main", + salt = "Control-Salt", + info = "ClientEncrypt-main", + okm = "e47f11626348e473b07abd0a524d46a76869735feed5511b8f38aa9ecb205042", + }, + { + name = "Companion session key, ServerEncrypt-main", + salt = "Control-Salt", + info = "ServerEncrypt-main", + okm = "ff1e61252d02c50c81da50149608d7a103ae82a20b78106c6a16e48cdaeb680a", + }, +} + +--- Run comprehensive self-test with RFC 5869 and generated test vectors. +--- +--- Validates HKDF-Extract and HKDF-Expand for SHA-256 and SHA-512 against +--- known-answer tests, checks the HAP/Companion derivations the driver depends +--- on, and confirms the documented input-validation behaviour. +--- +--- @return boolean result True if all tests pass, false otherwise +function hkdf.selftest() + local passed = 0 + local total = 0 + + --- Record one test result. `result` may be a boolean or a function returning + --- one; a function that raises counts as a failure rather than aborting the run. + --- @param name string + --- @param result boolean|fun(): boolean + local function check(name, result) + total = total + 1 + if type(result) == "function" then + local ok, value = pcall(result) + result = ok and value == true + end + if result == true then + print(" ✅ PASS: " .. name) + passed = passed + 1 + else + print(" ❌ FAIL: " .. name) + end + end + + print("Running HKDF test vectors...") + for _, vector in ipairs(test_vectors) do + local prk = hkdf.extract(vector.hash, vector.salt, vector.ikm) + local prk_hex = bytes.to_hex(prk) + if prk_hex == vector.prk then + check(vector.name .. " [PRK]", true) + else + check(vector.name .. " [PRK]", false) + print(" expected: " .. vector.prk) + print(" got: " .. prk_hex) + end + + -- Expand from the PRK we just derived, so a broken extract cannot be + -- masked by expand being fed the published PRK instead. + local okm_hex = bytes.to_hex(hkdf.expand(vector.hash, prk, vector.info, vector.length)) + if okm_hex == vector.okm then + check(vector.name .. " [OKM]", true) + else + check(vector.name .. " [OKM]", false) + print(" expected: " .. vector.okm) + print(" got: " .. okm_hex) + end + + -- The one-shot helper must agree with the two-phase form. + local derived = bytes.to_hex(hkdf.derive(vector.hash, vector.salt, vector.ikm, vector.info, vector.length)) + check(vector.name .. " [derive matches extract+expand]", derived == vector.okm) + end + + print("\nRunning HAP / Companion derivation vectors...") + for _, vector in ipairs(hap_vectors) do + local okm_hex = bytes.to_hex(hkdf.hkdf_sha512(vector.salt, HAP_K, vector.info, 32)) + if okm_hex == vector.okm then + check(vector.name, true) + else + check(vector.name, false) + print(" expected: " .. vector.okm) + print(" got: " .. okm_hex) + end + end + + print("\nRunning HKDF functional tests...") + + check("absent salt behaves as a zero-filled HashLen salt", function() + return hkdf.extract("sha512", nil, "ikm") == hkdf.extract("sha512", string_rep("\0", 64), "ikm") + and hkdf.extract("sha256", "", "ikm") == hkdf.extract("sha256", string_rep("\0", 32), "ikm") + end) + + check("absent info behaves as empty info", function() + return hkdf.expand("sha512", string_rep("k", 64), nil, 32) == hkdf.expand("sha512", string_rep("k", 64), "", 32) + end) + + check("PRK length equals HashLen", function() + return #hkdf.extract("sha256", "s", "i") == 32 and #hkdf.extract("sha512", "s", "i") == 64 + end) + + check("output lengths are exact across block boundaries", function() + local prk = hkdf.extract("sha512", "salt", "ikm") + for _, length in ipairs({ 1, 63, 64, 65, 127, 128, 129, 200 }) do + if #hkdf.expand("sha512", prk, "info", length) ~= length then + return false + end + end + return true + end) + + check("shorter output is a prefix of longer output", function() + local prk = hkdf.extract("sha512", "salt", "ikm") + local long = hkdf.expand("sha512", prk, "info", 160) + return hkdf.expand("sha512", prk, "info", 32) == string_sub(long, 1, 32) + and hkdf.expand("sha512", prk, "info", 64) == string_sub(long, 1, 64) + end) + + check("distinct info yields distinct output from one PRK", function() + local prk = hkdf.extract("sha512", "Control-Salt", HAP_K) + return hkdf.expand("sha512", prk, "ClientEncrypt-main", 32) ~= hkdf.expand("sha512", prk, "ServerEncrypt-main", 32) + end) + + check("maximum permitted length is accepted", function() + local ok, result = pcall(hkdf.expand, "sha256", string_rep("k", 32), "", 255 * 32) + return ok and #result == 255 * 32 + end) + + check("length above 255*HashLen is rejected", function() + return pcall(hkdf.expand, "sha256", string_rep("k", 32), "", 255 * 32 + 1) == false + end) + + check("zero and negative lengths are rejected", function() + return pcall(hkdf.expand, "sha512", string_rep("k", 64), "", 0) == false + and pcall(hkdf.expand, "sha512", string_rep("k", 64), "", -1) == false + end) + + check("non-integer length is rejected", function() + return pcall(hkdf.expand, "sha512", string_rep("k", 64), "", 32.5) == false + end) + + check("unsupported hash name is rejected", function() + return pcall(hkdf.extract, "sha1", "salt", "ikm") == false + and pcall(hkdf.derive, "md5", "salt", "ikm", "info", 16) == false + end) + + print(string.format("\nHKDF result: %d/%d tests passed\n", passed, total)) + return passed == total +end + +--- Run performance benchmarks for HKDF operations. +function hkdf.benchmark() + local ikm = string_rep(string_char(0x0b), 32) + local salt = "Pair-Setup-Encrypt-Salt" + local info = "Pair-Setup-Encrypt-Info" + local prk512 = hkdf.extract("sha512", salt, ikm) + + print("HKDF Operations:") + benchmark_op("extract (sha256)", function() + hkdf.extract("sha256", salt, ikm) + end, 200) + + benchmark_op("extract (sha512)", function() + hkdf.extract("sha512", salt, ikm) + end, 200) + + benchmark_op("expand 32B (sha512)", function() + hkdf.expand("sha512", prk512, info, 32) + end, 200) + + benchmark_op("expand 160B (sha512)", function() + hkdf.expand("sha512", prk512, info, 160) + end, 100) + + benchmark_op("derive 32B (sha512, HAP shape)", function() + hkdf.hkdf_sha512(salt, ikm, info, 32) + end, 100) +end + +return hkdf diff --git a/src/crypto/init.lua b/src/crypto/init.lua index 246f576..b7ad931 100644 --- a/src/crypto/init.lua +++ b/src/crypto/init.lua @@ -1,16 +1,20 @@ --- @module "crypto" --- Portable cryptographic primitives for Lua with optional OpenSSL acceleration. --- Pure-Lua implementations of hashing (SHA-256/512, BLAKE2), AEAD ciphers ---- (ChaCha20-Poly1305, AES-GCM), the Poly1305 MAC, and Curve25519/448 ---- Diffie-Hellman. Runs on Lua 5.1, 5.2, 5.3, 5.4, and LuaJIT with zero C ---- dependencies. +--- (ChaCha20-Poly1305, AES-GCM), the Poly1305 MAC, HKDF key derivation, +--- Curve25519/448 Diffie-Hellman, Ed25519 signatures, and SRP-6a. Runs on Lua +--- 5.1, 5.2, 5.3, 5.4, and LuaJIT with zero C dependencies. +--- +--- Key generation draws from `crypto.random`, which uses the host CSPRNG and +--- raises when there is not one. It never falls back to `math.random`. --- --- When the host provides the lua-openssl binding (e.g. Control4 DriverWorks OS --- >= 3.4.1), hashing and AEAD transparently prefer it for speed and fall back to --- the pure-Lua implementations otherwise. The elliptic-curve Diffie-Hellman ---- functions (x25519/x448) always use the portable implementations regardless of ---- the OpenSSL flag -- the shipped lua-openssl builds cannot perform the raw ---- Curve25519/448 operations. +--- functions (x25519/x448) and Ed25519 signing always use the portable +--- implementations regardless of the OpenSSL flag -- the shipped lua-openssl +--- builds cannot perform the raw Curve25519/448 operations, and cannot sign +--- with an Ed25519 key even when they can import one. --- --- @usage --- local crypto = require("crypto") @@ -47,14 +51,38 @@ local crypto = { --- @type crypto.poly1305 poly1305 = require("crypto.poly1305"), + -- Key derivation + --- @type crypto.hkdf + hkdf = require("crypto.hkdf"), + + -- Cryptographically secure randomness (raises rather than returning weak bytes) + --- @type crypto.random + random = require("crypto.random"), + + -- Arbitrary-precision integers (OpenSSL-preferred modular exponentiation) + --- @type crypto.bignum + bignum = require("crypto.bignum"), + + -- Password-authenticated key exchange (client side) + --- @type crypto.srp + srp = require("crypto.srp"), + -- Diffie-Hellman (always pure Lua) --- @type crypto.x25519 x25519 = require("crypto.x25519"), --- @type crypto.x448 x448 = require("crypto.x448"), + + -- Digital signatures (always pure Lua) + --- @type crypto.ed25519 + ed25519 = require("crypto.ed25519"), + + -- Optional OpenSSL acceleration (exposed for diagnostics and feature queries) + --- @type crypto.openssl_wrapper + openssl_wrapper = require("crypto.openssl_wrapper"), } -local openssl_wrapper = require("crypto.openssl_wrapper") +local openssl_wrapper = crypto.openssl_wrapper --- Library version (injected at build time for releases). local VERSION = "dev" @@ -85,8 +113,14 @@ function crypto.selftest() "chacha20_poly1305", "poly1305", "aes_gcm", + "hkdf", + "random", + "bignum", + "srp", "x25519", "x448", + "ed25519", + "openssl_wrapper", } local ok = true for _, name in ipairs(modules) do diff --git a/src/crypto/openssl_wrapper.lua b/src/crypto/openssl_wrapper.lua index 3c5f892..a43501f 100644 --- a/src/crypto/openssl_wrapper.lua +++ b/src/crypto/openssl_wrapper.lua @@ -14,9 +14,11 @@ --- - ChaCha20-Poly1305 AEAD cipher --- - AES-GCM AEAD cipher --- - ChaCha20 stream cipher +--- - Big-number modular exponentiation (SRP) --- ---- Note: X25519 and X448 currently use native implementations only as they are ---- not currently supported by lua-openssl. +--- Note: X25519, X448 and Ed25519 always use the native implementations. The +--- shipped lua-openssl builds cannot perform the raw scalar-multiplication or +--- EdDSA signing operations even when they can import the keys. --- @class crypto.openssl_wrapper local openssl_wrapper = {} @@ -30,17 +32,120 @@ local openssl_wrapper = {} local OpenSSLFeature = { --- Additional Authenticated Data support for AEAD ciphers (ChaCha20-Poly1305, AES-GCM) AAD = "AAD", + --- Key-derivation primitives (`openssl.kdf`), used to accelerate HKDF + KDF = "KDF", + --- Arbitrary-precision integers (`openssl.bn`), used to accelerate modular exponentiation + BN = "BN", + --- Raw Octet Key Pair support: creating and *signing* with Ed25519/X25519 keys. + --- Importing such a key is not sufficient; the probe requires a working signature. + OKP = "OKP", + --- Cryptographically secure random bytes (`openssl.random`), used by `crypto.random`. + --- Unlike every other feature here this one has no pure-Lua fallback, so it is + --- resolved through `get_ungated` rather than `get`. + RANDOM = "RANDOM", } ---- Feature version requirements mapping +--- Feature requirement definitions --- ---- Defines the minimum OpenSSL version required for each feature to work correctly. ---- Used internally by `get()` to determine feature availability based on ---- the installed OpenSSL version. +--- Each entry declares the minimum lua-openssl version a feature needs and, +--- optionally, a `probe` that confirms the capability is actually present. +--- A version bound alone cannot answer questions like "was this build compiled +--- with `openssl.bn`?", so features whose availability varies between builds of +--- the same version carry a probe. Probes must be side-effect free and are +--- evaluated lazily, at most once per feature, only when that feature is +--- requested. --- ---- @type table -local FeatureVersions = { - [OpenSSLFeature.AAD] = "0.9.2", +--- @alias FeatureRequirement { min_version: string, probe: (fun(openssl: table): boolean)? } +--- @type table +local FeatureRequirements = { + [OpenSSLFeature.AAD] = { min_version = "0.9.2" }, + [OpenSSLFeature.KDF] = { + min_version = "0.8.0", + probe = function(openssl) + return type(openssl.kdf) == "table" and type(openssl.kdf.derive) == "function" + end, + }, + [OpenSSLFeature.BN] = { + min_version = "0.8.0", + probe = function(openssl) + local bn = openssl.bn + if type(bn) ~= "table" then + return false + end + -- Modular exponentiation is spelled `powmod` on the Control4 build + -- (lua-openssl 0.8.5, verified on hardware 2026-08-07) and `mod_exp` on + -- some others, so accept either rather than assuming one name. + local powmod = type(bn.powmod) == "function" and bn.powmod or bn.mod_exp + if type(powmod) ~= "function" or type(bn.text) ~= "function" or type(bn.tohex) ~= "function" then + return false + end + -- Exercise the exact conversion path the bignum backend uses -- big-endian + -- bytes in via bn.text, hex out via bn.tohex -- and require the right + -- answer. Presence of the names is not proof they are wired up. + local ok, result = pcall(function() + local base = bn.text(string.char(0x04)) + local exponent = bn.text(string.char(0x0d)) + local modulus = bn.text(string.char(0x01, 0xf1)) + return bn.tohex(powmod(base, exponent, modulus)) + end) + -- 4^13 mod 497 == 445 == 0x1BD + return ok and type(result) == "string" and result:gsub("^0+", ""):lower() == "1bd" + end, + }, + [OpenSSLFeature.OKP] = { + min_version = "0.8.0", + probe = function(openssl) + local pkey = openssl.pkey + if type(pkey) ~= "table" or type(pkey.new) ~= "function" then + return false + end + -- Control4's build returns nil from pkey.new("ed25519"), and even when a + -- key is imported from DER its sign() yields nil. Only a completed + -- sign/verify round-trip counts as support. + local ok, verified = pcall(function() + local key = pkey.new("ed25519") + if key == nil then + return false + end + local signature = key:sign("probe") + if signature == nil then + return false + end + return key:verify("probe", signature) == true + end) + return ok and verified == true + end, + }, + [OpenSSLFeature.RANDOM] = { + min_version = "0.8.0", + probe = function(openssl) + if type(openssl.random) ~= "function" then + return false + end + -- `rand_status` reports whether the PRNG has been seeded with enough + -- entropy. A binding that cannot answer the question is treated as + -- unseeded rather than assumed good -- for entropy the safe default is + -- "no", because the fallback is a different source, not a slower one. + if type(openssl.rand_status) ~= "function" then + return false + end + local status_ok, seeded = pcall(openssl.rand_status) + if not status_ok or seeded ~= true then + return false + end + -- Verify the `strong` flag actually yields the requested width twice, and + -- that the two draws differ: a build whose RNG is stubbed out or wired to + -- a constant passes a length check but fails this one. Two equal draws + -- from a working CSPRNG has probability 2^-256, so the false negative is + -- not a real risk. Note this consumes entropy, unlike the other probes. + local first_ok, first = pcall(openssl.random, 32, true) + if not first_ok or type(first) ~= "string" or #first ~= 32 then + return false + end + local second_ok, second = pcall(openssl.random, 32, true) + return second_ok and type(second) == "string" and #second == 32 and first ~= second + end, + }, } -- Export Feature enum for external use @@ -48,6 +153,7 @@ openssl_wrapper.Feature = OpenSSLFeature --- @type table? local _openssl_module +--- Lazily populated cache of feature support; nil means "not yet resolved". --- @type table local _openssl_module_features = {} --- True once a require("openssl") attempt has failed, to avoid re-probing every call. @@ -62,6 +168,7 @@ function openssl_wrapper.use(use) _use_openssl = use -- Re-probe availability on the next get() so toggling at runtime is safe. _openssl_module = nil + _openssl_module_features = {} _openssl_unavailable = false end @@ -97,6 +204,63 @@ local function version_supports(current_version, required_version) return false end +--- Resolve whether a single feature is supported by the loaded binding. +--- @param openssl table The loaded lua-openssl module +--- @param feature OpenSSLFeature Feature to resolve +--- @return boolean supported +local function resolve_feature(openssl, feature) + local requirement = FeatureRequirements[feature] + if not requirement then + error("Unknown feature: " .. tostring(feature)) + end + + local current_version = type(openssl.version) == "function" and openssl.version() + if type(current_version) ~= "string" then + return false + end + if not version_supports(current_version, requirement.min_version) then + return false + end + if requirement.probe then + local ok, supported = pcall(requirement.probe, openssl) + return ok and supported == true + end + return true +end + +--- Load the binding, caching both the module and a failed attempt. +--- @return table|nil openssl +local function load_binding() + if _openssl_unavailable then + return nil + end + if _openssl_module == nil then + local ok, openssl_module = pcall(require, "openssl") + if not ok or openssl_module == nil then + -- Graceful fallback: acceleration was requested but the binding is absent. + _openssl_unavailable = true + return nil + end + --- @cast openssl_module table + _openssl_module = openssl_module + _openssl_module_features = {} + end + return _openssl_module +end + +--- Resolve a feature against the loaded binding, caching the verdict. +--- @param openssl table +--- @param feature OpenSSLFeature +--- @return boolean supported +local function supports(openssl, feature) + local supported = _openssl_module_features[feature] + if supported == nil then + supported = resolve_feature(openssl, feature) + _openssl_module_features[feature] = supported + end + return supported +end + --- Get the OpenSSL module if enabled and supports required features --- --- Checks if OpenSSL is enabled and supports all specified features before @@ -108,38 +272,489 @@ end function openssl_wrapper.get(...) local required_features = { ... } - if not _use_openssl or _openssl_unavailable then + if not _use_openssl then return nil - elseif _openssl_module == nil then - local ok, openssl_module = pcall(require, "openssl") - if not ok or openssl_module == nil then - -- Graceful fallback: acceleration was requested but the binding is absent. - _openssl_unavailable = true + end + local openssl = load_binding() + if openssl == nil then + return nil + end + -- Check all requested features, resolving (and caching) each on first request. + for _, required_feature in ipairs(required_features) do + if not supports(openssl, required_feature) then return nil end - --- @cast openssl_module table - _openssl_module = openssl_module - _openssl_module_features = {} - local current_version = type(_openssl_module.version) == "function" and _openssl_module.version() - if current_version then - -- Cache all supported features - for _, feature in ipairs(OpenSSLFeature) do - local required_version = FeatureVersions[feature] - - if not required_version then - error("Unknown feature: " .. tostring(feature)) + end + return openssl +end + +--- Get the OpenSSL module for a capability that is *not* an acceleration. +--- +--- `get` deliberately honours the opt-in acceleration flag, because every +--- feature behind it has a correct pure-Lua fallback and the flag only chooses +--- which correct implementation runs. `Feature.RANDOM` is different in kind: +--- there is no portable pure-Lua substitute for a CSPRNG, so gating it on a +--- performance switch would silently trade entropy for nothing. Callers that +--- need a capability rather than a speed-up use this instead. +--- +--- @param feature OpenSSLFeature Feature the binding must support +--- @return table|nil openssl The module if available and supporting the feature; nil otherwise +function openssl_wrapper.get_ungated(feature) + local openssl = load_binding() + if openssl == nil then + return nil + end + return supports(openssl, feature) and openssl or nil +end + +--- Explain why `get(feature)` is returning nil. +--- +--- `get` collapses three different situations into one `nil`, and two of them +--- look identical to a caller while having opposite remedies: a host that +--- cannot accelerate is a fact to design around, whereas a host that has simply +--- not called `use(true)` yet is a one-line initialisation bug. The second is +--- the likely one on Control4, where `CRYPTO_USE_OPENSSL` is not set in the +--- DriverWorks environment, so a driver that forgets the call reads as "no +--- binding" for every feature on hardware that has four of them. +--- +--- @param feature OpenSSLFeature? Feature to explain; omit to ask only about the flag and the binding +--- @return string|nil reason Human-readable cause, or nil when the feature is available +function openssl_wrapper.unavailable_reason(feature) + if not _use_openssl then + return "OpenSSL acceleration is not enabled: call crypto.use_openssl(true) during initialisation " + .. "(CRYPTO_USE_OPENSSL is not set in every host environment, notably Control4 DriverWorks)" + end + local openssl = load_binding() + if openssl == nil then + return "the lua-openssl binding is not available on this host" + end + if feature ~= nil and not supports(openssl, feature) then + return "the lua-openssl binding does not support " .. tostring(feature) + end + return nil +end + +--- Report which features the currently loaded binding supports. +--- +--- Intended for diagnostics. It reports what `get` would return, so every entry +--- is false while acceleration is off, regardless of what the host can do -- +--- call `unavailable_reason` to tell that case apart from a binding that really +--- lacks the feature. +--- +--- @return table features Support map (empty when the binding is unavailable) +function openssl_wrapper.features() + local report = {} + for _, feature in pairs(OpenSSLFeature) do + report[feature] = openssl_wrapper.get(feature) ~= nil + end + return report +end + +-- ============================================================================ +-- TESTS +-- ============================================================================ + +--- Run the feature-gating self-test. +--- +--- The gate is exercised against injected stand-in bindings rather than the +--- host's real lua-openssl, so the result is identical on every machine and on +--- hosts with no binding at all. This is a regression test for a gate that +--- silently never opened: `_openssl_module_features` was populated by iterating +--- a string-keyed table with `ipairs`, which visits nothing, so every +--- `get()` returned nil and ChaCha20-Poly1305 never used OpenSSL. +--- +--- @return boolean result True if all tests pass, false otherwise +function openssl_wrapper.selftest() + print("Running OpenSSL feature-gating test vectors...") + + -- Snapshot state so the test cannot disturb a real binding or a caller's flag. + local saved_use = _use_openssl + local saved_loaded = package.loaded["openssl"] + local saved_preload = package.preload["openssl"] + + --- Install a stand-in binding and force the gate to re-resolve against it. + --- @param stub table|nil Stand-in module, or nil to simulate an absent binding + local function install(stub) + package.loaded["openssl"] = stub + if stub == nil then + -- Force require("openssl") to fail regardless of what this host actually + -- has installed, so the fallback case is deterministic everywhere. + -- Each selftest stubs the same loader independently, which the language + -- server reads as redefining one field; that is the intent here. + --- @diagnostic disable-next-line: duplicate-set-field + package.preload["openssl"] = function() + error("simulated absent binding") + end + else + package.preload["openssl"] = nil + end + -- Acceleration stays requested in both cases; absence must degrade, not throw. + openssl_wrapper.use(true) + end + + --- Build a stand-in binding reporting `version`, with optional capabilities. + --- @param version string|nil Version string returned by openssl.version() + --- @param extra table|nil Additional fields (bn, kdf, pkey, ...) + --- @return table stub + local function stub_openssl(version, extra) + local stub = { + version = function() + return version + end, + } + for key, value in pairs(extra or {}) do + stub[key] = value + end + return stub + end + + -- A `bn` table that actually computes, modelling the real binding's shape: + -- `text` parses big-endian bytes, `tohex` renders hex, and modular + -- exponentiation is available under a configurable name. + --- @param name string Which spelling of modular exponentiation to expose + --- @return table bn + local function working_bn(name) + local bn = { + text = function(str) + local value = 0 + for i = 1, #str do + value = value * 256 + string.byte(str, i) end - _openssl_module_features[feature] = version_supports(current_version, required_version) + return value + end, + tohex = function(n) + return string.format("%X", n) + end, + } + bn[name] = function(base, exponent, modulus) + local result = 1 + for _ = 1, exponent do + result = (result * base) % modulus end + return result end + return bn end - -- Check all requested features - for _, required_feature in ipairs(required_features) do - if not _openssl_module_features[required_feature] then - return nil + + local tests = { + { + name = "version-only feature resolves true on a supporting version", + test = function() + install(stub_openssl("0.9.2")) + return openssl_wrapper.get(OpenSSLFeature.AAD) ~= nil + end, + }, + { + -- Measured on a Control4 controller 2026-08-07: the shipped binding is + -- lua-openssl 0.8.5, and there `cipher:update(aad, true)` ignores the AAD + -- flag and encrypts the AAD as plaintext. The 0.9.2 floor is therefore + -- load-bearing, not decorative, and this case pins it. + name = "Control4's lua-openssl 0.8.5 does not satisfy AAD", + test = function() + install(stub_openssl("0.8.5")) + return openssl_wrapper.get(OpenSSLFeature.AAD) == nil + end, + }, + { + -- Same binding, but BN is genuinely usable there, so the two features + -- must resolve differently on one and the same host. + name = "Control4's lua-openssl 0.8.5 does satisfy BN", + test = function() + install(stub_openssl("0.8.5", { bn = working_bn("powmod") })) + return openssl_wrapper.get(OpenSSLFeature.BN) ~= nil + end, + }, + { + name = "version-only feature resolves false below the minimum", + test = function() + install(stub_openssl("0.9.1")) + return openssl_wrapper.get(OpenSSLFeature.AAD) == nil + end, + }, + { + name = "newer versions still satisfy an older minimum", + test = function() + install(stub_openssl("0.10.0")) + return openssl_wrapper.get(OpenSSLFeature.AAD) ~= nil + end, + }, + { + name = "no features requested returns the module when enabled", + test = function() + install(stub_openssl("0.9.2")) + return openssl_wrapper.get() ~= nil + end, + }, + { + name = "acceleration disabled returns nil even with a supporting binding", + test = function() + install(stub_openssl("0.9.2")) + openssl_wrapper.use(false) + return openssl_wrapper.get(OpenSSLFeature.AAD) == nil + end, + }, + { + name = "absent binding falls back gracefully", + test = function() + install(nil) + return openssl_wrapper.get(OpenSSLFeature.AAD) == nil + end, + }, + -- `get` returns the same nil for three unrelated situations. The point of + -- `unavailable_reason` is that it separates them, so each case is pinned to + -- the phrase a caller would act on rather than merely to "some string". + { + name = "unavailable_reason blames the flag, not the host, when acceleration is off", + test = function() + install(stub_openssl("0.9.2")) + openssl_wrapper.use(false) + local reason = openssl_wrapper.unavailable_reason(OpenSSLFeature.AAD) + return type(reason) == "string" and reason:find("crypto.use_openssl(true)", 1, true) ~= nil + end, + }, + { + name = "unavailable_reason blames the host when the binding is absent", + test = function() + install(nil) + local reason = openssl_wrapper.unavailable_reason(OpenSSLFeature.AAD) + return type(reason) == "string" + and reason:find("not available", 1, true) ~= nil + and reason:find("use_openssl", 1, true) == nil + end, + }, + { + name = "unavailable_reason names the feature a present binding lacks", + test = function() + install(stub_openssl("0.8.5")) + local reason = openssl_wrapper.unavailable_reason(OpenSSLFeature.AAD) + return type(reason) == "string" and reason:find("does not support AAD", 1, true) ~= nil + end, + }, + { + name = "unavailable_reason is nil when the feature is available", + test = function() + install(stub_openssl("0.9.2")) + return openssl_wrapper.unavailable_reason(OpenSSLFeature.AAD) == nil + and openssl_wrapper.unavailable_reason() == nil + end, + }, + { + name = "BN probe passes when powmod round-trips (Control4 spelling)", + test = function() + install(stub_openssl("0.9.2", { bn = working_bn("powmod") })) + return openssl_wrapper.get(OpenSSLFeature.BN) ~= nil + end, + }, + { + name = "BN probe accepts the mod_exp spelling too", + test = function() + install(stub_openssl("0.9.2", { bn = working_bn("mod_exp") })) + return openssl_wrapper.get(OpenSSLFeature.BN) ~= nil + end, + }, + { + name = "BN probe fails when bn is missing", + test = function() + install(stub_openssl("0.9.2")) + return openssl_wrapper.get(OpenSSLFeature.BN) == nil + end, + }, + { + name = "BN probe fails when the answer is wrong", + test = function() + local broken = working_bn("powmod") + broken.powmod = function() + return 0 + end + install(stub_openssl("0.9.2", { bn = broken })) + return openssl_wrapper.get(OpenSSLFeature.BN) == nil + end, + }, + { + name = "BN probe fails when modular exponentiation raises", + test = function() + local raising = working_bn("powmod") + raising.powmod = function() + error("not compiled in") + end + install(stub_openssl("0.9.2", { bn = raising })) + return openssl_wrapper.get(OpenSSLFeature.BN) == nil + end, + }, + { + name = "KDF probe requires kdf.derive", + test = function() + install(stub_openssl("0.9.2", { kdf = {} })) + local without = openssl_wrapper.get(OpenSSLFeature.KDF) == nil + install(stub_openssl("0.9.2", { kdf = { derive = function() end } })) + return without and openssl_wrapper.get(OpenSSLFeature.KDF) ~= nil + end, + }, + { + name = "OKP probe fails when pkey.new returns nil (Control4 behaviour)", + test = function() + install(stub_openssl("0.9.2", { + pkey = { + new = function() + return nil + end, + }, + })) + return openssl_wrapper.get(OpenSSLFeature.OKP) == nil + end, + }, + { + name = "OKP probe fails when sign() returns nil (imported-key behaviour)", + test = function() + install(stub_openssl("0.9.2", { + pkey = { + new = function() + return { + sign = function() + return nil + end, + verify = function() + return true + end, + } + end, + }, + })) + return openssl_wrapper.get(OpenSSLFeature.OKP) == nil + end, + }, + { + name = "OKP probe passes only on a full sign/verify round-trip", + test = function() + install(stub_openssl("0.9.2", { + pkey = { + new = function() + return { + sign = function(_, message) + return "sig:" .. message + end, + verify = function(_, message, signature) + return signature == "sig:" .. message + end, + } + end, + }, + })) + return openssl_wrapper.get(OpenSSLFeature.OKP) ~= nil + end, + }, + { + name = "all requested features must hold, not just the first", + test = function() + install(stub_openssl("0.9.2", { bn = working_bn("powmod") })) + return openssl_wrapper.get(OpenSSLFeature.AAD, OpenSSLFeature.BN) ~= nil + and openssl_wrapper.get(OpenSSLFeature.AAD, OpenSSLFeature.KDF) == nil + end, + }, + { + name = "get honours the acceleration flag, get_ungated does not", + test = function() + install(stub_openssl("0.9.2", { bn = working_bn("powmod") })) + openssl_wrapper.use(false) + -- The flag chooses between two correct implementations, so it must + -- suppress `get`. It must not be able to suppress a capability that has + -- no fallback, which is the whole reason `get_ungated` exists. + return openssl_wrapper.get(OpenSSLFeature.BN) == nil and openssl_wrapper.get_ungated(OpenSSLFeature.BN) ~= nil + end, + }, + { + name = "get_ungated still enforces the probe", + test = function() + install(stub_openssl("0.9.2")) + openssl_wrapper.use(false) + -- Ungated means "ignore the flag", not "skip the check". + return openssl_wrapper.get_ungated(OpenSSLFeature.BN) == nil + end, + }, + { + name = "RANDOM probe requires rand_status, a full width, and two distinct draws", + test = function() + --- @param overrides table Fields replacing the working RNG stub + local function rng(overrides) + local draws = 0 + local stub = { + rand_status = function() + return true + end, + random = function(n) + draws = draws + 1 + return string.rep(string.char(draws % 256), n) + end, + } + for key, value in pairs(overrides) do + stub[key] = value + end + install(stub_openssl("0.8.5", stub)) + return openssl_wrapper.get_ungated(OpenSSLFeature.RANDOM) ~= nil + end + + return rng({}) == true + -- Missing rand_status: unverifiable seeding is treated as unseeded. + and rng({ rand_status = false }) == false + and rng({ + rand_status = function() + return false + end, + }) == false + -- A constant RNG passes a length check but not a distinctness one. + and rng({ + random = function(n) + return string.rep("\0", n) + end, + }) == false + -- A short read must not count as support. + and rng({ + random = function(n) + return string.rep("\0", n - 1) + end, + }) == false + and rng({ random = false }) == false + end, + }, + { + name = "unknown features are rejected, not silently granted", + test = function() + install(stub_openssl("0.9.2")) + local ok = pcall(openssl_wrapper.get, "NOT_A_FEATURE") + return ok == false + end, + }, + { + name = "every declared feature has a requirement entry", + test = function() + for _, feature in pairs(OpenSSLFeature) do + if FeatureRequirements[feature] == nil then + return false + end + end + return true + end, + }, + } + + local passed = 0 + for _, test in ipairs(tests) do + local ok, result = pcall(test.test) + if ok and result == true then + print(" ✅ PASS: " .. test.name) + passed = passed + 1 + else + print(" ❌ FAIL: " .. test.name .. (ok and "" or (" - " .. tostring(result)))) end end - return _openssl_module + + -- Restore the environment for subsequent tests in the same process. + package.loaded["openssl"] = saved_loaded + package.preload["openssl"] = saved_preload + openssl_wrapper.use(saved_use) + + print(string.format("\nOpenSSL feature-gating result: %d/%d tests passed\n", passed, #tests)) + return passed == #tests end return openssl_wrapper diff --git a/src/crypto/random.lua b/src/crypto/random.lua new file mode 100644 index 0000000..081b3c2 --- /dev/null +++ b/src/crypto/random.lua @@ -0,0 +1,485 @@ +--- @module "crypto.random" +--- Cryptographically secure random bytes -- or a hard failure. +--- +--- Every key this library can generate is only as good as the bytes underneath +--- it. `math.random` is not a CSPRNG on any Lua implementation: on 5.1 and +--- LuaJIT it is C `rand()`, and on 5.4+ an explicit low-entropy +--- `math.randomseed` actively *downgrades* a generator the runtime had already +--- seeded well. Seeding from `os.time()` and `os.clock()` at driver startup is +--- worth roughly 20 bits, which is an offline brute force measured in seconds. +--- +--- So this module has exactly one rule, and it is the reason it exists: +--- +--- > **It returns strong bytes or it raises. It never returns weak bytes.** +--- +--- A HAP driver that fails to pair is a bug report. A HAP driver that pairs with +--- a guessable long-term identity is a compromised controller that looks fine. +--- +--- Sources, in order: +--- +--- 1. `openssl.random(n, true)` -- the `strong` flag, behind `Feature.RANDOM`, +--- which probes `rand_status()` and two real draws rather than assuming. +--- 2. `/dev/urandom`. +--- 3. Nothing. `bytes()` raises. +--- +--- Unlike the accelerated primitives, source 1 is resolved through +--- `openssl_wrapper.get_ungated`: `crypto.use_openssl(false)` selects a slower +--- implementation everywhere else in the library, and it must not be capable of +--- selecting a *weaker* one here. +--- +--- Verified on a Control4 controller (dev, 2026-08-08): the shipped lua-openssl +--- 0.8.5 has `random` and `rand_status`, `rand_status()` is true, and +--- `random(n, true)` returns n distinct bytes. `/dev/urandom` is also readable +--- from inside the driver sandbox, so both sources are live on the target +--- hardware and neither one is theoretical. Note `random(0)` and negative +--- lengths raise on that build, which is why `bytes` validates `n` first. +--- +--- @usage +--- local random = require("crypto.random") +--- +--- local seed = random.bytes(32) -- raises if there is no strong source +--- +--- -- Platforms with neither source can supply their own: +--- random.set_source(function(n) return my_platform_csprng(n) end, "platform") +--- +--- @class crypto.random +local random = {} + +local openssl_wrapper = require("crypto.openssl_wrapper") + +--- Path read by the `/dev/urandom` source. Exposed only so the self-test can +--- point it at a file that does not exist and prove the failure path. +--- @type string +random._urandom_path = "/dev/urandom" + +--- Resolved source: a function taking a byte count and returning a byte string. +--- @type (fun(n: integer): string|nil)|nil +local _source = nil +--- Name of the resolved source, for `random.source()`. +--- @type string|nil +local _source_name = nil +--- True once resolution has run, so a negative result is not re-probed per call. +local _resolved = false + +--- Draw from the lua-openssl binding. +--- @param n integer +--- @return string|nil +local function openssl_source(n) + local openssl = openssl_wrapper.get_ungated(openssl_wrapper.Feature.RANDOM) + if openssl == nil then + return nil + end + local ok, out = pcall(openssl.random, n, true) + if not ok then + return nil + end + return out +end + +--- Draw from `/dev/urandom`. +--- +--- The handle is opened per call rather than held: key generation is rare, and a +--- long-lived Control4 driver holding a file descriptor open for the life of the +--- process is a worse trade than one `open` per key. +--- @param n integer +--- @return string|nil +local function urandom_source(n) + local ok, handle = pcall(io.open, random._urandom_path, "rb") + if not ok or handle == nil then + return nil + end + local read_ok, out = pcall(handle.read, handle, n) + handle:close() + if not read_ok then + return nil + end + return out +end + +--- Pick the first source that actually produces bytes. +--- +--- Each candidate is *exercised*, not merely detected: a source that exists but +--- returns nil or a short read is rejected here rather than at the call site. +local function resolve() + if _resolved then + return + end + _resolved = true + local candidates = { + { name = "openssl", draw = openssl_source }, + { name = "urandom", draw = urandom_source }, + } + for _, candidate in ipairs(candidates) do + local ok, sample = pcall(candidate.draw, 32) + if ok and type(sample) == "string" and #sample == 32 then + _source = candidate.draw + _source_name = candidate.name + return + end + end +end + +--- Name of the entropy source in use, resolving one if needed. +--- +--- Intended for preconditions and diagnostics: a driver can refuse to start +--- pairing when this returns nil instead of discovering it mid-handshake. +--- @return string|nil name "openssl", "urandom", a custom source's name, or nil +function random.source() + resolve() + return _source_name +end + +--- Whether a cryptographically secure source is available. +--- @return boolean available +function random.available() + return random.source() ~= nil +end + +--- Install a custom entropy source, overriding detection. +--- +--- The escape hatch for a platform with neither lua-openssl nor `/dev/urandom`. +--- The function must return exactly `n` bytes; a short or non-string return is +--- rejected by `bytes()` the same way a failing built-in source would be, so a +--- broken custom source cannot quietly weaken key generation. +--- +--- @param draw fun(n: integer): string|nil Returns exactly n cryptographically secure bytes +--- @param name string|nil Label reported by `random.source()` (default "custom") +function random.set_source(draw, name) + assert(type(draw) == "function", "crypto.random: source must be a function") + _source = draw + _source_name = name or "custom" + _resolved = true +end + +--- Discard the current source and re-detect on next use. +function random.reset() + _source = nil + _source_name = nil + _resolved = false +end + +--- Generate `n` cryptographically secure random bytes. +--- +--- Raises if no strong source is available, or if the source returns anything +--- other than exactly `n` bytes. It never falls back to a weak generator and +--- never returns a short result. +--- +--- @param n integer Number of bytes, must be a positive integer +--- @return string bytes Exactly n cryptographically secure bytes +function random.bytes(n) + assert( + type(n) == "number" and n > 0 and n % 1 == 0, + "crypto.random: byte count must be a positive integer, got " .. tostring(n) + ) + resolve() + if _source == nil then + error( + "crypto.random: no cryptographically secure entropy source available " + .. "(tried lua-openssl RAND and " + .. random._urandom_path + .. "). Refusing to generate a key from a weak generator -- supply a " + .. "source with crypto.random.set_source(fn)." + ) + end + local ok, out = pcall(_source, n) + if not ok then + error("crypto.random: entropy source '" .. tostring(_source_name) .. "' failed: " .. tostring(out)) + end + if type(out) ~= "string" or #out ~= n then + error( + "crypto.random: entropy source '" + .. tostring(_source_name) + .. "' returned " + .. (type(out) == "string" and (#out .. " bytes") or type(out)) + .. " instead of " + .. n + .. " bytes" + ) + end + return out +end + +-- ============================================================================ +-- TESTS +-- ============================================================================ + +--- Run the entropy-source self-test. +--- +--- The contract under test is a negative one -- "never returns weak bytes" -- so +--- most of these assert that something *fails*. The no-source case is forced +--- deterministically (absent binding plus a `_urandom_path` that cannot exist) +--- rather than skipped on hosts that happen to have entropy, because that is the +--- single case where a regression is silent and catastrophic. +--- +--- @return boolean result True if all tests pass, false otherwise +function random.selftest() + print("Running crypto.random entropy-source test vectors...") + + local saved_path = random._urandom_path + local saved_preload = package.preload["openssl"] + local saved_loaded = package.loaded["openssl"] + + --- Force both built-in sources to be unavailable. + local function starve() + random.reset() + package.loaded["openssl"] = nil + -- Each selftest stubs the same loader independently, which the language + -- server reads as redefining one field; that is the intent here. + --- @diagnostic disable-next-line: duplicate-set-field + package.preload["openssl"] = function() + error("simulated absent binding") + end + openssl_wrapper.use(false) -- resets the wrapper's binding cache + random._urandom_path = "/nonexistent/crypto-random-selftest" + end + + --- Restore real detection, including the wrapper's initial opt-in state, so + --- running this inside `crypto.selftest()` cannot disturb a later module. + local function unstarve() + random.reset() + package.loaded["openssl"] = saved_loaded + package.preload["openssl"] = saved_preload + openssl_wrapper.use(os.getenv("CRYPTO_USE_OPENSSL") == "1" or os.getenv("CRYPTO_USE_OPENSSL") == "true") + random._urandom_path = saved_path + end + + --- Install a source returning a fixed, known byte. + --- @param byte integer + local function fixed_source(byte) + random.set_source(function(n) + return string.rep(string.char(byte), n) + end, "fixed") + end + + local tests = { + { + name = "bytes() returns the requested width", + test = function() + unstarve() + if not random.available() then + -- No entropy on this host: the contract is still testable, and the + -- required behaviour is a raise rather than a weak result. + return random.bytes(32) == nil + end + return #random.bytes(1) == 1 and #random.bytes(32) == 32 and #random.bytes(384) == 384 + end, + }, + { + name = "two draws differ", + test = function() + unstarve() + if not random.available() then + return true + end + return random.bytes(32) ~= random.bytes(32) + end, + }, + { + name = "no source available raises instead of returning weak bytes", + test = function() + starve() + if random.available() then + return false + end + local ok, err = pcall(random.bytes, 32) + return ok == false and tostring(err):find("no cryptographically secure") ~= nil + end, + }, + { + name = "a source returning short output is rejected, not passed through", + test = function() + random.set_source(function(n) + return string.rep("A", n - 1) + end, "short") + local ok, err = pcall(random.bytes, 32) + return ok == false and tostring(err):find("31 bytes") ~= nil + end, + }, + { + name = "a source returning nil is rejected", + test = function() + random.set_source(function() + return nil + end, "nilsource") + return pcall(random.bytes, 32) == false + end, + }, + { + name = "a raising source is reported, not swallowed", + test = function() + random.set_source(function() + error("device gone") + end, "raising") + local ok, err = pcall(random.bytes, 32) + return ok == false and tostring(err):find("device gone") ~= nil + end, + }, + { + name = "a custom source is used verbatim", + test = function() + fixed_source(122) + return random.source() == "fixed" and random.bytes(4) == "zzzz" + end, + }, + { + name = "reset() restores detection", + test = function() + fixed_source(122) + random.reset() + unstarve() + return random.source() ~= "fixed" + end, + }, + { + name = "non-positive and fractional widths are rejected", + test = function() + unstarve() + return pcall(random.bytes, 0) == false + and pcall(random.bytes, -1) == false + and pcall(random.bytes, 1.5) == false + and pcall(random.bytes, "32") == false + end, + }, + { + name = "openssl is preferred over urandom when the binding supports RANDOM", + test = function() + random.reset() + local draws = 0 + package.preload["openssl"] = nil + package.loaded["openssl"] = { + version = function() + return "0.8.5" + end, + rand_status = function() + return true + end, + random = function(n) + draws = draws + 1 + -- Distinct per call so the RANDOM probe's two-draw check passes. + return string.rep(string.char(draws % 256), n) + end, + } + openssl_wrapper.use(false) -- reset the binding cache; RANDOM is ungated + local name = random.source() + return name == "openssl" and draws > 0 + end, + }, + { + name = "a binding whose rand_status is false is not used", + test = function() + random.reset() + package.preload["openssl"] = nil + package.loaded["openssl"] = { + version = function() + return "0.8.5" + end, + rand_status = function() + return false + end, + random = function(n) + return string.rep("\0", n) + end, + } + openssl_wrapper.use(false) + random._urandom_path = "/nonexistent/crypto-random-selftest" + return random.available() == false + end, + }, + { + name = "a binding whose RNG returns a constant is not used", + test = function() + random.reset() + package.preload["openssl"] = nil + package.loaded["openssl"] = { + version = function() + return "0.8.5" + end, + rand_status = function() + return true + end, + random = function(n) + return string.rep("\0", n) + end, + } + openssl_wrapper.use(false) + random._urandom_path = "/nonexistent/crypto-random-selftest" + return random.available() == false + end, + }, + { + -- Required lazily: those modules depend on crypto.random, not the other + -- way round, and this check belongs with the guarantee it protects. + name = "key generators return exactly the bytes this module supplied", + test = function() + local generators = { + { require("crypto.ed25519").generate_private_key, 32 }, + { require("crypto.x25519").generate_private_key, 32 }, + { require("crypto.x448").generate_private_key, 56 }, + } + for _, entry in ipairs(generators) do + local generate, width = entry[1], entry[2] + fixed_source(42) + local out = generate() + if type(out) ~= "string" or out ~= string.rep(string.char(42), width) then + return false + end + end + return true + end, + }, + { + -- Checked by width rather than by output: `get_public` would otherwise + -- have to run a 3072-bit modular exponentiation to prove the point. + name = "srp draws its private exponent here, 32 bytes wide", + test = function() + local requested = nil + random.set_source(function(n) + requested = n + error("stop before the modexp") + end, "recording") + local session = require("crypto.srp").new({ username = "Pair-Setup", password = "123-45-678" }) + pcall(session.get_public, session) + return requested == 32 + end, + }, + { + name = "every key generator raises when there is no entropy source", + test = function() + local session = require("crypto.srp").new({ username = "Pair-Setup", password = "123-45-678" }) + local generators = { + require("crypto.ed25519").generate_private_key, + require("crypto.x25519").generate_private_key, + require("crypto.x448").generate_private_key, + function() + return session:get_public() + end, + } + for _, generate in ipairs(generators) do + starve() + if pcall(generate) ~= false then + return false + end + end + return true + end, + }, + } + + local passed = 0 + for _, test in ipairs(tests) do + local ok, result = pcall(test.test) + if ok and result == true then + print(" ✅ PASS: " .. test.name) + passed = passed + 1 + else + print(" ❌ FAIL: " .. test.name .. (ok and "" or (" - " .. tostring(result)))) + end + end + + unstarve() + + print(string.format("\ncrypto.random result: %d/%d tests passed\n", passed, #tests)) + return passed == #tests +end + +return random diff --git a/src/crypto/srp.lua b/src/crypto/srp.lua new file mode 100644 index 0000000..cc62704 --- /dev/null +++ b/src/crypto/srp.lua @@ -0,0 +1,879 @@ +--- @module "crypto.srp" +--- SRP-6a **client**, parameterised by group and hash (RFC 5054 / RFC 2945). +--- +--- This module implements the *client* half of SRP-6a and nothing else. It +--- derives the client public value `A`, the premaster secret `S`, the session +--- key `K` and the client proof `M1`, and it verifies the server proof `M2`. +--- There is deliberately no server side: nothing here derives `B` from a +--- verifier, and nothing validates a client proof. Do not assume `crypto.srp` +--- can stand in for an SRP server. +--- +--- The default parameter set is `srp.GROUP_3072` -- RFC 5054 Appendix A group 15 +--- (the 3072-bit MODP safe prime, g = 5) with SHA-512. That is the parameter set +--- HomeKit Accessory Protocol Pair-Setup uses, with `I = "Pair-Setup"` and `P` +--- the setup code shown on the accessory. +--- +--- Encoding conventions +--- -------------------- +--- The conventions below follow `srptools`, the library pyatv drives for HAP +--- Pair-Setup, so they interoperate with a real accessory. They are *not* a +--- naive reading of RFC 5054, and every one of them is load-bearing: +--- +--- * `PAD(x)` is big-endian, left-zero-padded to the byte length of `N` +--- (384 bytes for group 15). It is applied in exactly two places: +--- `k = H(N | PAD(g))` and `u = H(PAD(A) | PAD(B))`. +--- * Everywhere else an integer is hashed in **minimal** big-endian form, with +--- leading zero bytes stripped -- notably `N`, `g`, `A` and `B` inside `M1`, +--- and `S` inside `K`. +--- * The salt is hashed as the **raw bytes the server supplied**, never +--- re-encoded through an integer. A salt whose leading byte is zero keeps that +--- byte. Routing it through an integer silently shortens it by one byte and +--- produces a ~1-in-256 intermittent pairing failure rather than an obvious +--- break, so the third known-answer vector pins this specifically. +--- +--- @usage +--- local srp = require("crypto.srp") +--- +--- local session = srp.new({ username = "Pair-Setup", password = setup_code }) +--- send(session:get_public()) -- A, 384 bytes +--- session:process(salt, B) -- the server's s (raw bytes) and B +--- send(session:get_proof()) -- M1, 64 bytes +--- assert(session:verify(server_M2), "server proof rejected") +--- local key = session:get_session_key() -- K, 64 bytes +--- +--- @class crypto.srp +local srp = {} + +local bignum = require("crypto.bignum") +local random = require("crypto.random") +local sha256 = require("crypto.sha256") +local sha512 = require("crypto.sha512") + +local utils = require("crypto.utils") +local bytes = utils.bytes +local benchmark_op = utils.benchmark.benchmark_op + +-- Local references for performance +local string_char = string.char +local string_rep = string.rep +local table_concat = table.concat + +-- ============================================================================ +-- GROUPS AND HASHES +-- ============================================================================ + +--- RFC 5054 Appendix A / RFC 3526 group 15: the 3072-bit MODP safe prime. +local RFC5054_3072_N_HEX = table_concat({ + "ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74", + "020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f1437", + "4fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7ed", + "ee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf05", + "98da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb", + "9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3b", + "e39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf695581718", + "3995497cea956ae515d2261898fa051015728e5a8aaac42dad33170d04507a33", + "a85521abdf1cba64ecfb850458dbef0a8aea71575d060c7db3970f85a6e1e4c7", + "abf5ae8cdb0933d71e8c94e04a25619dcee3d2261ad2ee6bf12ffa06d98a0864", + "d87602733ec86a64521f2b18177b200cbbe117577a615d6c770988c0bad946e2", + "08e24fa074e5ab3143db5bfce0fd108e4b82d120a93ad2caffffffffffffffff", +}) + +--- An SRP group: the safe prime `N` as a hex string, the generator `g`, and the +--- hash `srp.new` defaults to when the caller does not name one. +--- @alias SrpGroup { name: string, N: string, g: integer|string, hash: string } + +--- RFC 5054 group 15 (3072-bit) with g = 5 and SHA-512 -- the HAP Pair-Setup +--- parameter set, and the default for `srp.new`. +--- @type SrpGroup +srp.GROUP_3072 = { + name = "RFC 5054 group 15 (3072-bit)", + N = RFC5054_3072_N_HEX, + g = 5, + hash = "sha512", +} + +--- A hash usable as SRP's `H`: a name for diagnostics and a function taking a +--- byte string to a raw digest. +--- @alias SrpHash { name: string, hash: fun(data: string): string } + +--- Hashes `srp.new` accepts by name. +--- @type table +local HASHES = { + sha256 = { name = "sha256", hash = sha256.sha256 }, + sha512 = { name = "sha512", hash = sha512.sha512 }, +} + +--- Bytes of client private exponent `a` generated when the caller does not +--- supply one. RFC 5054 section 3.1 requires at least 256 bits. +local PRIVATE_BYTES = 32 + +-- ============================================================================ +-- INTERNAL HELPERS +-- ============================================================================ + +--- Parsed form of a group, memoized so the 3072-bit prime is decoded once. +--- Keyed weakly on the group table itself, so a caller-supplied group does not +--- pin its parsed form forever. +--- @type table +local group_cache = setmetatable({}, { __mode = "k" }) + +--- Decode a group into the values every session needs. +--- +--- `N_min` / `g_min` are the minimal big-endian encodings used inside `M1`, and +--- `g_pad` is `PAD(g)` as used inside `k`; both forms are precomputed because +--- the difference between them is exactly the convention this module has to get +--- right. +--- +--- @param group SrpGroup Group description +--- @return table params Fields: N, g, n_bytes, N_min, g_min, g_pad, derived +local function resolve_group(group) + assert(type(group) == "table", "SRP: group must be a table") + local params = group_cache[group] + if params then + return params + end + + assert(type(group.N) == "string", "SRP: group.N must be a hex string") + local N = bignum.from_hex(group.N) + assert(not bignum.is_zero(N), "SRP: group modulus N must be non-zero") + + local g + if type(group.g) == "number" then + g = bignum.from_number(group.g) + else + assert(type(group.g) == "string", "SRP: group.g must be a number or a hex string") + g = bignum.from_hex(group.g) + end + assert(not bignum.is_zero(g), "SRP: group generator g must be non-zero") + + local n_bytes = bignum.byte_length(N) + params = { + N = N, + g = g, + n_bytes = n_bytes, + N_min = bignum.to_bytes(N), + g_min = bignum.to_bytes(g), + g_pad = bignum.to_bytes(g, n_bytes), + -- Per-hash constants, keyed weakly on the hash table. + derived = setmetatable({}, { __mode = "k" }), + } + group_cache[group] = params + return params +end + +--- Compute the group/hash constants that do not depend on the session. +--- +--- `k = H(N | PAD(g))` is the SRP-6a multiplier; `hn_xor_hg = H(N) XOR H(g)` is +--- the first term of `M1`. Note the asymmetry that trips people up: `g` is +--- padded inside `k` but minimal inside `H(g)`. +--- +--- @param params table Parsed group from `resolve_group` +--- @param hash SrpHash Hash in use +--- @return table constants Fields: k (BigNum), hn_xor_hg (string) +local function derive_constants(params, hash) + local constants = params.derived[hash] + if constants then + return constants + end + local h = hash.hash + constants = { + k = bignum.from_bytes(h(params.N_min .. params.g_pad)), + hn_xor_hg = bytes.xor_bytes(h(params.N_min), h(params.g_min)), + } + params.derived[hash] = constants + return constants +end + +--- Resolve `opts.hash` to a hash table. +--- @param spec string|SrpHash Registered name, or a custom `{ name, hash }` +--- @return SrpHash hash +local function resolve_hash(spec) + if type(spec) == "table" then + assert(type(spec.name) == "string", "SRP: custom hash needs a string 'name'") + assert(type(spec.hash) == "function", "SRP: custom hash needs a function 'hash'") + return spec + end + local entry = HASHES[spec] + assert(entry, "SRP: unsupported hash '" .. tostring(spec) .. "'") + return entry +end + +-- ============================================================================ +-- SESSION +-- ============================================================================ + +--- One client-side SRP-6a exchange. +--- +--- Derived values are kept as fields so a failing exchange can be localised to a +--- single step: `k`, `x`, `v`, `u` and `S` are BigNums, `K`, `M1` and `M2` are +--- raw digests. Treat them as read-only; the accessors below are the supported +--- interface. +--- +--- @class crypto.srp.Session +--- @field group SrpGroup Group in use +--- @field params table Parsed group from `resolve_group` +--- @field hash SrpHash Hash in use +--- @field username string Identity `I` +--- @field password string Password `P` +local Session = {} +Session.__index = Session + +--- Set the client private exponent `a` explicitly. +--- +--- Only needed for deterministic tests and for protocols that derive `a` from +--- existing key material; otherwise `get_public()` generates one. Discards any +--- previously computed `A`. +--- +--- @param a string Private exponent as big-endian bytes +--- @return crypto.srp.Session self For chaining +function Session:set_private(a) + assert(type(a) == "string" and #a > 0, "SRP: private exponent must be a non-empty byte string") + local value = bignum.from_bytes(a) + assert(not bignum.is_zero(bignum.mod(value, self.params.N)), "SRP: private exponent must not be 0 mod N") + self.a = value + self.A = nil + self.A_bytes = nil + return self +end + +--- Client public value `A = g^a mod N`. +--- +--- Generates a random `a` on first call if `set_private` was not used, then +--- caches `A` for the life of the session. +--- +--- @return string A Big-endian bytes, left-padded to the byte length of N +function Session:get_public() + if not self.A_bytes then + if not self.a then + -- `crypto.random` raises when the host has no CSPRNG rather than handing + -- back a guessable `a`: recovering `a` recovers `S`, therefore `K`, + -- therefore the session, and permits an offline attack on the setup code. + self:set_private(random.bytes(PRIVATE_BYTES)) + end + local params = self.params + local A = bignum.mod_exp(params.g, self.a, params.N) + assert(not bignum.is_zero(A), "SRP: computed client public value A is zero mod N") + self.A = A + self.A_bytes = bignum.to_bytes(A, params.n_bytes) + end + return self.A_bytes +end + +--- Process the server's salt and public value, deriving the whole exchange. +--- +--- Computes `x`, `v`, `u`, `S`, `K`, `M1` and `M2`. Aborts -- as RFC 5054 +--- section 2.5.4 requires -- if `B mod N == 0` or if `u == 0`, either of which +--- would let a malicious server fix the premaster secret. +--- +--- `salt` is used byte for byte, exactly as received. It is not an integer and +--- must not be normalised into one. +--- +--- @param salt string Server salt `s`, raw bytes +--- @param B string Server public value `B`, big-endian bytes +--- @return crypto.srp.Session self For chaining +function Session:process(salt, B) + assert(type(salt) == "string", "SRP: salt must be a byte string") + assert(type(B) == "string" and #B > 0, "SRP: server public value B must be a non-empty byte string") + + local params = self.params + local N = params.N + local h = self.hash.hash + + -- B is kept exactly as supplied for hashing; only the safety check reduces it. + local B_value = bignum.from_bytes(B) + if bignum.is_zero(bignum.mod(B_value, N)) then + error("SRP: server public value B is 0 mod N; aborting per RFC 5054", 2) + end + + local A_bytes = self:get_public() + local A_value = self.A + + -- u = H(PAD(A) | PAD(B)). Checked before the expensive exponentiations so a + -- degenerate server is rejected without doing the work. + local u = bignum.from_bytes(h(A_bytes .. bignum.to_bytes(B_value, params.n_bytes))) + if bignum.is_zero(u) then + error("SRP: scrambling parameter u is 0; aborting per RFC 5054", 2) + end + + local constants = derive_constants(params, self.hash) + local k = constants.k + + -- x = H(s | H(I | ":" | P)). `salt` goes in raw: a leading zero byte is part + -- of the salt, not padding to be stripped. + local x = bignum.from_bytes(h(salt .. h(self.username .. ":" .. self.password))) + + -- v = g^x mod N, then S = (B - k*v)^(a + u*x) mod N. The subtraction can go + -- negative, so it goes through mod_sub to land on a non-negative residue. + local v = bignum.mod_exp(params.g, x, N) + local base = bignum.mod_sub(B_value, bignum.mod_mul(k, v, N), N) + local S = bignum.mod_exp(base, bignum.add(self.a, bignum.mul(u, x)), N) + + -- K, M1 and M2 all take S, A and B in minimal form, and the salt raw. + local K = h(bignum.to_bytes(S)) + local A_min = bignum.to_bytes(A_value) + local M1 = h(table_concat({ + constants.hn_xor_hg, + h(self.username), + salt, + A_min, + bignum.to_bytes(B_value), + K, + })) + local M2 = h(A_min .. M1 .. K) + + self.salt, self.B = salt, B_value + self.k, self.x, self.v, self.u, self.S = k, x, v, u, S + self.K, self.M1, self.M2 = K, M1, M2 + return self +end + +--- Client proof `M1 = H(H(N) XOR H(g) | H(I) | s | A | B | K)`. +--- @return string M1 Raw digest (64 bytes for SHA-512) +function Session:get_proof() + assert(self.M1, "SRP: call process(salt, B) before get_proof()") + return self.M1 +end + +--- Verify the server proof `M2 = H(A | M1 | K)`. +--- +--- The comparison is constant time, so a wrong proof leaks no information about +--- how much of it was right. +--- +--- @param M2 string Server proof, raw digest +--- @return boolean valid True when the proof matches +function Session:verify(M2) + assert(self.M2, "SRP: call process(salt, B) before verify()") + if type(M2) ~= "string" then + return false + end + return bytes.constant_time_compare(self.M2, M2) +end + +--- Shared session key `K = H(S)`. +--- @return string K Raw digest (64 bytes for SHA-512) +function Session:get_session_key() + assert(self.K, "SRP: call process(salt, B) before get_session_key()") + return self.K +end + +-- ============================================================================ +-- SRP PUBLIC INTERFACE +-- ============================================================================ + +--- Create a client session. +--- +--- @param opts { group?: SrpGroup, hash?: string|SrpHash, username: string, password: string } +--- `group` defaults to `srp.GROUP_3072`; `hash` defaults to the group's own +--- hash name and may be `"sha256"`, `"sha512"`, or a custom `{ name, hash }`. +--- `username` is `I` (`"Pair-Setup"` for HAP) and `password` is `P`. +--- @return crypto.srp.Session session +function srp.new(opts) + assert(type(opts) == "table", "SRP: srp.new requires an options table") + assert(type(opts.username) == "string", "SRP: username (I) must be a string") + assert(type(opts.password) == "string", "SRP: password (P) must be a string") + + local group = opts.group or srp.GROUP_3072 + local params = resolve_group(group) + local hash = resolve_hash(opts.hash or group.hash or "sha512") + + return setmetatable({ + group = group, + params = params, + hash = hash, + username = opts.username, + password = opts.password, + }, Session) +end + +--- Whether an exchange will run at usable speed on this host. +--- +--- SRP-6a's cost is dominated by modular exponentiation over the group modulus, +--- and for the 3072-bit HAP group the gap between backends is not a matter of +--- taste. Measured on a Control4 controller (2026-08-07): `bn.powmod` takes +--- 5.08 ms, while the pure-Lua path extrapolates to roughly 176 s for the +--- 256-bit client exponent -- a factor of about 34,000. Worse, Lua execution is +--- effectively serialised across drivers there, so an unaccelerated exchange +--- does not merely run slowly, it blocks the controller until the watchdog +--- resets the driver. +--- +--- This is deliberately advisory. `crypto.bignum` stays portable and will +--- compute the same answer either way, because the pure path is what makes the +--- test suite runnable everywhere. But a HAP driver should check this before +--- starting Pair-Setup rather than discovering it by hanging, and it should not +--- have to reach through `openssl_wrapper.features()` into another module's +--- internals to do so. +--- +--- The second return value says *why* an exchange would be slow, which matters +--- because "this host has no usable binding" and "nobody called +--- `crypto.use_openssl(true)`" are the same `false` and have nothing else in +--- common. Fail closed on the boolean; log the reason. +--- +--- @return boolean accelerated True if modular exponentiation uses OpenSSL +--- @return string|nil reason Why it does not, when it does not +function srp.is_accelerated() + return bignum.is_accelerated() +end + +-- ============================================================================ +-- TEST VECTORS AND VALIDATION +-- ============================================================================ + +-- Generated by tools/generate_srp_vectors.py -- do not edit by hand. +-- Source of truth: srptools, the library pyatv drives for HAP Pair-Setup. +local srp_vectors = { + { + name = "HAP Pair-Setup, 8-digit PIN", + password = "123-45-678", + salt = "beb25379d1a8581eb5a727673a2441ee", + a = "60975527035cf2ad1989806f0407210bc81edc04e2762a56afd529ddda2d4393", + b = "e487cb59d31ac550471e81f00f6928e01dda08e974a004f49e61f5d105284d20", + k = "a9c2e2559bf0ebb53f0cbbf62282906bede7f2182f00678211fbd5bde5b28503" + .. "3a4993503b87397f9be5ec02080fedbc0835587ad039060879b8621e8c3659e0", + x = "f63012102e042051ad49d9598ddd7d1f7f05b9306fb5c4011eb2f9410f36d036" + .. "9ffad9c644fdf308fcb7c09bf56dc2c0bb9e4e942f574e0786386a124f2bcde4", + v = "cfe3853f15657e2ee3638ffb7a7743c76cc1f85c0d7fcf2db85172c77800eda2" + .. "19e0e4fa98c95cb7634d4a35e8c74d6f728cf3864990c4e93c32f5120d71da56" + .. "ecf3711a02e9e0727cef62920e815306cc4c4375a40991b9e074a69fbf06986b" + .. "49c92edcda1e9a35ed4ef0b7d4351c1cc8c87844c560e1942e24438e50b04cbb" + .. "888674500e792449173598045dd1cba0c2128b2661f03d4417b29b4426a732b9" + .. "ce929abc431f561241633e095cc3b03976ed2bdc39ca2fd5265dade7cd04dd84" + .. "122278f190edc86c6ae9e1cedc03ec6d97218b7d587d47286678d305a5168c07" + .. "873d79ac72a42fe39235791a0f2841c68b2a89f5acb512a799e5842dca9eeaa3" + .. "b21e9ff5072e94aecb8f3572a540a65e577407bf0bed9a1b32f32c7161f675b4" + .. "a2e29353fac3463989f45522e6c0903154fbc51ba29079af6f7330ad596ea4b9" + .. "5b3533e3c2f78fec6cb3946e9e7d5dabb67d5605ea517eea2c2fc18552b80e3f" + .. "7b97168d2a9eb635205c534c46ecc51a052a4ff8ae5eb199129720e8c0ffaedd", + A = "fab6f5d2615d1e323512e7991cc37443f487da604ca8c9230fcb04e541dce628" + .. "0b27ca4680b0374f179dc3bdc7553fe62459798c701ad864a91390a28c93b644" + .. "adbf9c00745b942b79f9012a21b9b78782319d83a1f8362866fbd6f46bfc0ddb" + .. "2e1ab6e4b45a9906b82e37f05d6f97f6a3eb6e182079759c4f6847837b62321a" + .. "c1b4fa68641fcb4bb98dd697a0c73641385f4bab25b793584cc39fc8d48d4bd8" + .. "67a9a3c10f8ea12170268e34fe3bbe6ff89998d60da2f3e4283cbec1393d52af" + .. "724a57230c604e9fbce583d7613e6bffd67596ad121a8707eec4694495703368" + .. "6a155f644d5c5863b48f61bdbf19a53eab6dad0a186b8c152e5f5d8cad4b0ef8" + .. "aa4ea5008834c3cd342e5e0f167ad04592cd8bd279639398ef9e114dfaaab919" + .. "e14e850989224ddd98576d79385d2210902e9f9b1f2d86cfa47ee244635465f7" + .. "1058421a0184be51dd10cc9d079e6f1604e7aa9b7cf7883c7d4ce12b06ebe160" + .. "81e23f27a231d18432d7d1bb55c28ae21ffcf005f57528d15a88881bb3bbb7fe", + B = "f10ea26e7f6729cf4ad84ee29797902444db19e43a4208f0228db31dbcebf1ce" + .. "2599dd34db5527b459959d03def823ca34b26acebdcda6e5be266bda03434f41" + .. "99709b44f9006319c8c9954440cfa8513a3efaf891ffe7e9a22d114a627ff876" + .. "a169f8475f2338342ec9a22916ff13949848d2653bfbad782b99e2f0dbe5a6c5" + .. "e698a3b895a426dc357c6986001b9023b0eb05a13c3b9ec04ee837ddfc12eef3" + .. "536dab67ec1c47881eb87bbfbe7f010823d051d33e15c6e9138780395d4ecf10" + .. "bf610e11a5b0e3ee8d122665feba6b17c9eba084a60d15aae69152b827b507ed" + .. "e6d824237d8596a51e6355faf8727d5e67f91e60ac85feebdb852eeb61c9e6d1" + .. "7a48d55d6bc815adfe03daec5b6bd8a3d28b8fe4bc78e452c2cd8aa89fedaf39" + .. "252f4cb354c595c09fa2251078746482fba13daf27e156aabd18800efd97e472" + .. "9b74217bc9611d54250422b00c0ade7bf0721c5f1b7d479c3c32d6d34b29d2fb" + .. "e158ebe5ad9e9e40d997f755e919c77ad5be10300d55bbe921aee598d4dbfff3", + u = "d4af9d1fde81c67160f53a86495e58016c71109e769944d65a07835170e52b7a" + .. "579e8273e1cfa374537535e74d617c530f403914049da1757c50e7361d078fe4", + S = "309ce4265954b7f9005a7e137eca9173e313c349445b54f36e38668a66ec011a" + .. "1ca015e8fc24e16b0d87d95b8083d8fd722acb81c1a96ae87e0b80d1789d6670" + .. "5a41f90286c852a73e139a5dc628be44d4f7b4c76e6506af49b22ec2fedaaec8" + .. "5c518a71e7867db978997234ade66b5beb0aaa1259409778f7b461fcc0d1a288" + .. "4ffa4fbb1084b733be16f1a6ed890269541df5356cf871e38c3c6fc93edd1952" + .. "efb89e6b92c0452f8766648f3b48337dd32b39bf20a64632719de457910760cd" + .. "81188beac6176e24555952ec3d3d84cfcc320f25381b525269e263528838f5a7" + .. "a62c85d350f1c28856e90eaf9907f8471b8ce5b7c251472c7302ca8bd04e1741" + .. "2b24d6c3d70e4a16caeb65df5d33ec0d4e06869d99c690d872a8693d273c8f92" + .. "c4789adc376844d4d0621f0dc38f9c550e7312c8f9da78ef20ddcf5954133e83" + .. "f76ddacf8a5ece59b2b61770ded05418fc37af37ca29a423848b16d929335a4f" + .. "7cae9025f2bdf4a368fc6e510eec39a34dc15665ebee4b504cb55f47edb48a5c", + K = "8d81d9699f88f80724b5ccea8af575afb9ec0e32a6986336a58e7697f8394b54" + .. "1751f886a362c05e86cac223a522218b4c704635accacbff498b5a17e6baa3be", + M1 = "5405c5ad299a761d8afe17c45383de84a46f6c0d437a29bd9476bf4b77dd79e7" + .. "b95674a966498a3c5f98bf8c933f4baba9555765fc1ecc240eaeda3363a68d42", + M2 = "65623be9ae14a44abcee4951655103a51913c84dbd90eda6c386819bfe7d2b0d" + .. "7603ce198b4c0b257dfec327bcf035cc0dacd7e47b79c585aec992af0490c61b", + }, + { + name = "HAP Pair-Setup, 4-digit PIN", + password = "3939", + salt = "0a1b2c3d4e5f60718293a4b5c6d7e8f9", + a = "1d1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6", + b = "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0", + k = "a9c2e2559bf0ebb53f0cbbf62282906bede7f2182f00678211fbd5bde5b28503" + .. "3a4993503b87397f9be5ec02080fedbc0835587ad039060879b8621e8c3659e0", + x = "956dcab6654323ea59d60692c2b5c1b7737a6f488e8e9808a8d636109668d014" + .. "3dc0954764ead68763ba2fdeb26fe313ecdad98c46a06e36436d2d2f01f8283a", + v = "1db374f7d5d9572ef2ca480f2486d045d3f892de338e80e733e83ea6e18bdc82" + .. "ce0d40a69076c2ee273d2b365fd1296dc0db4826dfc010c5c608ab0ef399fcd3" + .. "46f594dbc9eda52e8107c307a80d7f23a78345bb036684c911ee4cbacef0bd54" + .. "ba91aeb6c039d5946e407b1df89a0c456abcc4d3cdc69b5705d2af5a177094de" + .. "5943a28ad127fd00acfbb064df86c41a542d8455527784e1b3dcbaae41f2fab7" + .. "9fc1083ec1d13f8aeebe3cf3532e47a577d7bfcad7fac05507df97aed5f0c6ef" + .. "4bfb185999842ed5d191b0296608cd5e06355bf7b797264097c7c587ab844e56" + .. "359a4895ed99fb8df2b00d6a857ca12cbdf554d41f3316275ba3fe485b2b4fa3" + .. "857ced98c0e522193726c262963dc1ad85720e42c23f9a01d749a86664b4f47a" + .. "74f6193cfd27a84d7a8baa4e0b8fb28caab6cd19fbaa38c5be54d6007787dab7" + .. "156286019c50e6f8b1e6b6fc4b9fdb3992242de199c231549bfbe8e6311bd304" + .. "34bd63e384bb58b648e83a5a05d0234176f344f3001ee1a8cba127aea976a0e9", + A = "de7460f6ad6438e8c91615e668456cf0a88911cc16ab77fffca2335788499a85" + .. "4bec07ebeb3a352caa79bf815776ff5ac3ef69d7504f8854de94484c1bc8f21f" + .. "713a51e6ea856e8d0b402a8986fb049e0f66d2fa420fbc38cd4e7b8dd52d3839" + .. "f116b188443e078f803e06502b34818437ce8acaaae810c47017d22158f2dcc5" + .. "4b926935b2360f9b15d9c50fc65933f189cb3081d6dfba978716fa0f1d0188bc" + .. "a82c41241c05141537128c28f8e17e37422116e410689562fc0132908d9ecbba" + .. "a7ce637528802009545c8cb39734c5cd43047b5be7239d286df9a0c68dc36f76" + .. "002eeb683fc207880301612d2da0070a195c6bc1959f52a08ac3519a528f0696" + .. "f1335b8e487029460e607f390af361d9861b3abff460785cd484380ef87d369d" + .. "3ff7461796e4384ff0a711bfe1227da33d6ad7a763a2f24915be0dfb2827c9cf" + .. "9cfcde20fbff65c2fbba2c56d1e652e9172db8bd9825279d12ff51147af67e93" + .. "112e5073d949f372d285298851eaceec9f442081bd0615810ba70af20df15092", + B = "c462c3ab34762e9c723360c1a78d30016ac79a679cd8adc997111a842ce6a99a" + .. "7ff316a558e8f4a1b2d94633a620a84000788081ea7867c8c3a18e12e96d4e1a" + .. "142b18beee958e3c2e5da7bd927a4d4859b12c666d0d941dd183a0b6401bc567" + .. "0fec8ec9fb9c9534e8c34880b5f05ae962b12fd903a3af0ef3f7dfd6be81f385" + .. "8f81be60afffcfae6b6a8b73462cca451bcb64e8fda884109cefe22a3d711176" + .. "d327fdf03352106fb11c3220c5715810ba1a45f67f4b6649e2cf6a7172b4257a" + .. "6728e5423cba18a371b376c0aed3b9a930c206f47e7110d67eb8b707b300d1cd" + .. "d9731fa14ce48b26f07261598c1501ba06610faa8f25bfd0dc9f657e90ee2310" + .. "2a04b2304f45ececbf1470e24fb46067decb99c0bbd31b53c7fed135a301ae5b" + .. "fa858601f0f1c23524ecae5fb50385fc72467b4dc95ace632cc7d4bd6be81f5b" + .. "d68db69da4264daa10394b39c822360d97894682b34718dcb4c26779eacb696b" + .. "2b8b7d50c9139e2bdaf2a5ce0b8cee5e8e28cb2ad3e64e824086cc87f02ce6f7", + u = "76ac3f8010b3121c47cd1b8658e63357c624572489c4926348e5484d88c461d1" + .. "6862858e97ea1ea53b4e6e372fbaccb1e6b1907bae0267085d7f3c0015137dae", + S = "30c4b3ab83046ffab2b95f0038d75d76d754f1937be01fb437cca89b9714174a" + .. "d2d800427c221774afab641f0708658a86fb28ad4df01aa57c558d64fb5f51d3" + .. "4fc5104105fa20c3db75e9edeab61f900d404bfee3102471066d2dc028c21fe4" + .. "8330039d0ad7bbea975831f657dae6609c6c6c2ee5906dd8389b6ceca6c5b286" + .. "28dce2acc4b11a06f996e411b05e40d2152fb6259e0430587edf8bfda1f9d7d1" + .. "a35dfde72a8b1bee7ddea0ca9cc72a532c05b1a42b089cef1943df4364561ecd" + .. "7249965fb6b3a15ab6c63cf5399411d13c6d1e596e329c4553e3d5eef61fc21e" + .. "861966e65a3b121598d110e38f618501fd2c0c37e3ab1d6336c8617868c7e4ea" + .. "504be9b7ee243aea59e58cc046817653b681162bcc991378d1396d50c1399f44" + .. "ac80e1669376781993da356b765fe5a71bd488d13ade485317020f01a3e7d906" + .. "e26bd38c3678b9d3ee139da6d83598f7491b904f2f3121c0a45470597645cd10" + .. "d9d6848d0e2da9e7bd8224d1a8b3511285807320a84b2ec918d1dee1b20fd060", + K = "41ea1f81f58f7e4e4c7f1f8e739e04ffb5e29dabdc5a21b18c014f905c17f291" + .. "e69806caeb800ccb7bbb6c1955ac61cf695c68d1ddf19a3465c465c5088ff5dd", + M1 = "778b8aecea94608ea7971e7d73eab4abf4b211e28f3039939394fd842ee4636a" + .. "dc9271ee44d792a757de399969c5cba80501b8bfdc82f17d0b3498f9f9ed73f8", + M2 = "4e3e1ddbb25ad6e82c23cb8cb6606bc7a7ce8505c29fbf1902aa702be29512d8" + .. "3adc72dc6dc6fecb80be70da5ae07d5108ed9ad047a4d5bc5913e4e31d34a940", + }, + { + name = "leading-zero salt (pins the minimal-length encoding)", + password = "123-45-678", + salt = "00b25379d1a8581eb5a727673a2441ee", + a = "60975527035cf2ad1989806f0407210bc81edc04e2762a56afd529ddda2d4393", + b = "e487cb59d31ac550471e81f00f6928e01dda08e974a004f49e61f5d105284d20", + k = "a9c2e2559bf0ebb53f0cbbf62282906bede7f2182f00678211fbd5bde5b28503" + .. "3a4993503b87397f9be5ec02080fedbc0835587ad039060879b8621e8c3659e0", + x = "5f71b784ad9d6a6f6e6ad9c86a78cc0013075cab4ebfc0fd507b9ad5ea4a3669" + .. "b8fbc39e4817d06ecffba7920c1bcdd4f0ad9e2d407ba8e8b8bc6e8f6ed5e9a7", + v = "86ef8b08203d8eb0993cb30e6bc06477d29a5f9e14abd6c163bf34f3e7c72a6b" + .. "c109718f73d1fa4835d9dc0d7f267abeb7ef27d47de1b4f2e09767c59e6f2eca" + .. "ab0d87c98f4d32137fdd13cafdc5b7805d9beff8d7c5eaea66f349ec5b694fb0" + .. "410de38ea315d99988799169d966961ca6c30d537efb4150d8560d3d3c7dc2a7" + .. "ef8075d440238d21c93cc46e85cdd360576997a5b0bf43818fe612140910ffe8" + .. "27b5e4a4e71f850e32c99d7bf18c1fd9f642a33860b6e1e6209b9aaaba83246c" + .. "b93d04c7f7df378734b374555f57d83f40e97573626b28d0181f4a7f5e42614b" + .. "266be571ecf3d2a9b64524097b12504eaf88193405dc2259150828cd28c691f3" + .. "e016c92df3941d47830aca98028d38a7e32a6d1306caea734e17cc8440e0d907" + .. "9dd2657461055aa4a7bab8633d8b6102126ded5389148c1b225029b79d5f9ff9" + .. "0af65ca92ff8de2e5001ae95bd7255b1ea9ae936e153af3d228611afaa6f5dbc" + .. "d2cb90949657743d837ef1088c595ad0540bc9549b7508181810ba9ae54c4685", + A = "fab6f5d2615d1e323512e7991cc37443f487da604ca8c9230fcb04e541dce628" + .. "0b27ca4680b0374f179dc3bdc7553fe62459798c701ad864a91390a28c93b644" + .. "adbf9c00745b942b79f9012a21b9b78782319d83a1f8362866fbd6f46bfc0ddb" + .. "2e1ab6e4b45a9906b82e37f05d6f97f6a3eb6e182079759c4f6847837b62321a" + .. "c1b4fa68641fcb4bb98dd697a0c73641385f4bab25b793584cc39fc8d48d4bd8" + .. "67a9a3c10f8ea12170268e34fe3bbe6ff89998d60da2f3e4283cbec1393d52af" + .. "724a57230c604e9fbce583d7613e6bffd67596ad121a8707eec4694495703368" + .. "6a155f644d5c5863b48f61bdbf19a53eab6dad0a186b8c152e5f5d8cad4b0ef8" + .. "aa4ea5008834c3cd342e5e0f167ad04592cd8bd279639398ef9e114dfaaab919" + .. "e14e850989224ddd98576d79385d2210902e9f9b1f2d86cfa47ee244635465f7" + .. "1058421a0184be51dd10cc9d079e6f1604e7aa9b7cf7883c7d4ce12b06ebe160" + .. "81e23f27a231d18432d7d1bb55c28ae21ffcf005f57528d15a88881bb3bbb7fe", + B = "39ca72cbb2e5b68512d3874d7b22bfe9f1543176f261a1f310e62178298dc8f2" + .. "4133f72a7940fffbe1fb6d8e5962293ae78439e43420cb27712a48da16b35c74" + .. "527b57c6da70223fd3281d3853fc2a643ccec161d44b2c994365bd85a514c3d0" + .. "9294f91102e2a7783722518155281c80d92d0789c7b95b2db45e993de419fead" + .. "440d3cf10b0ebecb9928a2b0eda1aec7232ddb0196aa60aeeafb59a23deb472c" + .. "7afa382096d03f8a4dccb546f7416bde2aa31e592f9573efb30e5b1507ba05c7" + .. "e9c27951c43a4faa32d7f032abc3f259f8becf8b18e18feef5ed8f5ce476de7e" + .. "72acfd7fd13c6df5ca65ea4d203ce9c92346d04cf50a9c1dc70b38a2232147ff" + .. "9d791da5e90ad9d335039f0a44cb80017a79b61ddcf1251cbd1621e99058fbde" + .. "470bc6f950b862a40c6070ac98dd09a1af4e3ba9af5f26a2f92b3155b14ffb0a" + .. "b5c33e16bd9fb6252278f1fdc96123e321f8c34d2f6eac7893a8c987fae9a78b" + .. "6d48a0e4c6bff093ab93fa18d62411a0deee1aa1ded536a7162474777414db48", + u = "8b30c0a71691eae700568e4d989ebeeba8857e1a624db4105302df0d572b662e" + .. "24f150012f36189b85012601cc9997a4c22010178b765ed07de345c3def8a41c", + S = "0e4c4f64cb62930017ec119ab50d2f1271a9999f5c8b8afaa6f5251c3cb7d5d4" + .. "2a835ec99e0291fb4d1877ccc167184b0413939f4a6239aa538fb94c779fd0a7" + .. "47ee2616ae45316b1a56fd0acef7c09a463779afd9a9308eb5811529440e38cf" + .. "e94e0a37599e15ee820b096c62c944d46175f821dafdde83239df5d3d76b7772" + .. "b58b301e278534c2c6f95dd5a6a64253dc8a96df5ec5f3399dba0bf7406bdbb4" + .. "8196fe6db0cbec216433a9fad45a917dd47c720b746dbf4e80160bf14d1f6129" + .. "e90910090bb3a2ec2fa0ae3928f8d92a8c49f1922c91df991425849a4c3b61f0" + .. "c9900a7896b7a94fa87d5592e03352f2b780a4079bf34182fa88c7e2819ab476" + .. "b4487d850878145b7263544540839e22606b7687c680d16b9221bb4ddf64dfdc" + .. "ed5848476f9f9e4801069eab6c1c846e542f1359da348989a554ab83019cc2d5" + .. "354ce83c190577a7fc8e0a017bf37daae759399de4087b5612f8f8ffdcbaeeef" + .. "05b9c54eb9c03ebcc3911b9a8ce03a29ddbeb3142f1fefc20239ec1c0c40945c", + K = "dbce5774183a5d8ad5eb70ee6be4a362f3203d73dea68806817018edd98c4c18" + .. "b9c5a2d051318fe8ddd290f24e0376fe5265828d8f47f8355b99d13f375465f1", + M1 = "7bfac666037a0b38b730e5c35822404d0dd8f335fc48a7868bdbabf55f098d32" + .. "0369911f7f4e2c6f66aa8d8362686e68764149c327e905cbbe18db994a29e15d", + M2 = "3b8b949d7943c47e06d205d13a29a51637eefe365ec3e69f58592302a44d79a1" + .. "a5e10d753c37556c59344462d0c396dcf8ae19061971d911ddb10c04bcaae3b9", + }, +} + +--- The identity HAP Pair-Setup uses; also the `I` every vector was generated with. +local HAP_USERNAME = "Pair-Setup" + +--- Hex of a value zero-padded to the byte length of N, matching how the +--- generator emits `v`, `A`, `B` and `S`. +--- @param value BigNum Value +--- @return string hex 768 lowercase hex digits for group 15 +local function padded_hex(value) + return bytes.to_hex(bignum.to_bytes(value, resolve_group(srp.GROUP_3072).n_bytes)) +end + +--- Run comprehensive self-test with known-answer test vectors. +--- +--- Every intermediate of every vector -- `k`, `x`, `v`, `A`, `u`, `S`, `K`, `M1` +--- and `M2` -- is asserted separately, so a convention that drifts shows up as a +--- named failing step rather than as "M1 is wrong". The functional tests then +--- cover private-key handling, session independence and the two RFC 5054 abort +--- conditions. +--- +--- This is slow by design: a 3072-bit modular exponentiation is seconds of pure +--- Lua and each vector needs three of them. +--- +--- @return boolean result True if all tests pass, false otherwise +function srp.selftest() + local passed = 0 + local total = 0 + + --- Record one test result. `result` may be a boolean or a function returning + --- one; a function that raises counts as a failure rather than aborting the run. + --- @param name string + --- @param result boolean|fun(): boolean + local function check(name, result) + total = total + 1 + if type(result) == "function" then + local ok, value = pcall(result) + result = ok and value == true + end + if result == true then + print(" ✅ PASS: " .. name) + passed = passed + 1 + else + print(" ❌ FAIL: " .. name) + end + end + + --- Compare a hex value and print both sides when they differ. + --- @param name string + --- @param got string + --- @param expected string + local function check_hex(name, got, expected) + check(name, got == expected) + if got ~= expected then + print(" expected: " .. expected) + print(" got: " .. got) + end + end + + print("Running SRP-6a test vectors (RFC 5054 group 15, SHA-512)...") + local reference_session, reference_vector + for _, vector in ipairs(srp_vectors) do + print("Vector: " .. vector.name) + local salt = bytes.from_hex(vector.salt) + local session = srp.new({ username = HAP_USERNAME, password = vector.password }) + session:set_private(bytes.from_hex(vector.a)) + + check_hex(vector.name .. " [A]", bytes.to_hex(session:get_public()), vector.A) + session:process(salt, bytes.from_hex(vector.B)) + + check_hex(vector.name .. " [k]", bignum.to_hex(session.k), vector.k) + check_hex(vector.name .. " [x]", bignum.to_hex(session.x), vector.x) + check_hex(vector.name .. " [v]", padded_hex(session.v), vector.v) + check_hex(vector.name .. " [u]", bignum.to_hex(session.u), vector.u) + check_hex(vector.name .. " [S]", padded_hex(session.S), vector.S) + check_hex(vector.name .. " [K]", bytes.to_hex(session:get_session_key()), vector.K) + check_hex(vector.name .. " [M1]", bytes.to_hex(session:get_proof()), vector.M1) + check_hex(vector.name .. " [M2]", bytes.to_hex(session.M2), vector.M2) + check(vector.name .. " [verify accepts M2]", session:verify(bytes.from_hex(vector.M2))) + + reference_session, reference_vector = session, vector + print() + end + + print("Running SRP-6a functional tests...") + + -- Determinism across independent session objects: same private exponent in, + -- same A out. Compared against the vector session so this costs one + -- exponentiation rather than two. + check("set_private is deterministic across sessions", function() + local other = srp.new({ username = HAP_USERNAME, password = reference_vector.password }) + other:set_private(bytes.from_hex(reference_vector.a)) + return other:get_public() == reference_session:get_public() + end) + + check("get_public returns PAD(A), 384 bytes", function() + return #reference_session:get_public() == 384 + end) + + check("set_private returns the session for chaining", function() + local session = srp.new({ username = HAP_USERNAME, password = "123-45-678" }) + return session:set_private(bytes.from_hex(reference_vector.a)) == session + end) + + check("two sessions without set_private produce different A", function() + local one = srp.new({ username = HAP_USERNAME, password = "123-45-678" }) + local two = srp.new({ username = HAP_USERNAME, password = "123-45-678" }) + local a1, a2 = one:get_public(), two:get_public() + return #a1 == 384 and #a2 == 384 and a1 ~= a2 + end) + + check("empty private exponent is rejected", function() + local session = srp.new({ username = HAP_USERNAME, password = "123-45-678" }) + return pcall(session.set_private, session, "") == false + end) + + check("zero private exponent is rejected", function() + local session = srp.new({ username = HAP_USERNAME, password = "123-45-678" }) + return pcall(session.set_private, session, string_rep(string_char(0), 32)) == false + end) + + -- RFC 5054 section 2.5.4: the client MUST abort when B mod N == 0. Both an + -- all-zero B and B == N hit that, and only the second catches an + -- implementation that tests the bytes instead of the residue. + check("B == 0 is rejected", function() + local session = srp.new({ username = HAP_USERNAME, password = "123-45-678" }) + session:set_private(bytes.from_hex(reference_vector.a)) + return pcall(session.process, session, "salt", string_rep(string_char(0), 384)) == false + end) + + check("B == N (0 mod N) is rejected", function() + local session = srp.new({ username = HAP_USERNAME, password = "123-45-678" }) + session:set_private(bytes.from_hex(reference_vector.a)) + return pcall(session.process, session, "salt", bytes.from_hex(RFC5054_3072_N_HEX)) == false + end) + + check("empty B is rejected", function() + local session = srp.new({ username = HAP_USERNAME, password = "123-45-678" }) + session:set_private(bytes.from_hex(reference_vector.a)) + return pcall(session.process, session, "salt", "") == false + end) + + -- u == 0 cannot be reached with a real hash, so inject one that always + -- returns zeros. The abort must fire on u, before any secret is derived. + check("u == 0 is rejected", function() + local zero_hash = { + name = "always-zero", + hash = function() + return string_rep(string_char(0), 64) + end, + } + local session = srp.new({ username = HAP_USERNAME, password = "123-45-678", hash = zero_hash }) + session:set_private(bytes.from_hex(reference_vector.a)) + local ok, err = pcall(session.process, session, "salt", bytes.from_hex(reference_vector.B)) + return ok == false and type(err) == "string" and err:find("u is 0", 1, true) ~= nil + end) + + check("verify rejects a wrong M2", function() + local wrong = bytes.from_hex(reference_vector.M2) + wrong = string_char(0) .. wrong:sub(2) + return reference_session:verify(wrong) == false + end) + + check("verify rejects a truncated M2", function() + return reference_session:verify(bytes.from_hex(reference_vector.M2):sub(1, 63)) == false + end) + + check("verify rejects a non-string M2", function() + return reference_session:verify(nil) == false + end) + + check("verify accepts the correct M2", function() + return reference_session:verify(bytes.from_hex(reference_vector.M2)) == true + end) + + check("accessors error before process()", function() + local session = srp.new({ username = HAP_USERNAME, password = "123-45-678" }) + return pcall(session.get_proof, session) == false + and pcall(session.get_session_key, session) == false + and pcall(session.verify, session, "x") == false + end) + + check("unsupported hash name is rejected", function() + return pcall(srp.new, { username = HAP_USERNAME, password = "x", hash = "sha1" }) == false + end) + + check("missing username or password is rejected", function() + return pcall(srp.new, { password = "x" }) == false + and pcall(srp.new, { username = HAP_USERNAME }) == false + and pcall(srp.new, nil) == false + end) + + check("GROUP_3072 exposes N, g and the hash name", function() + return srp.GROUP_3072.N == RFC5054_3072_N_HEX + and srp.GROUP_3072.g == 5 + and srp.GROUP_3072.hash == "sha512" + and #bytes.from_hex(srp.GROUP_3072.N) == 384 + end) + + -- The accessor exists so a HAP caller does not have to reach into bignum or + -- openssl_wrapper, so what matters is that it reports the same verdict *and* + -- the same reason, whatever this host happens to be. + check("is_accelerated agrees with bignum, reason included", function() + local srp_accelerated, srp_reason = srp.is_accelerated() + local bn_accelerated, bn_reason = bignum.is_accelerated() + return srp_accelerated == bn_accelerated + and srp_reason == bn_reason + and type(srp_accelerated) == "boolean" + and (srp_accelerated or type(srp_reason) == "string") + end) + + print(string.format("\nSRP result: %d/%d tests passed\n", passed, total)) + return passed == total +end + +--- Run performance benchmarks for the client-side SRP-6a operations. +--- +--- Iteration counts are deliberately tiny: every operation below is dominated by +--- 3072-bit modular exponentiation, which is seconds per call in pure Lua. Note +--- that `benchmark_op` adds three warm-up runs on top of the count shown. +function srp.benchmark() + local vector = srp_vectors[1] + local salt = bytes.from_hex(vector.salt) + local B = bytes.from_hex(vector.B) + local a = bytes.from_hex(vector.a) + local M2 = bytes.from_hex(vector.M2) + + local function new_session() + return srp.new({ username = HAP_USERNAME, password = vector.password }):set_private(a) + end + + local ready = new_session() + ready:process(salt, B) + + print("SRP-6a client (RFC 5054 group 15, SHA-512):") + benchmark_op("get_public (A = g^a mod N)", function() + new_session():get_public() + end, 3) + + benchmark_op("process (v, u, S, K, M1, M2)", function() + ready:process(salt, B) + end, 2) + + benchmark_op("full exchange (A + process)", function() + local session = new_session() + session:get_public() + session:process(salt, B) + end, 2) + + benchmark_op("verify (M2)", function() + ready:verify(M2) + end, 2000) +end + +return srp diff --git a/src/crypto/x25519.lua b/src/crypto/x25519.lua index 3b211b7..cb8eeed 100644 --- a/src/crypto/x25519.lua +++ b/src/crypto/x25519.lua @@ -5,6 +5,7 @@ local x25519 = {} local bit32 = require("bitn").bit32 +local random = require("crypto.random") local utils = require("crypto.utils") local bytes = utils.bytes local benchmark_op = utils.benchmark.benchmark_op @@ -22,8 +23,9 @@ local table_concat = table.concat -- CURVE25519 FIELD ARITHMETIC -- ============================================================================ ---- @alias FieldElement integer[] 16-element array (indices 1-16) representing a field element ---- @alias ProductArray integer[] 31-element array (indices 1-31) for multiplication products +-- `FieldElement` and `ProductArray` are shared with ed25519 (same field, same +-- limb layout) and are defined once in `annotations.lua`. + --- @alias ScalarArray integer[] 32-element array (indices 1-32) for scalar bytes --- Initialize a 16-element field element with zeros @@ -311,18 +313,13 @@ end -- ============================================================================ --- Generate a random Curve25519 private key +--- +--- Drawn from `crypto.random`, which raises rather than falling back to a weak +--- generator when the host has no CSPRNG. A guessable ephemeral scalar here +--- yields the shared secret to anyone who observed the exchange. --- @return string private_key 32-byte private key function x25519.generate_private_key() - -- Better randomness by using time + clock + counter - local counter = x25519._key_counter or 0 - x25519._key_counter = counter + 1 - math.randomseed(os.time() + os.clock() * 1000000 + counter) - - local key_bytes = {} - for i = 1, 32 do - key_bytes[i] = string_char(math.random(0, 255)) - end - return table_concat(key_bytes) + return random.bytes(32) end --- Derive public key from private key diff --git a/src/crypto/x448.lua b/src/crypto/x448.lua index 5679a7f..99c1193 100644 --- a/src/crypto/x448.lua +++ b/src/crypto/x448.lua @@ -13,6 +13,7 @@ local x448 = {} local bitn = require("bitn") +local random = require("crypto.random") local utils = require("crypto.utils") local bytes = utils.bytes @@ -438,20 +439,12 @@ local function x448_scalarmult(scalar, base) end --- Generate a random Curve448 private key +--- +--- Drawn from `crypto.random`, which raises rather than falling back to a weak +--- generator when the host has no CSPRNG. --- @return string private_key 56-byte private key function x448.generate_private_key() - -- Generate 56 random bytes - local key = "" - - -- Mix multiple sources of randomness - local seed = os.time() + (os.clock() * 1000000) - math.randomseed(seed) - - for _ = 1, 56 do - key = key .. char(math.random(0, 255)) - end - - return key + return random.bytes(56) end --- Derive public key from private key diff --git a/tools/generate_srp_vectors.py b/tools/generate_srp_vectors.py new file mode 100644 index 0000000..30731c6 --- /dev/null +++ b/tools/generate_srp_vectors.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Generate SRP-6a known-answer vectors for crypto.srp. + +RFC 5054 appendix B publishes vectors for SHA-1 with the 1024-bit group, which +validates the algorithm but not the parameter set HAP uses (RFC 5054 group 15, +3072-bit, SHA-512). This script produces vectors for that parameter set using +`srptools`, which is the library pyatv itself drives for HAP Pair-Setup, so the +vectors encode the convention that actually interoperates with an Apple TV +rather than a from-scratch reading of the RFC. + +Everything is deterministic: the salt and both private exponents are fixed +literals, so re-running this reproduces the committed vectors byte for byte. + +Usage: + pip install srptools + python3 tools/generate_srp_vectors.py # emit the Lua table + python3 tools/generate_srp_vectors.py --check # verify internal consistency only + +Paste the output into the `srp_vectors` table in src/crypto/srp.lua. +""" + +from __future__ import annotations + +import argparse +import binascii +import hashlib +import sys + +from srptools import SRPClientSession, SRPContext, SRPServerSession, constants +from srptools.utils import int_from_hex, int_to_bytes + +# The HAP parameter set. `SRPContext` takes hex strings. +PRIME = constants.PRIME_3072 +GENERATOR = constants.PRIME_3072_GEN +HASH_FUNC = hashlib.sha512 +USERNAME = "Pair-Setup" + +N = int_from_hex(PRIME) +N_BYTES = len(int_to_bytes(N)) # 384 + + +def h(*chunks: bytes) -> bytes: + return HASH_FUNC(b"".join(chunks)).digest() + + +def pad(value: int) -> bytes: + """PAD(x): left-pad to the byte length of N, as RFC 5054 specifies.""" + return int_to_bytes(value).rjust(N_BYTES, b"\x00") + + +def minimal(value: int) -> bytes: + """srptools' default int encoding: big-endian, leading zero bytes stripped. + + This is NOT PAD(). srptools applies PAD() only to `u`'s inputs and to `g` + inside `k`; everywhere else -- notably the salt, A and B inside M1 -- an int + is rendered by `'%x' % val` and zero-padded only to an even hex length. A + value whose top byte is zero is therefore one byte shorter than PAD() would + make it. See the `leading_zero_salt` case below. + """ + return int_to_bytes(value) + + +# Deterministic inputs. `a` is 32 bytes because pyatv seeds the client private +# exponent with the hexlified Ed25519 auth key, i.e. a 256-bit value. +CASES = [ + { + "name": "HAP Pair-Setup, 8-digit PIN", + "password": "123-45-678", + "salt_hex": "beb25379d1a8581eb5a727673a2441ee", + "a_hex": "60975527035cf2ad1989806f0407210bc81edc04e2762a56afd529ddda2d4393", + "b_hex": "e487cb59d31ac550471e81f00f6928e01dda08e974a004f49e61f5d105284d20", + }, + { + "name": "HAP Pair-Setup, 4-digit PIN", + "password": "3939", + "salt_hex": "0a1b2c3d4e5f60718293a4b5c6d7e8f9", + "a_hex": "1d1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6", + "b_hex": "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0", + }, + { + # Salt whose leading byte is zero. Under PAD() this is 16 bytes; under + # srptools' minimal encoding it is 15, and x = H(s | ...) changes + # accordingly. This case exists specifically to pin which convention + # crypto.srp implements, because getting it wrong is a ~1-in-256 + # intermittent pairing failure rather than an obvious break. + "name": "leading-zero salt (pins the minimal-length encoding)", + "password": "123-45-678", + "salt_hex": "00b25379d1a8581eb5a727673a2441ee", + "a_hex": "60975527035cf2ad1989806f0407210bc81edc04e2762a56afd529ddda2d4393", + "b_hex": "e487cb59d31ac550471e81f00f6928e01dda08e974a004f49e61f5d105284d20", + }, +] + + +def build(case: dict) -> dict: + # The salt is BYTES, not an int, and that distinction is the whole point of + # the third case. pyatv passes binascii.hexlify(atv_salt), and srptools' + # init_base() unhexlifies straight to bytes, so a leading zero byte survives. + # Routing it through int -> hex -> bytes here would silently drop that byte + # and the vector would quietly stop testing what it claims to test. + salt = binascii.unhexlify(case["salt_hex"]) + a = int_from_hex(case["a_hex"]) + b = int_from_hex(case["b_hex"]) + + context = SRPContext( + USERNAME, + case["password"], + prime=PRIME, + generator=GENERATOR, + hash_func=HASH_FUNC, + ) + + # Server side: derive the verifier from *this* salt, not the random one + # get_user_data_triplet() would invent, then run a session with a fixed + # private exponent so B is reproducible. + x_for_verifier = context.get_common_password_hash(salt) # salt is bytes + password_verifier = "%x" % context.get_common_password_verifier(x_for_verifier) + server = SRPServerSession(context, password_verifier, private=case["b_hex"]) + assert int_from_hex(server.private) == b + + client = SRPClientSession(context, private=case["a_hex"]) + client.process(server.public, case["salt_hex"]) + server.process(client.public, case["salt_hex"]) + + # Both sides must agree, or the vector is worthless. + assert client.key == server.key, "session keys diverged" + # In srptools both roles expose key_proof = M1 and key_proof_hash = M2, so + # the cross-checks compare like with like. + assert server.verify_proof(client.key_proof), "server rejected M1" + assert client.verify_proof(server.key_proof_hash), "client rejected M2" + assert client.key_proof == server.key_proof, "M1 diverged between roles" + assert client.key_proof_hash == server.key_proof_hash, "M2 diverged between roles" + + A = int_from_hex(client.public) + B = int_from_hex(server.public) + x = context.get_common_password_hash(salt) + u = context.get_common_secret(B, A) + S = context.get_client_premaster_secret(x, B, a, u) + k = context._mult # noqa: SLF001 - the library exposes no accessor + + # Independently recompute K, M1 and M2 from the primitives rather than + # trusting the library's own bookkeeping, so a vector cannot be self-consistent + # and wrong at the same time. + K = h(minimal(S)) + assert binascii.unhexlify(client.key) == K, "K mismatch" + M1 = h( + bytes(p ^ q for p, q in zip(h(minimal(N)), h(minimal(context._gen)))), # noqa: SLF001 + h(USERNAME.encode()), + salt, + minimal(A), + minimal(B), + K, + ) + assert binascii.unhexlify(client.key_proof) == M1, "M1 mismatch" + M2 = h(minimal(A), M1, K) + assert binascii.unhexlify(server.key_proof_hash) == M2, "M2 mismatch" + + return { + "name": case["name"], + "password": case["password"], + "salt": case["salt_hex"], + "a": case["a_hex"], + "b": case["b_hex"], + "k": "%x" % k, + "x": "%x" % x, + "v": "%x" % int_from_hex(password_verifier), + "A": "%0*x" % (N_BYTES * 2, A), + "B": "%0*x" % (N_BYTES * 2, B), + "u": "%x" % u, + "S": "%0*x" % (N_BYTES * 2, S), + "K": binascii.hexlify(K).decode(), + "M1": binascii.hexlify(M1).decode(), + "M2": binascii.hexlify(M2).decode(), + } + + +def lua_string(value: str, indent: str) -> str: + """Emit a long hex literal as concatenated 64-character chunks.""" + chunks = [value[i : i + 64] for i in range(0, len(value), 64)] + if len(chunks) == 1: + return '"%s"' % chunks[0] + body = ('\n%s .. ' % indent).join('"%s"' % c for c in chunks) + return body + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true", help="verify only, emit nothing") + args = parser.parse_args() + + results = [build(case) for case in CASES] + if args.check: + print("all %d vectors internally consistent" % len(results), file=sys.stderr) + return 0 + + print("-- Generated by tools/generate_srp_vectors.py -- do not edit by hand.") + print("-- Source of truth: srptools, the library pyatv drives for HAP Pair-Setup.") + print("local srp_vectors = {") + for r in results: + print(" {") + print(' name = "%s",' % r["name"]) + print(' password = "%s",' % r["password"]) + for field in ("salt", "a", "b", "k", "x", "v", "A", "B", "u", "S", "K", "M1", "M2"): + print(" %s = %s," % (field, lua_string(r[field], " "))) + print(" },") + print("}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())