From 0c3b2553ed48f3bbd0ab91da565313a42a113f71 Mon Sep 17 00:00:00 2001 From: "svc-finitelabs[bot]" <269744575+svc-finitelabs[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:48:51 -0500 Subject: [PATCH 1/3] feat: add Ed25519, HKDF, SRP-6a and a bignum backend for HAP pairing Adds the four crypto primitives HomeKit Accessory Protocol pairing needs, plus the OpenSSL routing work required to make one of them viable on Control4 hardware. crypto.ed25519 (RFC 8032), pure Lua Keygen, sign, verify, and the expanded private-key form. HAP signs repeatedly with one long-term key, so expand_private_key(seed) returns the clamped scalar and prefix once and sign_expanded() reuses them, which also skips the public-key scalar multiplication: signing drops from 64.5 ms to 32.7 ms on Lua 5.5 and 7.1 ms to 3.7 ms on LuaJIT. Verified in layers, because a passing selftest is weak evidence for a signature scheme. RFC 8032 section 7.1 vectors machine-extracted from the RFC text rather than transcribed by hand (TEST 1/2/3, the 1023-byte TEST, and TEST SHA(abc)); 30 vector assertions covering derive, sign, sign_expanded byte-identity, accept, and rejection of a flipped message bit and a flipped signature bit. Cross-checked against two independent implementations, not one: python-cryptography during development, and 12 fresh random seed/message pairs signed by Node's crypto and compared byte for byte (36/36). The non-canonical S >= L rejection was confirmed load-bearing by deleting the check in a scratch copy and observing that the S+L signature then verifies, since [S+L]B == [S]B, so that test fails for the intended reason. 11 further tests cover malformed input: verify returns false rather than raising for wrong-length signatures and public keys, undecodable points, non-canonical S, and non-string args. Known properties, recorded rather than hidden: verify is not constant-time (all public data, matches TweetNaCl); the module is not reentrant, sharing pre-allocated scratch exactly as x25519.lua already does; verification is cofactorless, not ZIP-215 strict; and generate_private_key inherits x25519's os.time/os.clock seeding, which is not a CSPRNG and is flagged as such. crypto.hkdf (RFC 5869) over SHA-256 and SHA-512 Extract, Expand, a one-shot derive(), and hkdf_sha256 / hkdf_sha512 wrappers. HAP needs the SHA-512 variant for the Pair-Setup session key, the controller and accessory signing material, the Pair-Verify session key, and the Companion session keys. 41 known-answer tests. The SHA-256 vectors are RFC 5869 appendix A.1-A.3 verbatim; A.4-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, so those come from a generator that reproduces A.1-A.3, making it a validated oracle rather than an assumed-correct one. PRK values come from a separate crypto.createHmac call and are asserted independently, and each OKM is expanded from the PRK this implementation derived rather than from the published one, so a broken extract cannot be masked by expand being handed the right input. The six HAP/Companion derivations are pinned with their real salt and info strings ("Pair-Setup-Encrypt-Salt", "Control-Salt" / "ClientEncrypt-main", ...), so a regression in salt or info handling surfaces here as a wrong key rather than as a pairing failure on hardware. Deliberately not routed to openssl.kdf, and the reason is recorded in the module: hmac_sha256/hmac_sha512 already return the OpenSSL result when acceleration is on, and a HAP derivation is two HMAC invocations in total, so a separate route would buy nothing measurable while adding a path that cannot be exercised on a host without the binding. Feature.KDF is still declared so the capability stays queryable if that trade changes. crypto.bignum Arbitrary-precision unsigned integers sized for 3072-bit modexp (RFC 5054 group 15), which SRP needs. 24-bit limbs, CIOS Montgomery multiplication with a sliding window, and a slow bitwise square-and-multiply reference path the selftest cross-checks the fast one against. 54 known-answer tests. Overflow bound is 2^48-1, from carrying on every multiply-accumulate iteration rather than per column, which keeps it exact in doubles on the 5.1/LuaJIT legs with 5 bits to spare. The canonical representation is always the pure-Lua limb table; OpenSSL is used only inside mod_exp, converting in via bn.text and out via bn.tohex. That is deliberate: crypto.use_openssl() can be toggled at runtime, and if handles were sometimes userdata and sometimes tables a mid-flight toggle would produce mixed operands and silent breakage. Conversion is a few hundred bytes against a 3072-bit exponentiation, so it costs nothing. Verified against a real lua-openssl 0.11.1, not only against stand-ins: accelerated and pure paths produce identical output, and acceleration is 26x faster here. crypto.srp, SRP-6a client for HAP Pair-Setup RFC 5054 group 15 (3072-bit) with SHA-512 and username "Pair-Setup" as the HAP configuration, parameterised by group and hash. Client side only, stated in the module LuaDoc so nobody assumes server support exists. The conventions come from srptools, the library pyatv actually drives for HAP, not from a fresh reading of RFC 5054, because what matters here is what interoperates with an Apple TV. The distinction is real: most values are hashed in minimal big-endian form with leading zero bytes stripped, and PAD() is applied only inside k = H(N | PAD(g)) and u = H(PAD(A) | PAD(B)). The salt is hashed exactly as received, so a salt with a leading zero byte keeps it. Getting that wrong is a roughly 1-in-256 intermittent pairing failure rather than an obvious break, so one committed vector uses such a salt specifically to pin it, and that vector was verified to genuinely discriminate before being trusted. 48 tests: 30 vector assertions across 3 vectors, asserting A, k, x, v, u, S, K, M1, M2 and verify(M2) separately so a failure localises to a step rather than just saying "M1 wrong". Plus 18 functional tests covering B mod N == 0, B == N (which a naive byte-comparison check would miss), empty B, u == 0, zero private exponent, a wrong or truncated M2, and accessors called before process(). Verified beyond the committed vectors with 8 fresh random sessions generated by srptools, varying PIN formats and including leading-zero salts, checking A, K, M1 and verify(M2): 32/32. That is the check that would catch a self-consistent-but-wrong implementation, since the committed vectors and the code could in principle share a mistaken assumption. Also adds tools/generate_srp_vectors.py, which produces 3072/SHA-512 vectors from srptools, so the committed vectors encode the convention that interoperates rather than a fresh reading of the RFC. Opening the OpenSSL feature gate openssl_wrapper.get() iterated the string-keyed OpenSSLFeature table with ipairs, which visits nothing. _openssl_module_features therefore stayed empty and every get() call returned nil, while get() with no feature argument returned the module, which is why the failure was silent. Rather than only swapping in pairs, features now declare { min_version, probe? } and resolve lazily, at most once, on first request. A version bound alone cannot answer "was this build compiled with openssl.bn?", which is exactly the question the bignum backend needs answered, so routing is now declarative instead of hardcoded: BN probes the exact conversion route the bignum backend uses, big-endian bytes in through bn.text and hex out through bn.tohex, and requires 4^13 mod 497 to come back 445, so a binding cannot pass on symbol names alone. It accepts either spelling of modular exponentiation: lua-openssl 0.8.5 as shipped on Control4 spells it bn.powmod, not bn.mod_exp, and probing only for the latter would have closed the gate on the one path where OpenSSL is a large real win rather than a nicety. KDF probes for kdf.derive OKP requires a completed sign/verify round-trip, so it reports false on the Control4 behaviour where pkey.new("ed25519") is nil and an imported key's sign() returns nil 17 regression tests driven by injected stand-in bindings rather than the host's real lua-openssl, so the result is identical on CI and on machines with no binding installed. The first case is the direct regression for the ipairs bug and was verified to fail against the pre-fix file. openssl_wrapper is registered as a module in run_tests.sh and crypto.selftest(), and exposed on the crypto table so callers can query openssl_wrapper.features(). Fixing the gate does not activate ChaCha20-Poly1305's OpenSSL branch on Control4, and the distinction matters. Both of its call sites request Feature.AAD, and the controller reports lua-openssl 0.8.5, below the 0.9.2 floor AAD requires, so it stays on the pure-Lua path there before and after this change. That floor is load-bearing rather than decorative: on 0.8.5, cipher:update(aad, true) is byte-for-byte identical to cipher:update(aad), meaning the AAD flag is ignored and the AAD is encrypted as though it were plaintext. Verified three ways on hardware: the flagged call does not reproduce the RFC 8439 vector, it produces exactly the same stream as encrypting aad .. plaintext with no AAD at all, and the same cipher with no AAD does reproduce the RFC 8439 ciphertext. Had the gate opened there, the library's own guard ("AAD update should not return data in AEAD mode") would have raised. Both findings are pinned as named regression cases, so a future change to the version floor has to argue with a measurement instead of a guess. CI: a lua-openssl version matrix alongside the Lua matrix The existing matrix proves portability across interpreters, but every leg runs with no lua-openssl installed, so it only ever exercised the pure-Lua paths. That is how ChaCha20-Poly1305's OpenSSL branch stayed unreachable for its whole existence while the suite stayed green. This adds the other axis: which binding is present. 0.8.5-1 the binding shipped on Control4 DriverWorks, AAD expected false 0.9.2-2 the exact floor at which AAD starts working, expected true 0.11.1-1 current upstream, expected true Each leg asserts the feature map resolves as measured, then runs the suite both with and without acceleration. The 0.8.5 leg is a faithful Control4 proxy, not an approximation: installing that exact rock locally reproduced the controller's behaviour, with cipher:update(aad, true) returning the AAD length instead of 0 and the ciphertext not matching RFC 8439, identically to the controller, despite this host running OpenSSL 3.6.3 against the controller's 3.1.4. The defect tracks the lua-openssl version, not the OpenSSL library or the hardware, which is exactly what makes a version floor the right mechanism. OKP is asserted false on all three. No lua-openssl build tested can sign with an Ed25519 key (0.11.1 over OpenSSL 3.6.3 raises "not support ed25519"), so routing crypto.ed25519 to pure Lua is not a Control4 workaround but the only correct choice everywhere. If that assertion ever flips, the routing decision should be revisited, which is why it is asserted rather than assumed. fail-fast is off so one binding's failure cannot mask another's, and build now depends on this job as well as the interpreter matrix. Performance on Control4, which answers the ticket's open question Measured on the dev controller (192.168.2.92) rather than reasoned from the module listing. bn.powmod over RFC 5054 group 15 runs 5.08 ms/op with a 256-bit exponent and 60.92 ms/op with a full 3072-bit exponent, both checked against independently computed expected values. Timing pure-Lua mod_exp across 4/8/16/32/64/96-bit exponents fits ms = 4982 + 669*bits at R^2 = 0.9962, which extrapolates to roughly 176 s for the 256-bit SRP exponent and 2061 s for a full-width one. That is a factor of about 34,000, and a whole Pair-Setup client goes from under 0.05 s to roughly 15 minutes. It is not merely slow. A direct attempt at the 256-bit case blocked the driver's Lua thread until the driver was reset without finishing, and other drivers stopped being serviced while it ran. So the pure-Lua path is a correctness reference and a portability fallback, not something shippable on this hardware, and Feature.BN is effectively a precondition for HAP pairing there. Recorded in CLAUDE.md, including the recommendation that callers check for BN and fail loudly rather than fall back into a controller hang. Docs: README gains both new modules in the algorithm table, usage examples for signing (including the expanded-key form) and HKDF, and a table of what each lua-openssl binding actually supports, since that is not derivable from a version number. CLAUDE.md records the routing decisions, the Control4 measurements behind them, and that bn's modular exponentiation is spelled powmod on every build tested. Green on lua 5.5, 5.4, 5.3, 5.2, 5.1 and luajit 2.0/2.1; 14/14 modules pass across the suite with acceleration both on and off. FL-3 --- .github/workflows/build.yml | 95 +- CLAUDE.md | 59 ++ README.md | 55 +- run_tests.sh | 10 +- src/crypto/bignum.lua | 1706 ++++++++++++++++++++++++++++++++ src/crypto/ed25519.lua | 1279 ++++++++++++++++++++++++ src/crypto/hkdf.lua | 506 ++++++++++ src/crypto/init.lua | 40 +- src/crypto/openssl_wrapper.lua | 448 ++++++++- src/crypto/srp.lua | 855 ++++++++++++++++ tools/generate_srp_vectors.py | 211 ++++ 11 files changed, 5228 insertions(+), 36 deletions(-) create mode 100644 src/crypto/bignum.lua create mode 100644 src/crypto/ed25519.lua create mode 100644 src/crypto/hkdf.lua create mode 100644 src/crypto/srp.lua create mode 100644 tools/generate_srp_vectors.py 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..beec3bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,10 @@ 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) +│ ├── 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 +75,61 @@ 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. +- **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. | + +### 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.openssl_wrapper.features().BN` and fail loudly rather than +silently falling back to something that will hang the controller. ### bitn dependency diff --git a/README.md b/README.md index e381b68..ab42a02 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ 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` | ## OpenSSL acceleration @@ -44,9 +48,32 @@ 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 } -- e.g. Control4 +``` + +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 | | + +Note that `bn`'s modular exponentiation is named `powmod`, not `mod_exp`, on +every build tested. ## Installation @@ -96,6 +123,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..93c7d85 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, 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" "bignum" "srp" "x25519" "x448" "ed25519" "openssl_wrapper") default_modules=("${all_modules[@]}") modules_to_run=("$@") @@ -137,8 +138,13 @@ 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 "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/bignum.lua b/src/crypto/bignum.lua new file mode 100644 index 0000000..50872ad --- /dev/null +++ b/src/crypto/bignum.lua @@ -0,0 +1,1706 @@ +--- @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 + +-- ============================================================================ +-- 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 + --- @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 + 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. + 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, + }, + } + + 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..f2ec3ab --- /dev/null +++ b/src/crypto/ed25519.lua @@ -0,0 +1,1279 @@ +--- @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 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) +-- ============================================================================ + +--- @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 +--- @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) +--- +--- Uses the same time/clock/counter seeding strategy as `crypto.x25519`; for +--- production keys prefer supplying a seed from a system CSPRNG. +--- @return string seed 32-byte private key seed +function ed25519.generate_private_key() + -- Better randomness by using time + clock + counter + local counter = ed25519._key_counter or 0 + ed25519._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) +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..9371f41 --- /dev/null +++ b/src/crypto/hkdf.lua @@ -0,0 +1,506 @@ +--- @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. +--- +--- @alias HkdfHash { hmac: fun(key: string, data: string): string, length: integer } +--- @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..84566a9 100644 --- a/src/crypto/init.lua +++ b/src/crypto/init.lua @@ -1,16 +1,17 @@ --- @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. --- --- 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 +48,34 @@ local crypto = { --- @type crypto.poly1305 poly1305 = require("crypto.poly1305"), + -- Key derivation + --- @type crypto.hkdf + hkdf = require("crypto.hkdf"), + + -- 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 +106,13 @@ function crypto.selftest() "chacha20_poly1305", "poly1305", "aes_gcm", + "hkdf", + "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..b52792c 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,86 @@ 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", } ---- 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, + }, } -- Export Feature enum for external use @@ -48,6 +119,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 +134,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 +170,30 @@ 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 + --- Get the OpenSSL module if enabled and supports required features --- --- Checks if OpenSSL is enabled and supports all specified features before @@ -120,26 +217,331 @@ function openssl_wrapper.get(...) --- @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 - _openssl_module_features[feature] = version_supports(current_version, required_version) - end - end end - -- Check all requested features + -- Check all requested features, resolving (and caching) each on first request. for _, required_feature in ipairs(required_features) do - if not _openssl_module_features[required_feature] then + local supported = _openssl_module_features[required_feature] + if supported == nil then + supported = resolve_feature(_openssl_module, required_feature) + _openssl_module_features[required_feature] = supported + end + if not supported then return nil end end return _openssl_module end +--- Report which features the currently loaded binding supports. +--- Intended for diagnostics; forces resolution of every declared 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. + 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 + 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 + + 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, + }, + { + 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 = "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 + + -- 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/srp.lua b/src/crypto/srp.lua new file mode 100644 index 0000000..b4bbf52 --- /dev/null +++ b/src/crypto/srp.lua @@ -0,0 +1,855 @@ +--- @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 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 floor = math.floor +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 + +--- Counter mixed into the seed so two generations in the same clock tick differ. +local key_counter = 0 + +--- Generate random bytes for the client private exponent. +--- +--- Same seeding strategy as `crypto.x25519.generate_private_key`. The seed is +--- floored before use because Lua 5.4's `math.randomseed` rejects a float with a +--- fractional part. +--- +--- @param n integer Number of bytes +--- @return string bytes Random byte string +local function random_bytes(n) + key_counter = key_counter + 1 + math.randomseed(floor(os.time() + os.clock() * 1000000) + key_counter) + local out = {} + for i = 1, n do + out[i] = string_char(math.random(0, 255)) + end + return table_concat(out) +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 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 + 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 + +-- ============================================================================ +-- 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) + + 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/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()) From b5e1fa3383ab7c9afe05cba497c94a7e7a7a1be4 Mon Sep 17 00:00:00 2001 From: "svc-finitelabs[bot]" <269744575+svc-finitelabs[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:43:43 -0500 Subject: [PATCH 2/3] feat: add crypto.random and refuse to generate keys from a weak PRNG Every key generation path seeded a non-cryptographic PRNG from the clock (`math.randomseed(os.time() + os.clock() * 1000000 + counter)`, then `math.random(0, 255)`). At driver startup that is worth roughly 20 bits: `os.time()` has one-second granularity and `os.clock()` is near deterministic. On 5.1 and LuaJIT `math.random` is C `rand()`; on 5.4+ an explicit low-entropy `randomseed` downgrades a generator the runtime had already seeded well, so the call was worse than making none. Add `crypto.random`, which returns strong bytes or raises. Sources, in order: `openssl.random(n, true)` behind a new `Feature.RANDOM` probe, then `/dev/urandom`, then failure. It never returns weak bytes and never returns a short read. A driver that fails to pair is a bug report; one that pairs with a guessable long-term identity looks healthy while being compromised. Route all four generators through it -- `ed25519.generate_private_key`, `srp`'s client exponent `a`, `x25519.generate_private_key` and `x448.generate_private_key` -- and leave every explicit-input entrypoint (`ed25519.sign(seed, ...)`, `x25519.diffie_hellman(priv, ...)`, `session:set_private(a)`) untouched. `Feature.RANDOM` resolves through a new `openssl_wrapper.get_ungated` rather than `get`. The acceleration flag chooses between two correct implementations everywhere else in the library; it must not be able to select a weaker source of entropy. The probe checks `rand_status()` and two real draws of the full width rather than trusting the version or the presence of a name, so a build whose RNG is stubbed or wired to a constant is rejected. 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, `random(n, true)` returns n distinct bytes, and the probe passes as written. `/dev/urandom` is also readable from inside the driver sandbox, so both sources are live on the target hardware. `random(0)` and negative lengths raise on that build, hence the width validation in `bytes`. Also add `srp.is_accelerated()`, delegating to a new `bignum.is_accelerated()` so it reports what `mod_exp` will actually do -- feature gate *and* the multi-limb known-answer check -- instead of what the feature map alone implies. Advisory only: bignum stays portable, because the pure path is what keeps the suite runnable everywhere. But at roughly 176 s versus 5 ms for the 3072-bit client exponent on a controller where Lua is serialised across drivers, callers deserve the precondition at the layer that owns it rather than reaching through `openssl_wrapper.features().BN`. 15 self-tests for the new module, 3 more for the gating change. Each guard was confirmed load-bearing by removing it: dropping the exact-width check, replacing the hard failure with a `math.random` fallback, reverting x448 to its old generator, and dropping the probe's distinctness check each fail exactly the tests that name them. --- CLAUDE.md | 40 ++- README.md | 46 +++- run_tests.sh | 7 +- src/crypto/bignum.lua | 17 ++ src/crypto/ed25519.lua | 18 +- src/crypto/init.lua | 8 + src/crypto/openssl_wrapper.lua | 176 ++++++++++-- src/crypto/random.lua | 482 +++++++++++++++++++++++++++++++++ src/crypto/srp.lua | 51 ++-- src/crypto/x25519.lua | 16 +- src/crypto/x448.lua | 17 +- 11 files changed, 798 insertions(+), 80 deletions(-) create mode 100644 src/crypto/random.lua diff --git a/CLAUDE.md b/CLAUDE.md index beec3bb..5269fe0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ lua-crypto/ │ ├── 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 @@ -104,6 +105,7 @@ OpenSSL 3.1.4), pinned as regression cases in `openssl_wrapper.selftest()`: | `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. | ### Why bignum must use OpenSSL on Control4 @@ -128,9 +130,45 @@ 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.openssl_wrapper.features().BN` and fail loudly rather than +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. + +### 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 The pure-Lua primitives use `bitn` (portable bitwise ops) for cross-version diff --git a/README.md b/README.md index ab42a02..0283dc0 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,36 @@ portable enough to run inside sandboxed Lua hosts such as Control4 DriverWorks. | 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 @@ -61,7 +91,18 @@ actually supports: ```lua crypto.use_openssl(true) local features = crypto.openssl_wrapper.features() --- { AAD = false, BN = true, KDF = true, OKP = false } -- e.g. Control4 +-- { 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: @@ -72,6 +113,9 @@ Measured behaviour of the bindings covered by CI: | 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. diff --git a/run_tests.sh b/run_tests.sh index 93c7d85..313010a 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -10,8 +10,8 @@ # ./run_tests.sh sha256 x25519 # Run only sha256 and x25519 # # Available modules: sha256, sha512, blake2, chacha20, chacha20_poly1305, -# poly1305, aes_gcm, hkdf, bignum, srp, x25519, x448, ed25519, -# openssl_wrapper +# poly1305, aes_gcm, hkdf, random, bignum, srp, x25519, x448, +# ed25519, openssl_wrapper set -e # Exit on any error @@ -49,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" "hkdf" "bignum" "srp" "x25519" "x448" "ed25519" "openssl_wrapper") +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=("$@") @@ -139,6 +139,7 @@ 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" diff --git a/src/crypto/bignum.lua b/src/crypto/bignum.lua index 50872ad..6b90779 100644 --- a/src/crypto/bignum.lua +++ b/src/crypto/bignum.lua @@ -1013,6 +1013,23 @@ function bignum.mod_exp(base, exp, m) 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()`. +--- +--- @return boolean accelerated True if OpenSSL will handle modular exponentiation +function bignum.is_accelerated() + local openssl = openssl_wrapper.get(openssl_wrapper.Feature.BN) + return openssl ~= nil and accelerator_ready(openssl) +end + -- ============================================================================ -- TEST VECTORS AND VALIDATION -- ============================================================================ diff --git a/src/crypto/ed25519.lua b/src/crypto/ed25519.lua index f2ec3ab..e0f7bfe 100644 --- a/src/crypto/ed25519.lua +++ b/src/crypto/ed25519.lua @@ -14,6 +14,7 @@ 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 @@ -762,20 +763,13 @@ end --- Generate a random Ed25519 private key (seed) --- ---- Uses the same time/clock/counter seeding strategy as `crypto.x25519`; for ---- production keys prefer supplying a seed from a system CSPRNG. +--- 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() - -- Better randomness by using time + clock + counter - local counter = ed25519._key_counter or 0 - ed25519._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 --- Expand a 32-byte seed into the 64-byte signing key material diff --git a/src/crypto/init.lua b/src/crypto/init.lua index 84566a9..b7ad931 100644 --- a/src/crypto/init.lua +++ b/src/crypto/init.lua @@ -5,6 +5,9 @@ --- 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 @@ -52,6 +55,10 @@ local crypto = { --- @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"), @@ -107,6 +114,7 @@ function crypto.selftest() "poly1305", "aes_gcm", "hkdf", + "random", "bignum", "srp", "x25519", diff --git a/src/crypto/openssl_wrapper.lua b/src/crypto/openssl_wrapper.lua index b52792c..fa5e0eb 100644 --- a/src/crypto/openssl_wrapper.lua +++ b/src/crypto/openssl_wrapper.lua @@ -39,6 +39,10 @@ local OpenSSLFeature = { --- 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 requirement definitions @@ -112,6 +116,36 @@ local FeatureRequirements = { 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 @@ -194,6 +228,39 @@ local function resolve_feature(openssl, feature) 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 @@ -205,31 +272,39 @@ end function openssl_wrapper.get(...) local required_features = { ... } - if not _use_openssl or _openssl_unavailable then + if not _use_openssl then + return nil + end + local openssl = load_binding() + if openssl == nil 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 - return nil - end - --- @cast openssl_module table - _openssl_module = openssl_module - _openssl_module_features = {} end -- Check all requested features, resolving (and caching) each on first request. for _, required_feature in ipairs(required_features) do - local supported = _openssl_module_features[required_feature] - if supported == nil then - supported = resolve_feature(_openssl_module, required_feature) - _openssl_module_features[required_feature] = supported - end - if not supported then + if not supports(openssl, required_feature) then return nil end end - return _openssl_module + 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 --- Report which features the currently loaded binding supports. @@ -503,6 +578,71 @@ function openssl_wrapper.selftest() 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() diff --git a/src/crypto/random.lua b/src/crypto/random.lua new file mode 100644 index 0000000..c6b9e72 --- /dev/null +++ b/src/crypto/random.lua @@ -0,0 +1,482 @@ +--- @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 + 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 index b4bbf52..cd85916 100644 --- a/src/crypto/srp.lua +++ b/src/crypto/srp.lua @@ -45,6 +45,7 @@ local srp = {} local bignum = require("crypto.bignum") +local random = require("crypto.random") local sha256 = require("crypto.sha256") local sha512 = require("crypto.sha512") @@ -53,7 +54,6 @@ local bytes = utils.bytes local benchmark_op = utils.benchmark.benchmark_op -- Local references for performance -local floor = math.floor local string_char = string.char local string_rep = string.rep local table_concat = table.concat @@ -198,27 +198,6 @@ local function resolve_hash(spec) return entry end ---- Counter mixed into the seed so two generations in the same clock tick differ. -local key_counter = 0 - ---- Generate random bytes for the client private exponent. ---- ---- Same seeding strategy as `crypto.x25519.generate_private_key`. The seed is ---- floored before use because Lua 5.4's `math.randomseed` rejects a float with a ---- fractional part. ---- ---- @param n integer Number of bytes ---- @return string bytes Random byte string -local function random_bytes(n) - key_counter = key_counter + 1 - math.randomseed(floor(os.time() + os.clock() * 1000000) + key_counter) - local out = {} - for i = 1, n do - out[i] = string_char(math.random(0, 255)) - end - return table_concat(out) -end - -- ============================================================================ -- SESSION -- ============================================================================ @@ -265,7 +244,10 @@ end function Session:get_public() if not self.A_bytes then if not self.a then - self:set_private(random_bytes(PRIVATE_BYTES)) + -- `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) @@ -402,6 +384,29 @@ function srp.new(opts) }, 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. +--- +--- @return boolean accelerated True if modular exponentiation uses OpenSSL +function srp.is_accelerated() + return bignum.is_accelerated() +end + -- ============================================================================ -- TEST VECTORS AND VALIDATION -- ============================================================================ diff --git a/src/crypto/x25519.lua b/src/crypto/x25519.lua index 3b211b7..71116f3 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 @@ -311,18 +312,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 From 596dc356e86798367ff4ba4185d5d6d4259c2d70 Mon Sep 17 00:00:00 2001 From: "svc-finitelabs[bot]" <269744575+svc-finitelabs[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:18:50 -0500 Subject: [PATCH 3/3] docs: explain why acceleration is off, and make is_accelerated say which Follow-up to the b5e1fa3 review. Three things it asked for. Nothing is accelerated until crypto.use_openssl(true) is called, and the failure mode is quiet: CRYPTO_USE_OPENSSL is not set in the DriverWorks environment, so a driver that skips the call gets pure Lua everywhere and openssl_wrapper.features() reports all five false on hardware where four are true. Documented under its own heading in CLAUDE.md with the measured cost. "No usable binding" and "nobody enabled acceleration" produced the same false from is_accelerated() and need opposite responses, so bignum.is_accelerated() and srp.is_accelerated() now return a reason as a second value, sourced from a new openssl_wrapper.unavailable_reason(). The reason is always present when the verdict is false. Adding a return value does not disturb existing callers. Recorded the asymmetric hardware measurements from the review, plus the consequence that a 1.583 s verify blocks the Lua thread and Pair-Verify will need spreading across timer callbacks. Also fixed the language server diagnostics. FieldElement and ProductArray are one type shared by x25519 and ed25519, so they move to a doc-only annotations.lua, which also gives Int64HighLow the definition it never had. HkdfHash becomes a @class, which fixes the redundant-return-value at hkdf.lua:87 and two undefined-field reports with the same edit. That takes src/ from 59 coded diagnostics to 0, and make typecheck now runs the server that install-deps was already installing. Measured on lua-openssl 0.11.1 over OpenSSL 3.6.3: pkey.new("ed25519") is rejected there too, so the always-pure-Lua route for ed25519 is the current state of the binding rather than a workaround for Control4's 0.8.5. --- CLAUDE.md | 76 +++++++++++++++++++++++++++++- Makefile | 22 +++++++++ src/crypto/annotations.lua | 32 +++++++++++++ src/crypto/bignum.lua | 85 +++++++++++++++++++++++++++++++++- src/crypto/ed25519.lua | 5 +- src/crypto/hkdf.lua | 5 +- src/crypto/openssl_wrapper.lua | 75 +++++++++++++++++++++++++++++- src/crypto/random.lua | 3 ++ src/crypto/srp.lua | 19 ++++++++ src/crypto/x25519.lua | 5 +- 10 files changed, 318 insertions(+), 9 deletions(-) create mode 100644 src/crypto/annotations.lua diff --git a/CLAUDE.md b/CLAUDE.md index 5269fe0..8c761e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,11 @@ Routing is deliberate and hardwired, not runtime-probed: 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. + 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 @@ -107,6 +111,40 @@ OpenSSL 3.1.4), pinned as regression cases in `openssl_wrapper.selftest()`: | `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, @@ -140,6 +178,42 @@ 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 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/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 index 6b90779..d48a37c 100644 --- a/src/crypto/bignum.lua +++ b/src/crypto/bignum.lua @@ -1024,10 +1024,26 @@ end --- --- 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) - return openssl ~= nil and accelerator_ready(openssl) + 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 -- ============================================================================ @@ -1193,7 +1209,10 @@ function bignum.selftest() --- 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 + --- @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) @@ -1211,6 +1230,9 @@ function bignum.selftest() 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 = { @@ -1230,6 +1252,9 @@ function bignum.selftest() 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 @@ -1662,6 +1687,62 @@ function bignum.selftest() 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 diff --git a/src/crypto/ed25519.lua b/src/crypto/ed25519.lua index e0f7bfe..65050c1 100644 --- a/src/crypto/ed25519.lua +++ b/src/crypto/ed25519.lua @@ -37,8 +37,9 @@ local table_concat = table.concat -- CURVE25519 FIELD ARITHMETIC (shared field with X25519: p = 2^255 - 19) -- ============================================================================ ---- @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 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 diff --git a/src/crypto/hkdf.lua b/src/crypto/hkdf.lua index 9371f41..249df50 100644 --- a/src/crypto/hkdf.lua +++ b/src/crypto/hkdf.lua @@ -39,7 +39,10 @@ local table_concat = table.concat --- RFC 5869 terms, which fixes both the PRK size and the 255*HashLen output --- ceiling. --- ---- @alias HkdfHash { hmac: fun(key: string, data: string): string, length: integer } +--- @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 }, diff --git a/src/crypto/openssl_wrapper.lua b/src/crypto/openssl_wrapper.lua index fa5e0eb..a43501f 100644 --- a/src/crypto/openssl_wrapper.lua +++ b/src/crypto/openssl_wrapper.lua @@ -307,8 +307,40 @@ function openssl_wrapper.get_ungated(feature) 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; forces resolution of every declared feature. +--- +--- 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 = {} @@ -347,6 +379,9 @@ function openssl_wrapper.selftest() 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 @@ -465,6 +500,44 @@ function openssl_wrapper.selftest() 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() diff --git a/src/crypto/random.lua b/src/crypto/random.lua index c6b9e72..081b3c2 100644 --- a/src/crypto/random.lua +++ b/src/crypto/random.lua @@ -223,6 +223,9 @@ function random.selftest() 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 diff --git a/src/crypto/srp.lua b/src/crypto/srp.lua index cd85916..cc62704 100644 --- a/src/crypto/srp.lua +++ b/src/crypto/srp.lua @@ -211,6 +211,7 @@ end --- --- @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` @@ -402,7 +403,13 @@ end --- 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 @@ -814,6 +821,18 @@ function srp.selftest() 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 diff --git a/src/crypto/x25519.lua b/src/crypto/x25519.lua index 71116f3..cb8eeed 100644 --- a/src/crypto/x25519.lua +++ b/src/crypto/x25519.lua @@ -23,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