From 6ff59ab257c9ef19a558be5e2a9e19853dfd54d6 Mon Sep 17 00:00:00 2001 From: Leonardo Custodio Date: Mon, 14 Sep 2026 16:45:33 -0300 Subject: [PATCH 1/8] Some improvements --- .github/workflows/ci.yml | 56 + .size-limit.json | 4 +- CHANGELOG.md | 87 +- MIGRATION.md | 34 +- README.md | 4 +- RUST_DIVERGENCES.md | 137 +- api/crypto.api.md | 13 +- api/index.d.mts | 255 ++- bun.lock | 24 +- package.json | 4 +- scripts/check-heavy-vectors.ts | 39 + scripts/generate-vectors.ts | 36 +- src/aead.ts | 32 +- src/ecdsa.ts | 69 +- src/ed25519.ts | 56 +- src/error.ts | 124 +- src/hash.ts | 106 +- src/index.ts | 7 +- src/kdf.ts | 65 +- src/memzero.ts | 48 +- src/scrypt-core.ts | 248 +++ src/stream.ts | 19 +- src/x25519.ts | 31 +- tests/__snapshots__/golden.test.ts.snap | 10 +- tests/corpus/corpus.ts | 142 +- tests/crypto.property.test.ts | 336 ++- tests/crypto.test.ts | 148 +- tests/differential.test.ts | 34 +- tests/golden.test.ts | 6 +- tests/heavy-vectors.test.ts | 32 + tests/kdf-backend.test.ts | 96 + tests/rust-validation/Cargo.lock | 2 - tests/rust-validation/Cargo.toml | 8 + tests/rust-validation/README.md | 75 +- .../rust-validation/expected-divergences.json | 391 ---- .../bc-crypto-rust-4f2b791-edits.patch | 658 ++++++ tests/rust-validation/src/main.rs | 300 ++- tests/scrypt-core.test.ts | 67 + tests/strict-boundaries.test.ts | 74 + tests/vectors/heavy.json | 22 + tests/vectors/recipes.ts | 11 +- tests/vectors/vectors.json | 1897 ++++++++++++++++- 42 files changed, 4901 insertions(+), 906 deletions(-) create mode 100644 scripts/check-heavy-vectors.ts create mode 100644 src/scrypt-core.ts create mode 100644 tests/heavy-vectors.test.ts create mode 100644 tests/kdf-backend.test.ts delete mode 100644 tests/rust-validation/expected-divergences.json create mode 100644 tests/rust-validation/reference/bc-crypto-rust-4f2b791-edits.patch create mode 100644 tests/scrypt-core.test.ts create mode 100644 tests/vectors/heavy.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d198c3c..396a9e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,3 +57,59 @@ jobs: - name: Check dependency hygiene run: bun run check:deps + + rust-validation: + name: Cross-validate vectors against the Rust reference + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: latest + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: '24' + + - name: Install dependencies + run: bun install --frozen-lockfile + + # The reference is bc-crypto-rust at 4f2b791 plus the input-validation + # edits kept in tests/rust-validation/reference/, materialised at the + # path the harness's [patch.crates-io] names. This step goes with the + # patch once the release that contains the edits ships. + - name: Materialise the reference (bc-crypto-rust 4f2b791 + edits) + run: | + ref="$GITHUB_WORKSPACE/../../../bc-rust/bc-crypto-rust" + git clone --quiet https://github.com/BlockchainCommons/bc-crypto-rust.git "$ref" + git -C "$ref" checkout --quiet 4f2b791320730578b04943c833c4a9e6c232fc4d + git -C "$ref" apply --verbose "$GITHUB_WORKSPACE/tests/rust-validation/reference/bc-crypto-rust-4f2b791-edits.patch" + + # The whole corpus (tests/corpus/corpus.ts `allRecipes()`) + - name: Materialise the full corpus + run: bun scripts/generate-vectors.ts --full "${{ runner.temp }}/crypto-full-corpus.json" + + # The runner image ships a stable Rust toolchain + - name: Validate the golden vectors against the reference + working-directory: tests/rust-validation + run: cargo run --release --locked -- ../vectors/vectors.json + + - name: Validate the full corpus against the reference + working-directory: tests/rust-validation + run: cargo run --release --locked -- "${{ runner.temp }}/crypto-full-corpus.json" + + # scrypt logN 22, r 9: about 5 GiB per step, run one after another + - name: Validate the heavy vectors against the reference + working-directory: tests/rust-validation + run: cargo run --release --locked -- ../vectors/heavy.json + + - name: Check the heavy vectors on JavaScriptCore (Bun) + run: bun scripts/check-heavy-vectors.ts + + - name: Check the heavy vectors on Node + run: CRYPTO_HEAVY=1 bunx vitest run tests/heavy-vectors.test.ts diff --git a/.size-limit.json b/.size-limit.json index 46bf17d..775b2b8 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -2,7 +2,7 @@ { "name": "ESM entry (import *), minified + gzipped", "path": "dist/index.mjs", - "limit": "37 kB" + "limit": "40 kB" }, { "name": "ESM entry, package's own code (noble and rand external)", @@ -13,6 +13,6 @@ "@noble/hashes", "@blockchaincommons/rand" ], - "limit": "3 kB" + "limit": "6 kB" } ] diff --git a/CHANGELOG.md b/CHANGELOG.md index e62c812..7d870dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,90 @@ # Changelog +## Unreleased + +Closes every divergence from the reference that a TypeScript design can +remove. The reference is the `bc-rust/bc-crypto-rust` working tree (commit +`4f2b791`, tag 0.14.0, plus its input-validation edits), which the Rust harness +now patches in; against it there is no exception list. Requires +`@blockchaincommons/rand` ≥ 1.0.0-beta.3. + +### Changed (breaking) + +- **Every argument is type-checked first.** A byte argument that is not a + `Uint8Array` (a string, a plain array, an `ArrayBuffer`), an options + argument that is not an object, or a `littleEndian` that is not a boolean + throws `CryptoError` `InvalidParameter` naming the argument, before any + length, domain or backend check. Before, such values leaked engine + `TypeError`s, were silently accepted (`crc32([1, 2, 3])` returned a + checksum, `ed25519.verify(pk, sig, "msg")` returned `false`) or blamed + another argument (`ecdsa.sign(key, "msg")` reported the private key). A + `Buffer` and a `Uint8Array` from another realm are accepted. `memzero` and + `memzeroAll` require numeric typed arrays. +- **A low-order X25519 peer is `NonContributoryKey`.** `x25519.sharedKey` + throws the new code with the reference's message, `"X25519 peer key + produces an all-zero shared secret"` (its `try_x25519_shared_key` returns + `Err(NonContributoryKey)`), instead of `InvalidData` "low-order point". + `CryptoErrorCode` and `CryptoErrorDetails` gain the member. +- **scrypt has no default memory ceiling.** `maxmem` is an opt-in ceiling; + by default the parameters decide, as in the reference. logN 17, r 64 + (1.07 GiB) now derives (`88d8c775…86f3`), where noble's ~1 GiB default + rejected it. + +### Added + +- **A paged scrypt core for oversize parameter sets.** JavaScriptCore (Bun, + Safari) holds at most 2^32 bytes in one typed array; when `128·r·N` or + `128·r·p` exceeds 2^31 bytes, `scrypt` derives with an in-package RFC 7914 + core that keeps `V` and `B` in pages, byte-identical to noble and to the + reference (logN 22, r 9 gives `1fc13793…d167` on Bun in about 6 s at + 4.8 GiB). Smaller sets still go to noble. +- **Hybrid uncompressed keys.** `ecdsa.compressPublicKey` accepts + libsecp256k1's `06`/`07` prefixes when the low bit matches the parity of + y, as the reference does; a mismatch is `InvalidData`. +- **PBKDF2 `dkLen` up to (2^32 − 1)·hLen** (RFC 8018 §5.2; 32 or 64), where + the port stopped at 2^32 − 1. `iterations` 0 stays `InvalidParameter` at + every length, the typed form of the reference's `assert!(iterations > 0)`. +- `chacha20` returns `Uint8Array`. + +### Validation + +- The harness is patched to the reference tree, has no exception list, and + parses every argument with the Rust width before the call: a wrong-length + fixed argument, sealed data under 16 bytes, a number outside `u8`, `u32` + or `usize`, and raw ChaCha20 are js-only, never matches or mismatches. + The golden file grew from 679 to 807 vectors: point (de)compression and + AEAD decryption success paths, 66 non-canonical Ed25519 A and R rows, 19 + undecodable-key and r/s ∈ {0, n} verify rows, 21 low-order X25519 rows + carrying the error value, 6 hybrid keys, the logN 17 r 64 row and 3 width + probes. `--full` replays the whole corpus (1195) and `heavy.json` the + logN 22, r 9 vector; CI runs all three, plus the heavy vector on Bun and + Node. Results: `807 vectors - 793 match, 14 js-only, 0 MISMATCH`; + `1195 - 1180, 15, 0`; `1 - 1, 0, 0`. +- Tests: an argument-type property over every exported function, the + Ed25519 decoder boundary (dalek decodes 26 of the 40 non-canonical + encodings, the port none; `verify` is `false` for all 40 on both sides), + a verify-never-throws property, the Ed25519 packed and fallback generator + paths, malformed generators propagating rand's `InvalidGenerator` + unwrapped, backend spies for the PBKDF2 bound and the scrypt ceiling, and + the paged core against noble under one-block, three-block and default + pages plus the RFC 7914 vectors. + +### Corrections to the 1.0.0-beta.2 entry + +The four "behavioral differences" that entry kept were differences from the +released `bc-crypto` 0.14.0 crate, not from the reference tree, which already +returned `false` for an unparseable verify input, rejected a low-order X25519 +peer and asserted positive KDF costs. Against the reference, malformed verify +inputs match (`false` on both sides), scrypt logN 0 and PBKDF2 iterations 0 +are the usual panic → `InvalidParameter` mapping, and the low-order X25519 +peer is matched by code and message as of this release. + ## 1.0.0-beta.2 -Pending release. Fixes Ed25519 verification and adds reference parameter -validation. Four behavioral differences remain against published Rust -`bc-crypto` 0.14.0; see [RUST_DIVERGENCES.md](./RUST_DIVERGENCES.md). -Ordinary signing and derivation outputs are unchanged. +Fixes Ed25519 verification and adds reference parameter validation. Four +behavioral differences remained against the published Rust `bc-crypto` +0.14.0 crate (see the corrections above). Ordinary signing and derivation +outputs are unchanged. ### Fixed diff --git a/MIGRATION.md b/MIGRATION.md index 34dd17a..9bccc9c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -21,6 +21,13 @@ `CryptoResult` no longer exist, for documented validation and authentication failures. - [ ] `ed25519.verify` is strict (a small-order or non-canonical key or `R` never verifies); standard generated signatures are unaffected. +- [ ] Pass `Uint8Array`s (a `Buffer` qualifies). A string, plain array or + `ArrayBuffer` in any byte position is now `CryptoError` + `InvalidParameter`, named after the argument; encode text explicitly. +- [ ] A low-order X25519 peer in `x25519.sharedKey` is `NonContributoryKey` + (the reference's message), not `InvalidData`. +- [ ] scrypt has no default memory ceiling; pass `maxmem` if you want one. + PBKDF2 accepts `dkLen` up to (2^32 − 1)·hLen. - [ ] Raise your Node floor to **22.12** and TypeScript to **>= 5.7**. ## 1. Package name and imports @@ -112,7 +119,8 @@ try { case "AuthenticationFailed": // tag mismatch; e.cause is the backend's error case "InvalidSize": // e.details: { what, expected, actual } case "InvalidData": // a key, point or signature of the right length that is not valid - case "InvalidParameter": // a KDF or counter argument outside its domain + case "InvalidParameter": // an argument outside its domain, including a non-Uint8Array byte argument + case "NonContributoryKey": // x25519.sharedKey: a low-order peer key (all-zero shared secret) } } } @@ -123,13 +131,19 @@ public `CryptoError` constructor are gone; instances come from the static factories. Length checks that used to throw a bare `Error("Private key must be 32 bytes")` now throw `CryptoError` with `code: "InvalidSize"`; the message names the parameter and the actual length. -Invalid scalars or points, low-order X25519 public keys, and rejected KDF -parameters are reported as `CryptoError` (previously the -noble library's own `Error`/`RangeError` escaped). The three `verify` -functions return `false` for a malformed signature or public key of the -right length and only throw for wrong lengths; `ed25519.verify` is strict -(canonical encodings, no small-order key or `R`, and an uncofactored -equation). Rust uses the same equation but a more permissive public-key decoder. +Invalid scalars or points and rejected KDF parameters are reported as +`CryptoError` (previously the noble library's own `Error`/`RangeError` +escaped); a low-order X25519 public key is `NonContributoryKey`, with the +reference's message. Every byte argument is checked to be a `Uint8Array` +before anything else, and every options object to be an object: a string, +plain array or `ArrayBuffer` is `InvalidParameter` naming the argument (it +used to leak an engine `TypeError`, be silently accepted, or blame another +argument). The three `verify` functions return `false` for a malformed +signature or public key of the right length and only throw for wrong lengths +or wrong types; `ed25519.verify` is strict (canonical encodings, no +small-order key or `R`, and an uncofactored equation). Rust uses the same +equation but a more permissive public-key decoder; the difference cannot be +observed through `verify`, which is `false` on both sides for every such key. ## 5. Randomness @@ -137,7 +151,9 @@ Functions that draw randomness take `{ rng }` and default to `@blockchaincommons/rand`'s `secureRng()`. ECDSA/X25519 key generation and Schnorr auxiliary randomness use `randomBytes`. Ed25519 uses `fillBytesPacked` when supplied, falling back to `fillBytes`; this matches the Rust rand_core path. Custom generators -with different byte streams must expose that packed method: +with different byte streams must expose that packed method. A generator's +own error, including rand's `RandError` `InvalidGenerator` for a generator +that lacks a method the draw calls, propagates unwrapped: ```diff - const key = ecdsaNewPrivateKeyUsing(rng); diff --git a/README.md b/README.md index cffd963..1507a34 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ try { } ``` +Every byte argument must be a `Uint8Array` (a `Buffer` qualifies); a string, array or `ArrayBuffer` is `CryptoError` `InvalidParameter`, named after the argument, before any other check. A low-order X25519 peer is `NonContributoryKey`, with the reference's message. + `memzero(bytes)` and `memzeroAll(arrays)` overwrite a typed array with zeros as a best effort. JavaScript has no volatile writes and an engine may keep copies of a buffer, so treat them as defence in depth, not as a guarantee that a key has left memory. Runnable examples live in the [`examples/`](https://github.com/BlockchainCommons/bc-crypto-ts/tree/master/examples) directory. @@ -63,7 +65,7 @@ Runnable examples live in the [`examples/`](https://github.com/BlockchainCommons ### Version History - +- **Unreleased** - Every argument is type-checked before anything else (a non-`Uint8Array` byte argument is `InvalidParameter`); a low-order X25519 peer is `NonContributoryKey` with the reference's message; scrypt has no default memory ceiling and derives oversize parameter sets through a paged core on JavaScriptCore; hybrid `06`/`07` uncompressed keys compress; PBKDF2 accepts `dkLen` up to (2^32 − 1)·hLen. The Rust harness runs against the reference tree with no exception list, on the golden file, the full corpus and a heavy vector, in CI. - **1.0.0-beta.2 (September 12, 2026)** - Ed25519 uses the Rust reference's uncofactored verification equation; ChaCha20 counter-overflow reports `InvalidParameter`; scrypt validates its backend limits. scrypt mirrors the reference's parameter rules (`logN < 16·r`, `r·p < 2^30`, the parameterised path's `10..=64` output length) and gains `maxmem`; PBKDF2 accepts `dkLen: 0` with positive iterations; argon2id keeps only the reference's fixed costs. - **1.0.0-beta.1 (September 9, 2026)** - Initial beta implementation. diff --git a/RUST_DIVERGENCES.md b/RUST_DIVERGENCES.md index a3e5c75..fd4cef1 100644 --- a/RUST_DIVERGENCES.md +++ b/RUST_DIVERGENCES.md @@ -1,129 +1,34 @@ -# Compatibility with the Rust reference +# Divergences from the Rust reference implementation -The published reference is `bc-crypto` **0.14.0**, commit -[`4f2b791320730578b04943c833c4a9e6c232fc4d`](https://github.com/BlockchainCommons/bc-crypto-rust/commit/4f2b791320730578b04943c833c4a9e6c232fc4d), -recorded in [`.github/versions.yml`](./.github/versions.yml). -This document describes the current TypeScript working tree, including the pending beta.2 fixes -listed in [CHANGELOG.md](./CHANGELOG.md) and [MIGRATION.md](./MIGRATION.md). +The reference is the `bc-rust/bc-crypto-rust` working tree: commit +[`4f2b791320730578b04943c833c4a9e6c232fc4d`](https://github.com/BlockchainCommons/bc-crypto-rust/commit/4f2b791320730578b04943c833c4a9e6c232fc4d) +(tag `0.14.0`, `Cargo.toml` version 0.14.0) plus its uncommitted edits, recorded in +[`.github/versions.yml`](./.github/versions.yml): -## Validation scope +- `ecdsa_verify`, `schnorr_verify` and `ed25519_verify` return `false` for an unparseable public key or signature (`src/ecdsa_signing.rs`, `schnorr_signing.rs`, `ed25519_signing.rs`); +- `try_x25519_shared_key` returns `Err(Error::NonContributoryKey)` for a low-order peer, and `x25519_shared_key` panics on it (`src/public_key_encryption.rs`, `error.rs`); +- `scrypt_opt` asserts `log_n > 0` (`src/scrypt.rs`); +- `pbkdf2_hmac_sha256` and `pbkdf2_hmac_sha512` assert `iterations > 0`, including for empty output (`src/hash.rs`). -The committed corpus contains **679 vectors**. Against the published Rust reference: +The released `bc-crypto` 0.14.0 differs from the reference on these four points and is not the reference. `tests/rust-validation/Cargo.toml` patches `bc-crypto` to that tree (`[patch.crates-io]`), and CI reproduces the tree from `tests/rust-validation/reference/bc-crypto-rust-4f2b791-edits.patch`; when the release that contains the edits ships, the pin moves to it and nothing else changes (see Maintenance). -``` -679 vectors - 649 match, 30 expected-divergence/js-only, 0 MISMATCH -``` +`tests/rust-validation` replays the vectors against the reference in CI: -The 30 comprise D2 ×9, D3 ×13, D4 ×1, D5 ×4, and raw ChaCha20 ×3. -The exception list contains exact reviewed recipes in -[`tests/rust-validation/expected-divergences.json`](./tests/rust-validation/expected-divergences.json). -A new recipe cannot automatically inherit an exception. The harness also checks that D2 -returns the specific HKDF-of-zero key, not merely any successful result. + 807 vectors - 793 match, 14 js-only, 0 MISMATCH tests/vectors/vectors.json + 1195 vectors - 1180 match, 15 js-only, 0 MISMATCH the full corpus (generated in CI) + 1 vectors - 1 match, 0 js-only, 0 MISMATCH tests/vectors/heavy.json (scrypt logN 22, r 9) -The harness compares Rust outputs against committed TypeScript expectations. Golden tests -check the current TypeScript implementation against those expectations. Failures are -normalized to `throw`; this does not establish equality of error classes, messages, -panic recovery, or allocation behavior. Wrong-length fixed-array arguments are rejected -by the harness adapter because the Rust call cannot represent them. Raw ChaCha20 is -classified before comparison because `bc-crypto` has no corresponding function. +There is no exception list: any difference is a MISMATCH. The js-only rows are inputs the reference's signatures cannot receive (no reference function, as for raw ChaCha20; a fixed-size argument of the wrong length; sealed data under 16 bytes; a number outside the Rust width); they are neither matches nor divergences. -## Remaining behavioral differences from Rust 0.14.0 +## 1. True behavioral divergences (same input, different outcome) -### D2: X25519 low-order peers +For every input the reference accepts and a JavaScript runtime can hold, outputs and accept/reject decisions are identical, with one exception. -Rust applies HKDF-SHA-256 with salt `agreement` to the all-zero shared secret, -producing the same symmetric key independently of the private key. TypeScript rejects -that secret as `CryptoError` with `InvalidData`. +### D1. PBKDF2 output past (2^32 − 1)·hLen -The fixtures cover nine low-order encodings/aliases. They do not exhaust every high-bit -variant. RFC 7748 permits all-zero rejection; this higher-level API deliberately chooses it. - -### D3: malformed verification inputs - -TypeScript returns `false` for right-length invalid keys/signatures. Rust 0.14.0 can panic: - -- ECDSA: public-key or compact-signature parsing fails. -- Schnorr: x-only public-key parsing fails; invalid signature values can instead return false. -- Ed25519: public-key decoding fails; malformed R or s is handled inside verification. - -### D4: scrypt logN = 0 - -Rust's parameter constructor accepts N = 1 and computes a result. TypeScript requires -logN >= 1 and rejects this input with `InvalidParameter`, consistent with RFC 7914. - -### D5: PBKDF2 iterations = 0 - -For nonempty output, Rust treats zero iterations as one. For empty output, it returns -an empty vector. TypeScript rejects zero before checking output length, for both -SHA-256 and SHA-512. The four fixtures cover both hashes with lengths 0 and 32. - -## Closed in TypeScript: Ed25519's verification equation - -The beta.2 TypeScript implementation computes `[s]B - [k]A` and compares its canonical -encoding with R, as Dalek's `verify_strict` does. The earlier call to noble's verifier -cleared the cofactor, allowing a nonzero torsion residual to verify. - -Sixteen fixtures cover order-two torsion in A, R, both, or neither. They include signatures -that must reject and mixed-order cases that satisfy the equation and must remain valid. -Both implementations agree on these fixtures. Ordinary generated signatures retain their -results. Rejecting every mixed-order point would not implement Rust's policy. - -A residual **decoding-policy difference** remains: TypeScript requires canonical A and R -encodings; Dalek can decode some non-canonical A encodings and hashes the original bytes. -Dalek's final canonical byte comparison rejects non-canonical R. A parser difference alone -does not demonstrate an accepted-signature difference, and the present fixtures do not -prove equivalence for every non-canonical A. Keep this qualification when describing -“strict” compatibility; do not switch to ZIP-215 verification as a substitute. - -## Platform limits and additional APIs - -- **Integer and length validation:** JavaScript can express nonintegers, negative numbers, - and wrong-length byte arrays where Rust uses unsigned integers or fixed arrays. TypeScript - validates these inputs. This is not a cryptographic output difference. -- **scrypt limits:** TypeScript supports logN 1–32, subject to `logN < 16*r`, `r*p < 2^30`, - output-length rules, and backend/runtime allocation limits. Parameterized output lengths - are 10–64; the default path accepts 1 through `(2^32 - 1)*32` subject to memory. - Rust additionally checks usize multiplication overflow and has an architecture-dependent - logN limit. Its 0.11.0 backend's integer-division length check technically admits another - 31 output bytes beyond the stated maximum; this enormous allocation was not tested. -- **scrypt memory:** noble defaults to `128*8*(2^20 + 2)` bytes and checks - `128*r*(N+p+1)` against `maxmem`. Rust has no matching configurable ceiling. This is a real - resource-policy difference for parameters expressible on both sides. Raising `maxmem` - does not enable logN > 32 or bypass engine allocation limits. -- **PBKDF2 output:** TypeScript caps dkLen at `u32::MAX`; Rust accepts usize. Huge outputs - have not been allocated to test runtime behavior. Empty outputs match only with positive - iterations. -- **Raw ChaCha20:** this is an additional TypeScript API used by provenance marks. Its - backend reserves block counter `2^32 - 1`; initial counters are 0 through `2^32 - 2`, and - `ceil(data.length / 64)` must not exceed `2^32 - 1 - counter`. The wrapper validates - capacity and normalizes backend rejection to `CryptoError.InvalidParameter`. -- **Error handling:** documented validation/authentication failures use `CryptoError`; - verification failures of the correct input lengths return false. Resource exhaustion or - failures in user-supplied random generators are not covered by a universal error guarantee. - -## Equivalent mappings over supported inputs - -- ECDSA signs double SHA-256 using deterministic signatures and explicitly rejects high-S - signatures. Both sides produce compact 64-byte signatures. -- X25519 masks the public key's high bit on both sides, independently of low-order rejection. -- HKDF uses the same salts, empty info, and output bytes. Empty salt is equivalent to the - default zero salt for SHA-256 and SHA-512. Signing/agreement derivation names map to the - same HKDF operations; the two Rust signing derivations share one TypeScript function. -- scrypt defaults are logN 17, r 8, p 1. Argon2id defaults are v0x13, m 19456 KiB, t 2, p 1; - output length is at least 4 and salt length at least 8. Platform allocation limits remain. -- Rust's AEAD `(ciphertext, tag)` maps to TypeScript's `ciphertext || tag`. The harness joins - and splits them. Authentication failures are compared as normalized failures. -- Grouped TypeScript methods and options map to Rust free functions. Ed25519 verification - takes `(publicKey, signature, message)` in TypeScript; the adapter swaps Rust's order. -- ECDSA/X25519 private-key generation and Schnorr auxiliary randomness use `random_data` - on Rust and `randomBytes` on TypeScript. Ed25519 uses rand_core's `fill_bytes`, mapped to - `fillBytesPacked` when supplied and otherwise `fillBytes`. Any custom generator with - distinct streams must expose the packed method. Seeded fixtures compare both paths. +`pbkdf2Sha256`, `pbkdf2Sha512` and scrypt's default path stop at (2^32 − 1)·hLen output bytes (RFC 8018 §5.2) and throw `InvalidParameter` beyond it. The reference accepts a longer `key_len`, and its `pbkdf2` 0.12.2 backend's `u32` block counter then overflows: a panic with overflow checks, a wrapped counter (the output repeats from the start) without them. `scrypt` 0.11.0 admits 31 further bytes that reach that block. The port does not reproduce this because the reference's result depends on its build profile and needs at least 137 GiB of output. An upstream report to RustCrypto is pending. ## Maintenance -Run golden tests and the Rust harness whenever vectors or reference versions change. -A newly discovered divergence must include an input, both outcomes, rationale, and regression -coverage. Only add a reviewed exact-recipe exception for an intentionally retained difference. -When upstream fixes ship, update `.github/versions.yml`, the harness dependency/lockfile, -changelog and migration notes before revising this inventory and removing obsolete exceptions. +- CI runs the harness on `vectors.json`, the full corpus and `heavy.json`, and checks the heavy vectors in TypeScript under Bun and Node. A difference is a bug on one side. Either fix it, or record it here with an input, both outcomes, the reason no TypeScript design can match, and a vector. +- The reference is the bc-rust working tree, patched into the harness with `[patch.crates-io] bc-crypto = { path = … }`; the harness prints the resolved source on stderr. `.github/workflows/upstream.yml` opens an issue when bc-crypto-rust moves. When the release that contains the four edits ships, re-pin `tests/rust-validation/Cargo.toml`, `Cargo.lock` and `.github/versions.yml` to it, and drop the patch, the `reference/` directory and the CI step that materialises the tree. No port behaviour changes with that move; the three result lines must be unchanged. The bc-components-ts and bc-envelope-ts harnesses carry the same patch and move their locks on their own schedule. diff --git a/api/crypto.api.md b/api/crypto.api.md index 79a692f..36346e0 100644 --- a/api/crypto.api.md +++ b/api/crypto.api.md @@ -21,7 +21,7 @@ export interface Argon2idOptions { } // @public -export function chacha20(key: Uint8Array, nonce: Uint8Array, data: Uint8Array, input?: Chacha20Options): Uint8Array; +export function chacha20(key: Uint8Array, nonce: Uint8Array, data: Uint8Array, options?: Chacha20Options): Uint8Array; // @public export interface Chacha20Options { @@ -65,10 +65,11 @@ export class CryptoError extends Error { is(code: CryptoErrorCode): boolean; static isCryptoError(value: unknown): value is CryptoError; override readonly name = "CryptoError"; + static nonContributoryKey(cause?: unknown): CryptoError; } // @public -export type CryptoErrorCode = "InvalidSize" | "InvalidData" | "InvalidParameter" | "AuthenticationFailed"; +export type CryptoErrorCode = "InvalidSize" | "InvalidData" | "InvalidParameter" | "NonContributoryKey" | "AuthenticationFailed"; // @public export type CryptoErrorDetails = { @@ -82,6 +83,9 @@ export type CryptoErrorDetails = { } | { readonly code: "InvalidParameter"; readonly what: string; +} | { + readonly code: "NonContributoryKey"; + readonly what: string; } | { readonly code: "AuthenticationFailed"; }; @@ -97,7 +101,6 @@ export function doubleSha256(data: Uint8Array): Uint8Array; // @public export interface Ecdsa { - // (undocumented) compressPublicKey(uncompressed: Uint8Array): Uint8Array; // (undocumented) decompressPublicKey(compressed: Uint8Array): Uint8Array; @@ -161,10 +164,10 @@ export interface Pbkdf2Options { readonly iterations: number; } -// @public (undocumented) +// @public export function pbkdf2Sha256(password: Uint8Array, salt: Uint8Array, options: Pbkdf2Options): Uint8Array; -// @public (undocumented) +// @public export function pbkdf2Sha512(password: Uint8Array, salt: Uint8Array, options: Pbkdf2Options): Uint8Array; export { RngOptions } diff --git a/api/index.d.mts b/api/index.d.mts index c9dcebb..10ec331 100644 --- a/api/index.d.mts +++ b/api/index.d.mts @@ -1,12 +1,7 @@ import { RandomNumberGenerator, RngOptions, RngOptions as RngOptions$1 } from "@blockchaincommons/rand"; //#region src/error.d.ts -/** - * The single error type thrown by this package. - * - * @module error - */ /** Machine-readable discriminant for a {@link CryptoError}. */ -type CryptoErrorCode = "InvalidSize" | "InvalidData" | "InvalidParameter" | "AuthenticationFailed"; +type CryptoErrorCode = "InvalidSize" | "InvalidData" | "InvalidParameter" | "NonContributoryKey" | "AuthenticationFailed"; /** * The structured payload of a {@link CryptoError}, discriminated by `code`: * `e.details.code === "InvalidSize"` narrows to `{ what, expected, actual }`. @@ -23,12 +18,25 @@ type CryptoErrorDetails = { } | { /** A key, point or signature of the right length that is not valid. */ readonly code: "InvalidData"; - /** The argument, e.g. `"X25519 public key"`. */ + /** The argument, e.g. `"ECDSA compressed public key"`. */ readonly what: string; } | { - /** A KDF or counter argument outside its domain. */ + /** + * An argument outside its domain: a number that is not an integer of + * the Rust width, an options object that is not an object, a boolean + * option that is not a boolean, or a byte argument that is not a + * `Uint8Array`. Also a KDF parameter set the backend rejects. + */ readonly code: "InvalidParameter"; - /** The argument, e.g. `"scrypt logN"`. */ + /** The argument, e.g. `"scrypt logN"` or `"ECDSA message"`. */ + readonly what: string; +} | { + /** + * `x25519.sharedKey` was given a low-order peer key, so the shared + * secret would be all zero (the reference's `Error::NonContributoryKey`). + */ + readonly code: "NonContributoryKey"; + /** The argument: `"X25519 public key"`. */ readonly what: string; } | { /** AEAD authentication failed: wrong key, nonce or aad, or tampered data. */ @@ -37,12 +45,17 @@ type CryptoErrorDetails = { /** * Thrown for wrong-length keys, nonces, signatures and public keys * (`InvalidSize`), a key, point or signature of the right length that is not - * valid (`InvalidData`), a KDF or counter argument outside its domain - * (`InvalidParameter`), and AEAD tag mismatch (`AuthenticationFailed`). + * valid (`InvalidData`), an argument outside its domain, including a value + * of the wrong type (`InvalidParameter`), a low-order X25519 peer key + * (`NonContributoryKey`), and AEAD tag mismatch (`AuthenticationFailed`). * - * Every failure raised by this package is a `CryptoError`; when a backend - * error is what was caught, it is the `cause`. Instances come from the static - * factories only. + * Every failure of an argument or of a primitive is a `CryptoError`; when a + * backend error is what was caught, it is the `cause`. Two things propagate + * unwrapped, because they are not this package's: a generator's own error + * (`RandError` from `@blockchaincommons/rand`, including `InvalidGenerator` + * for a generator that lacks a method the draw calls), and an allocation + * failure outside the KDFs (`RangeError` from the engine). Instances come + * from the static factories only. * * @example * ```ts @@ -71,22 +84,30 @@ export declare class CryptoError extends Error { static invalidSize(what: string, expected: number, actual: number): CryptoError; /** `what` has the right length but is not a valid key, point or signature. */ static invalidData(what: string, message: string, cause?: unknown): CryptoError; - /** `what` (a KDF or counter argument) is outside its domain. */ + /** `what` (a number, an options object or a byte argument) is outside its domain. */ static invalidParameter(what: string, message: string, cause?: unknown): CryptoError; + /** + * The X25519 peer key is a low-order point, so the shared secret would be + * all zero. The message is the reference's `Error::NonContributoryKey` + * Display text. + */ + static nonContributoryKey(cause?: unknown): CryptoError; /** AEAD authentication failed (wrong key, nonce, aad, or tampered data). */ static authenticationFailed(cause?: unknown): CryptoError; } //#endregion //#region src/memzero.d.ts +/** The typed arrays {@link memzero} zeroes: every numeric kind, not `BigInt64Array`/`BigUint64Array` or a `DataView`. */ +type NumericTypedArray = Uint8Array | Uint8ClampedArray | Uint16Array | Uint32Array | Int8Array | Int16Array | Int32Array | Float32Array | Float64Array; /** - * Best-effort zeroing of secret buffers. - * - * @module memzero + * Overwrite every element with zero. + * @throws {CryptoError} `InvalidParameter` unless `data` is a numeric typed array. */ -type NumericTypedArray = Uint8Array | Uint8ClampedArray | Uint16Array | Uint32Array | Int8Array | Int16Array | Int32Array | Float32Array | Float64Array; -/** Overwrite every element with zero. */ export declare function memzero(data: NumericTypedArray): void; -/** {@link memzero} each array. */ +/** + * {@link memzero} each array. + * @throws {CryptoError} `InvalidParameter` unless `arrays` is an array of numeric typed arrays. + */ export declare function memzeroAll(arrays: readonly NumericTypedArray[]): void; //#endregion //#region src/hash.d.ts @@ -96,35 +117,75 @@ export declare const CRC32_SIZE = 4; export declare const SHA256_SIZE = 32; /** Bytes in a SHA-512 digest. */ export declare const SHA512_SIZE = 64; -/** CRC-32 (IEEE 802.3 / ISO-HDLC) as an unsigned 32-bit integer. */ +/** + * CRC-32 (IEEE 802.3 / ISO-HDLC) as an unsigned 32-bit integer. + * @throws {CryptoError} `InvalidParameter` unless `data` is a `Uint8Array`. + */ export declare function crc32(data: Uint8Array): number; /** Options for {@link crc32Bytes}. */ interface Crc32Options { /** Emit the checksum little-endian. Default: big-endian. */ readonly littleEndian?: boolean | undefined; } -/** CRC-32 as four bytes, big-endian unless `littleEndian` is set. */ +/** + * CRC-32 as four bytes, big-endian unless `littleEndian` is set. + * @throws {CryptoError} `InvalidParameter` unless `data` is a `Uint8Array`, + * `options` an object (or absent) and `littleEndian` a boolean (or absent). + */ export declare function crc32Bytes(data: Uint8Array, options?: Crc32Options): Uint8Array; -/** SHA-256 of `data` (32 bytes). */ +/** + * SHA-256 of `data` (32 bytes). + * @throws {CryptoError} `InvalidParameter` unless `data` is a `Uint8Array`. + */ export declare function sha256(data: Uint8Array): Uint8Array; -/** `sha256(sha256(data))`, the Bitcoin message hash. */ +/** + * `sha256(sha256(data))`, the Bitcoin message hash. + * @throws {CryptoError} `InvalidParameter` unless `data` is a `Uint8Array`. + */ export declare function doubleSha256(data: Uint8Array): Uint8Array; -/** SHA-512 of `data` (64 bytes). */ +/** + * SHA-512 of `data` (64 bytes). + * @throws {CryptoError} `InvalidParameter` unless `data` is a `Uint8Array`. + */ export declare function sha512(data: Uint8Array): Uint8Array; -/** HMAC-SHA-256 of `message` under `key` (32 bytes). */ +/** + * HMAC-SHA-256 of `message` under `key` (32 bytes). + * @throws {CryptoError} `InvalidParameter` unless both arguments are `Uint8Array`s. + */ export declare function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array; -/** HMAC-SHA-512 of `message` under `key` (64 bytes). */ +/** + * HMAC-SHA-512 of `message` under `key` (64 bytes). + * @throws {CryptoError} `InvalidParameter` unless both arguments are `Uint8Array`s. + */ export declare function hmacSha512(key: Uint8Array, message: Uint8Array): Uint8Array; /** Options for the PBKDF2 functions. */ interface Pbkdf2Options { - /** PBKDF2 iteration count; an integer ≥ 1. */ + /** + * PBKDF2 iteration count; an integer in [1, 2^32 − 1]. The reference asserts + * `iterations > 0`, including for empty output, so 0 is `InvalidParameter`. + */ readonly iterations: number; - /** Derived key length in bytes. */ + /** + * Derived key length in bytes; an integer in [0, (2^32 − 1) · hLen] + * (RFC 8018 §5.2, hLen 32 or 64). 0 is an empty key on both sides. An + * allocation the host cannot make surfaces as `InvalidParameter` with the + * engine error as `cause`. + */ readonly dkLen: number; } -/** @throws {CryptoError} `InvalidParameter` unless `iterations` is an integer ≥ 1 and `dkLen` an integer ≥ 0. */ +/** + * PBKDF2-HMAC-SHA-256. + * @throws {CryptoError} `InvalidParameter` unless `password` and `salt` are + * `Uint8Array`s, `options` is an object, `iterations` an integer in + * [1, 2^32 − 1] and `dkLen` an integer in [0, (2^32 − 1) · 32]. + */ export declare function pbkdf2Sha256(password: Uint8Array, salt: Uint8Array, options: Pbkdf2Options): Uint8Array; -/** @throws {CryptoError} `InvalidParameter` unless `iterations` is an integer ≥ 1 and `dkLen` an integer ≥ 0. */ +/** + * PBKDF2-HMAC-SHA-512. + * @throws {CryptoError} `InvalidParameter` unless `password` and `salt` are + * `Uint8Array`s, `options` is an object, `iterations` an integer in + * [1, 2^32 − 1] and `dkLen` an integer in [0, (2^32 − 1) · 64]. + */ export declare function pbkdf2Sha512(password: Uint8Array, salt: Uint8Array, options: Pbkdf2Options): Uint8Array; /** Options for the HKDF functions. */ interface HkdfOptions { @@ -133,12 +194,14 @@ interface HkdfOptions { } /** * HKDF-SHA-256 with no `info`, `dkLen` output bytes. - * @throws {CryptoError} `InvalidParameter` unless `dkLen` is an integer in [0, 255 · 32]. + * @throws {CryptoError} `InvalidParameter` unless `keyMaterial` and `salt` + * are `Uint8Array`s, `options` is an object and `dkLen` an integer in [0, 255 · 32]. */ export declare function hkdfSha256(keyMaterial: Uint8Array, salt: Uint8Array, options: HkdfOptions): Uint8Array; /** * HKDF-SHA-512 with no `info`, `dkLen` output bytes. - * @throws {CryptoError} `InvalidParameter` unless `dkLen` is an integer in [0, 255 · 64]. + * @throws {CryptoError} `InvalidParameter` unless `keyMaterial` and `salt` + * are `Uint8Array`s, `options` is an object and `dkLen` an integer in [0, 255 · 64]. */ export declare function hkdfSha512(keyMaterial: Uint8Array, salt: Uint8Array, options: HkdfOptions): Uint8Array; //#endregion @@ -151,22 +214,28 @@ interface ScryptOptions { * with the defaults any length ≥ 1 is accepted (the reference's default path). */ readonly dkLen: number; - /** log₂ of the CPU/memory cost `N`. Default 17 (N = 131072). An integer in [1, 32] and below `16·r`. */ + /** + * log₂ of the CPU/memory cost `N`. Default 17 (N = 131072). An integer in + * [1, 32] and below `16·r`; the reference asserts `log_n > 0`, and its + * panic is `InvalidParameter` here. Values above 32 need at least 3.3 TiB. + */ readonly logN?: number | undefined; /** Block size. Default 8. */ readonly r?: number | undefined; /** Parallelism. Default 1. `r·p` must be below 2^30. */ readonly p?: number | undefined; /** - * The working-memory ceiling in bytes the backend enforces - * (`128·r·(N + p + 1)` bytes are needed). Default a little over 1 GiB - * (`128·8·(2^20 + 2)`); the reference has no configurable ceiling. Raising maxmem does not bypass - * the backend's logN limit or the runtime's allocation limits. + * An optional ceiling in bytes on the working memory, `128·r·(N + p + 1)`. + * By default there is none, as in the reference, which allocates whatever + * the parameters imply; a parameter set above the ceiling is + * `InvalidParameter`. A ceiling does not bypass the `logN` limit or the + * runtime's allocation limits. */ readonly maxmem?: number | undefined; } /** - * @throws {CryptoError} `InvalidParameter` when `dkLen` is outside its domain + * @throws {CryptoError} `InvalidParameter` unless `password` and `salt` are + * `Uint8Array`s and `options` an object; when `dkLen` is outside its domain * (see {@link ScryptOptions.dkLen}), `logN` not in [1, 32] or not below `16·r` * (scrypt requires `N < 2^(128·r/8)`), `r` or `p` not ≥ 1, `r·p` not below * 2^30, or the parameters exceed `maxmem`. @@ -179,7 +248,8 @@ interface Argon2idOptions { } /** * Argon2id with the reference's fixed parameters (m 19456 KiB, t 2, p 1). - * @throws {CryptoError} `InvalidParameter` when `dkLen` is not an integer ≥ 4 + * @throws {CryptoError} `InvalidParameter` unless `password` and `salt` are + * `Uint8Array`s and `options` an object; when `dkLen` is not an integer ≥ 4 * or `salt` is shorter than 8 bytes. */ export declare function argon2id(password: Uint8Array, salt: Uint8Array, options: Argon2idOptions): Uint8Array; @@ -198,9 +268,18 @@ interface Chacha20Poly1305 { readonly NONCE_SIZE: 12; /** Poly1305 tag length in bytes; the trailing bytes of a sealed buffer. */ readonly TAG_SIZE: 16; - /** Returns `ciphertext || tag`; the tag is the trailing 16 bytes. */ + /** + * Returns `ciphertext || tag`; the tag is the trailing 16 bytes. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array` argument; + * `InvalidSize` on a wrong-length key or nonce. + */ encrypt(key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, options?: AeadOptions): Uint8Array; - /** Takes `ciphertext || tag`. @throws {CryptoError} `AuthenticationFailed` on tag mismatch. */ + /** + * Takes `ciphertext || tag`. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array` argument; + * `InvalidSize` on a wrong-length key or nonce, or sealed data shorter + * than the tag; `AuthenticationFailed` on tag mismatch. + */ decrypt(key: Uint8Array, nonce: Uint8Array, sealed: Uint8Array, options?: AeadOptions): Uint8Array; } /** ChaCha20-Poly1305 (RFC 8439). */ @@ -211,11 +290,13 @@ export declare const chacha20Poly1305: Chacha20Poly1305; * The stack's signing-key derivation: HKDF-SHA-256(keyMaterial, salt "signing") * → 32 bytes, used for ECDSA, Schnorr and Ed25519 private keys alike * (the reference's `derive_signing_private_key` ≡ `ecdsa_derive_private_key`). + * @throws {CryptoError} `InvalidParameter` unless `keyMaterial` is a `Uint8Array`. */ export declare function deriveSigningPrivateKey(keyMaterial: Uint8Array): Uint8Array; /** * The stack's agreement-key derivation: HKDF-SHA-256(keyMaterial, salt * "agreement") → 32 bytes, an X25519 private key. + * @throws {CryptoError} `InvalidParameter` unless `keyMaterial` is a `Uint8Array`. */ export declare function deriveAgreementPrivateKey(keyMaterial: Uint8Array): Uint8Array; /** The shape of the {@link x25519} family. */ @@ -224,17 +305,26 @@ interface X25519 { readonly PRIVATE_KEY_SIZE: 32; /** Public key length in bytes. */ readonly PUBLIC_KEY_SIZE: 32; - /** 32 random bytes from `options.rng` (default secure), unvalidated. */ + /** + * 32 random bytes from `options.rng` (default secure), unvalidated; the + * reference's `x25519_new_private_key_using` (`random_data`). A generator's + * own error, including rand's `InvalidGenerator`, propagates unwrapped. + * @throws {CryptoError} `InvalidParameter` unless `options` is an object or absent. + */ generatePrivateKey(options?: RngOptions$1): Uint8Array; /** * The X25519 public key of `privateKey` (clamped per RFC 7748). - * @throws {CryptoError} `InvalidSize` on a wrong-length key. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key. */ publicKey(privateKey: Uint8Array): Uint8Array; /** - * X25519 Diffie-Hellman, then HKDF-SHA-256 with salt "agreement" → 32 bytes. - * @throws {CryptoError} `InvalidData` when `publicKey` is a low-order point - * (the reference derives a predictable key instead; divergence D2). + * X25519 Diffie-Hellman, then HKDF-SHA-256 with salt "agreement" → 32 bytes + * (the reference's `try_x25519_shared_key`). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; + * `InvalidSize` on a wrong length; `NonContributoryKey` when `publicKey` + * is a low-order point, i.e. the shared secret would be all zero, as the + * reference's `try_x25519_shared_key` returns `Err(Error::NonContributoryKey)` + * (its `x25519_shared_key` wrapper panics on the same input). */ sharedKey(privateKey: Uint8Array, publicKey: Uint8Array): Uint8Array; } @@ -254,23 +344,36 @@ interface Ecdsa { readonly MESSAGE_HASH_SIZE: 32; /** Compact (`r ‖ s`) signature length in bytes. */ readonly SIGNATURE_SIZE: 64; - /** 32 random bytes from `options.rng` (default secure), unvalidated. */ + /** + * 32 random bytes from `options.rng` (default secure), unvalidated; the + * reference's `ecdsa_new_private_key_using` (`random_data`). A generator's + * own error, including rand's `InvalidGenerator`, propagates unwrapped. + * @throws {CryptoError} `InvalidParameter` unless `options` is an object or absent. + */ generatePrivateKey(options?: RngOptions$1): Uint8Array; /** * Compressed (33-byte) public key. - * @throws {CryptoError} `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar (0 or ≥ n). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar (0 or ≥ n). */ publicKey(privateKey: Uint8Array): Uint8Array; - /** @throws {CryptoError} `InvalidSize` on a wrong length; `InvalidData` when the bytes are not a point on the curve. */ + /** @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong length; `InvalidData` when the bytes are not a point on the curve. */ decompressPublicKey(compressed: Uint8Array): Uint8Array; - /** @throws {CryptoError} `InvalidSize` on a wrong length; `InvalidData` when the bytes are not a point on the curve. */ + /** + * 65 bytes: `04 ‖ x ‖ y`, or the hybrid `06`/`07` forms, whose low bit must + * equal the parity of y (libsecp256k1's parser, which the reference uses). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong length; `InvalidData` when the bytes are not a point on the curve or a hybrid prefix disagrees with y. + */ compressPublicKey(uncompressed: Uint8Array): Uint8Array; /** * Deterministic (RFC 6979) signature over `doubleSha256(message)`; 64-byte compact form. - * @throws {CryptoError} `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar. */ sign(privateKey: Uint8Array, message: Uint8Array): Uint8Array; - /** `false` on an invalid signature; throws only on wrong-length inputs. */ + /** + * `false` on an invalid signature, and for an unparseable key or an r or s + * ≥ n of the right length (the reference's `let Ok(..) = … else { return false; }`). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature. + */ verify(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array): boolean; } /** secp256k1 ECDSA: keys, point compression, and RFC 6979 signatures over double SHA-256. */ @@ -290,17 +393,20 @@ interface Schnorr { readonly SIGNATURE_SIZE: 64; /** * x-only (32-byte) public key of a secp256k1 private key. - * @throws {CryptoError} `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar. */ publicKey(privateKey: Uint8Array): Uint8Array; /** - * BIP-340 signature. Aux-rand comes from `options.auxRand`, else 32 bytes drawn from `options.rng`. - * @throws {CryptoError} `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar. + * BIP-340 signature. Aux-rand comes from `options.auxRand`, else 32 bytes + * drawn from `options.rng` (the reference's `schnorr_sign_using`, + * `random_data(32)`); a generator's own error propagates unwrapped. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array` or a non-object `options`; `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar. */ sign(privateKey: Uint8Array, message: Uint8Array, options?: SchnorrSignOptions): Uint8Array; /** - * `false` on an invalid signature or a malformed key (BIP-340 vectors 5–14). - * @throws {CryptoError} `InvalidSize` on a wrong-length key or signature. + * `false` on an invalid signature or an unparseable key (BIP-340 vectors + * 5–14; the reference's `let Ok(pk) = … else { return false; }`). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature. */ verify(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array): boolean; } @@ -316,25 +422,34 @@ interface Ed25519 { readonly PUBLIC_KEY_SIZE: 32; /** Signature length in bytes. */ readonly SIGNATURE_SIZE: 64; - /** 32 random bytes from `options.rng` (default secure). */ + /** + * 32 random bytes from `options.rng` (default secure). The reference's + * `ed25519_new_private_key_using` draws through `rand_core`'s `fill_bytes`, + * so the generator's `fillBytesPacked` is used when it has one (the packed + * stream of `SeededRng`), else `fillBytes` through rand's `fillRandomBytes`. + * A generator's own error propagates unwrapped; a `fillBytesPacked` that is + * present but not a function is rand's `RandError` `InvalidGenerator`. + * @throws {CryptoError} `InvalidParameter` unless `options` is an object or absent. + */ generatePrivateKey(options?: RngOptions$1): Uint8Array; /** * The public key of the 32-byte seed `privateKey` (RFC 8032). - * @throws {CryptoError} `InvalidSize` on a wrong-length key. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key. */ publicKey(privateKey: Uint8Array): Uint8Array; /** * Deterministic RFC 8032 signature (64 bytes). - * @throws {CryptoError} `InvalidSize` on a wrong-length key. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key. */ sign(privateKey: Uint8Array, message: Uint8Array): Uint8Array; /** * `(publicKey, signature, message)`, the same order as `ecdsa.verify` and `schnorr.verify`. * - * Uses the reference's uncofactored verification equation. Only canonical point - * encodings are accepted, and a small-order public key or `R` never - * verifies. `false` on any invalid or malformed input of the right length. - * @throws {CryptoError} `InvalidSize` on a wrong-length key or signature. + * Uses the reference's uncofactored verification equation (`verify_strict`). + * Only canonical point encodings are accepted, and a small-order public + * key or `R` never verifies. `false` on any invalid or malformed input of + * the right length, including a key the reference cannot decode. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature. */ verify(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array): boolean; } @@ -350,12 +465,14 @@ interface Chacha20Options { /** * XORs `data` with the ChaCha20 keystream for `key` (32 bytes) and `nonce` * (12 bytes), starting at block `counter`. Applying it twice restores the - * input. - * @throws {CryptoError} `InvalidSize` on a wrong-length key or nonce; + * input. There is no `bc-crypto` function for this; it is provenance-mark's + * `ChaCha20::new` + `apply_keystream` (the `chacha20` crate). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array` argument or + * a non-object `options`; `InvalidSize` on a wrong-length key or nonce; * `InvalidParameter` if `counter` is outside [0, 2^32 - 2] or the data * would use the backend-reserved block counter 2^32 - 1. */ -export declare function chacha20(key: Uint8Array, nonce: Uint8Array, data: Uint8Array, { counter }?: Chacha20Options): Uint8Array; +export declare function chacha20(key: Uint8Array, nonce: Uint8Array, data: Uint8Array, options?: Chacha20Options): Uint8Array; //#endregion export type { AeadOptions, Argon2idOptions, Chacha20Options, Chacha20Poly1305, Crc32Options, CryptoErrorCode, CryptoErrorDetails, Ecdsa, Ed25519, HkdfOptions, NumericTypedArray, Pbkdf2Options, RngOptions, Schnorr, SchnorrSignOptions, ScryptOptions, X25519 }; //# sourceMappingURL=index.d.mts.map \ No newline at end of file diff --git a/bun.lock b/bun.lock index 700d7ec..dd0b288 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@blockchaincommons/crypto", "dependencies": { - "@blockchaincommons/rand": "^1.0.0-beta.2", + "@blockchaincommons/rand": "^1.0.0-beta.3", "@noble/ciphers": "^2.4.0", "@noble/curves": "^2.4.0", "@noble/hashes": "^2.4.0", @@ -49,7 +49,7 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], - "@blockchaincommons/rand": ["@blockchaincommons/rand@1.0.0-beta.2", "", {}, "sha512-RCmCzePC4Ja3WQWebSm0kD0id7gkqhKbppmoLdNSAz17wQvsoZG/MWs/IcH4TYGYJ0UxX7b+qxwxpdq81ib4Yg=="], + "@blockchaincommons/rand": ["@blockchaincommons/rand@1.0.0-beta.3", "", {}, "sha512-SelWiTP+Ngb2y9ZOT5deyDmGFjkP0JxJxMN3GA1UOAiV05XtDgUPMPFreU3LQ9zBpAepFajE8t7IrCf2dDQRxA=="], "@braidai/lang": ["@braidai/lang@1.1.2", "", {}, "sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA=="], @@ -225,11 +225,11 @@ "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], - "@size-limit/esbuild": ["@size-limit/esbuild@13.0.3", "", { "dependencies": { "esbuild": "^0.28.1", "nanoid": "^6.0.0" }, "peerDependencies": { "size-limit": "13.0.3" } }, "sha512-g24wsTxM3N/SaGv1MiiDjTShNrIna1WCNJ7NXMrlsWBHWv1OL0nCyllR1bDDHf+gV41lF7QxX7vknswoOr71DQ=="], + "@size-limit/esbuild": ["@size-limit/esbuild@13.1.1", "", { "dependencies": { "esbuild": "^0.28.2", "nanoid": "^6.0.1" }, "peerDependencies": { "size-limit": "13.1.1" } }, "sha512-+aWz9zUJTtnkc1UejLzrFGBOmM/H6QpYvL39BfgmxUULCeLn6xMb2sQw2w8n1CaF70/DvH9/J9QdNl4kAhdrHA=="], - "@size-limit/file": ["@size-limit/file@13.0.3", "", { "peerDependencies": { "size-limit": "13.0.3" } }, "sha512-PWTITIXH5p9aGIf6qq2Fruihn/b9nBQyfkyoAyb6DzFJgS1Ek9MSPJYKxKFLO8jdo0aqSgBPd3sevbS6PyBiJw=="], + "@size-limit/file": ["@size-limit/file@13.1.1", "", { "peerDependencies": { "size-limit": "13.1.1" } }, "sha512-hWsrLRKBSh1vVFa86e2WcpBWcTYnwRvvZ54o6wzmPduhYUQPgOCr9eWqNMLCxvqLP5EiJeGCwLQFDjdgusGFSw=="], - "@size-limit/preset-small-lib": ["@size-limit/preset-small-lib@13.0.3", "", { "dependencies": { "@size-limit/esbuild": "13.0.3", "@size-limit/file": "13.0.3", "size-limit": "13.0.3" } }, "sha512-rqKn1+JkVF5ckZRmcxeQPZ8g0e9Fqddh6bjmDotijXJtN40KJQ+5TG6pTiYq3RaA4nkuEkixHULpDBGcgAARAg=="], + "@size-limit/preset-small-lib": ["@size-limit/preset-small-lib@13.1.1", "", { "dependencies": { "@size-limit/esbuild": "13.1.1", "@size-limit/file": "13.1.1", "size-limit": "13.1.1" } }, "sha512-by5zdyqKuIU3umUo3WQFwDyv1/cOJnYR/cE8VEZeW9lPRyfaExiWu1A3jzIn+LA4GGZaYvQ5Z6THHMlGij9Jmw=="], "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], @@ -477,7 +477,7 @@ "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], - "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + "fast-check": ["fast-check@4.10.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -593,9 +593,9 @@ "magic-string": ["magic-string@1.3.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg=="], - "magicast": ["magicast@0.5.4", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="], + "magicast": ["magicast@0.5.5", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA=="], - "markdown-it": ["markdown-it@14.3.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA=="], + "markdown-it": ["markdown-it@14.3.2", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q=="], "marked": ["marked@9.1.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q=="], @@ -685,7 +685,7 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "size-limit": ["size-limit@13.0.3", "", { "dependencies": { "bytes-iec": "^3.1.1", "lilconfig": "^3.1.3", "nanospinner": "^1.2.2" }, "bin": { "size-limit": "bin.js" } }, "sha512-KVb2aNEU49BwTR21SVjD+2QHP9gBV/nWsTHzNB/heRwXtHyA7lLQiDZDQ1TiNh/B/TZXKAZrHYyTt+cvBUrzYw=="], + "size-limit": ["size-limit@13.1.1", "", { "dependencies": { "bytes-iec": "^3.1.1", "lilconfig": "^3.1.3", "nanospinner": "^1.2.2" }, "bin": { "size-limit": "bin.js" } }, "sha512-Yl3CuQFSB3TSirrg9jyD/ahEUa6+aoMWj4UuucW36XunEsBfYhRACZRQr7vaUKoLkS88Njw0czdpDQFfbvCc+Q=="], "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], @@ -767,7 +767,7 @@ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], "yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="], @@ -797,8 +797,6 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], - "ajv-draft-04/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -825,8 +823,6 @@ "@rushstack/node-core-library/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "ajv-draft-04/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], } } diff --git a/package.json b/package.json index c06da77..a65de64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockchaincommons/crypto", - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", "type": "module", "sideEffects": false, "description": "Blockchain Commons Cryptographic Utilities for TypeScript", @@ -85,7 +85,7 @@ "node": ">=22.12" }, "dependencies": { - "@blockchaincommons/rand": "^1.0.0-beta.2", + "@blockchaincommons/rand": "^1.0.0-beta.3", "@noble/ciphers": "^2.4.0", "@noble/curves": "^2.4.0", "@noble/hashes": "^2.4.0" diff --git a/scripts/check-heavy-vectors.ts b/scripts/check-heavy-vectors.ts new file mode 100644 index 0000000..ab9bd5b --- /dev/null +++ b/scripts/check-heavy-vectors.ts @@ -0,0 +1,39 @@ +/** + * Replays tests/vectors/heavy.json with the working tree on this runtime. + * + * bun scripts/check-heavy-vectors.ts + * + * Run it with Bun in CI: JavaScriptCore caps one typed array at 2^32 bytes, + * so this is the runtime where scrypt's paged core has to produce the + * reference's bytes. Node runs the same file through + * `CRYPTO_HEAVY=1 bunx vitest run tests/heavy-vectors.test.ts`. Needs about + * 5 GiB of memory and takes seconds to a minute. Exit 1 on any difference. + */ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as src from "../src/index.ts"; +import * as rand from "@blockchaincommons/rand"; +import { materialize, redesignedAdapterFor, type Recipe } from "../tests/vectors/recipes.ts"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const { count, vectors } = JSON.parse( + readFileSync(join(root, "tests/vectors/heavy.json"), "utf8"), +) as { count: number; vectors: { recipe: Recipe; expect: string }[] }; +if (vectors.length !== count) throw new Error("heavy.json count does not match its vectors"); +const api = redesignedAdapterFor(src, rand); + +let mismatch = 0; +for (const v of vectors) { + const t0 = performance.now(); + const got = materialize(api, v.recipe); + const seconds = ((performance.now() - t0) / 1000).toFixed(1); + if (got === v.expect) { + console.log(`match ${JSON.stringify(v.recipe)} (${seconds} s)`); + } else { + mismatch++; + console.error(`MISMATCH ${JSON.stringify(v.recipe)}\n expect: ${v.expect}\n got: ${got}`); + } +} +console.log(`${vectors.length} heavy vectors - ${vectors.length - mismatch} match, ${mismatch} MISMATCH`); +process.exit(mismatch === 0 ? 0 : 1); diff --git a/scripts/generate-vectors.ts b/scripts/generate-vectors.ts index 71a8379..716092a 100644 --- a/scripts/generate-vectors.ts +++ b/scripts/generate-vectors.ts @@ -1,7 +1,11 @@ /** - * Golden vector generator. `bun scripts/generate-vectors.ts`. - * Materialises the golden recipe subset with the WORKING TREE and writes - * tests/vectors/vectors.json. Regenerating is a deliberate, reviewed act. + * Vector generator. Materialises recipes with the WORKING TREE. + * + * bun scripts/generate-vectors.ts golden subset → tests/vectors/vectors.json + * bun scripts/generate-vectors.ts --full every corpus recipe → (not committed; CI replays it) + * bun scripts/generate-vectors.ts --heavy heavyRecipes() → tests/vectors/heavy.json (gigabytes of memory) + * + * Regenerating a committed file is a deliberate, reviewed act. */ import { writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -9,14 +13,26 @@ import { fileURLToPath } from "node:url"; import * as src from "../src/index.ts"; import * as rand from "@blockchaincommons/rand"; import { materialize, redesignedAdapterFor } from "../tests/vectors/recipes.ts"; -import { goldenRecipes } from "../tests/corpus/corpus.ts"; +import { allRecipes, goldenRecipes, heavyRecipes } from "../tests/corpus/corpus.ts"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const api = redesignedAdapterFor(src, rand); +const args = process.argv.slice(2); + +const [recipes, out, label] = (() => { + const full = args.indexOf("--full"); + if (full !== -1) { + const path = args[full + 1]; + if (path === undefined) throw new Error("--full needs an output path"); + return [allRecipes(), path, "full corpus"] as const; + } + if (args.includes("--heavy")) { + return [heavyRecipes(), join(root, "tests/vectors/heavy.json"), "heavy"] as const; + } + return [goldenRecipes(), join(root, "tests/vectors/vectors.json"), "golden"] as const; +})(); + const vectors = []; -for (const recipe of goldenRecipes()) vectors.push({ recipe, expect: materialize(api, recipe) }); -writeFileSync( - join(root, "tests/vectors/vectors.json"), - JSON.stringify({ count: vectors.length, vectors }, null, 1) + "\n", -); -console.log(`wrote ${vectors.length} vectors`); +for (const recipe of recipes) vectors.push({ recipe, expect: materialize(api, recipe) }); +writeFileSync(out, JSON.stringify({ count: vectors.length, vectors }, null, 1) + "\n"); +console.log(`wrote ${vectors.length} ${label} vectors to ${out}`); diff --git a/src/aead.ts b/src/aead.ts index df28598..fed24c6 100644 --- a/src/aead.ts +++ b/src/aead.ts @@ -4,7 +4,7 @@ * @module aead */ import { chacha20poly1305 } from "@noble/ciphers/chacha.js"; -import { CryptoError, requireLength } from "./error.js"; +import { CryptoError, requireBytes, requireLength, requireOptions } from "./error.js"; const SYMMETRIC_KEY_SIZE = 32; const SYMMETRIC_NONCE_SIZE = 12; @@ -18,6 +18,15 @@ export interface AeadOptions { const EMPTY = new Uint8Array(0); +/** The `aad` option, validated: `undefined` means none (the same as empty). */ +function aadOf(options: AeadOptions | undefined): Uint8Array { + requireOptions("ChaCha20-Poly1305 options", options, true); + const aad = options?.aad; + if (aad === undefined) return EMPTY; + requireBytes("ChaCha20-Poly1305 aad", aad); + return aad; +} + /** The {@link chacha20Poly1305} family. */ export interface Chacha20Poly1305 { /** Key length in bytes. */ @@ -26,14 +35,23 @@ export interface Chacha20Poly1305 { readonly NONCE_SIZE: 12; /** Poly1305 tag length in bytes; the trailing bytes of a sealed buffer. */ readonly TAG_SIZE: 16; - /** Returns `ciphertext || tag`; the tag is the trailing 16 bytes. */ + /** + * Returns `ciphertext || tag`; the tag is the trailing 16 bytes. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array` argument; + * `InvalidSize` on a wrong-length key or nonce. + */ encrypt( key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, options?: AeadOptions, ): Uint8Array; - /** Takes `ciphertext || tag`. @throws {CryptoError} `AuthenticationFailed` on tag mismatch. */ + /** + * Takes `ciphertext || tag`. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array` argument; + * `InvalidSize` on a wrong-length key or nonce, or sealed data shorter + * than the tag; `AuthenticationFailed` on tag mismatch. + */ decrypt( key: Uint8Array, nonce: Uint8Array, @@ -51,12 +69,16 @@ export const chacha20Poly1305: Chacha20Poly1305 = { encrypt(key, nonce, plaintext, options) { requireLength("ChaCha20-Poly1305 key", key, SYMMETRIC_KEY_SIZE); requireLength("ChaCha20-Poly1305 nonce", nonce, SYMMETRIC_NONCE_SIZE); - return chacha20poly1305(key, nonce, options?.aad ?? EMPTY).encrypt(plaintext); + requireBytes("ChaCha20-Poly1305 plaintext", plaintext); + const aad = aadOf(options); + return chacha20poly1305(key, nonce, aad).encrypt(plaintext); }, decrypt(key, nonce, sealed, options) { requireLength("ChaCha20-Poly1305 key", key, SYMMETRIC_KEY_SIZE); requireLength("ChaCha20-Poly1305 nonce", nonce, SYMMETRIC_NONCE_SIZE); + requireBytes("ChaCha20-Poly1305 sealed data", sealed); + const aad = aadOf(options); if (sealed.length < SYMMETRIC_AUTH_SIZE) { throw CryptoError.invalidSize( "ChaCha20-Poly1305 sealed data", @@ -65,7 +87,7 @@ export const chacha20Poly1305: Chacha20Poly1305 = { ); } try { - return chacha20poly1305(key, nonce, options?.aad ?? EMPTY).decrypt(sealed); + return chacha20poly1305(key, nonce, aad).decrypt(sealed); } catch (error) { throw CryptoError.authenticationFailed(error); } diff --git a/src/ecdsa.ts b/src/ecdsa.ts index c7ffa3d..9a615be 100644 --- a/src/ecdsa.ts +++ b/src/ecdsa.ts @@ -11,7 +11,7 @@ import { secureRng, } from "@blockchaincommons/rand"; import { doubleSha256 } from "./hash.js"; -import { CryptoError, requireLength } from "./error.js"; +import { CryptoError, requireBytes, requireLength, requireOptions } from "./error.js"; import { guard } from "./domain.js"; const ECDSA_PRIVATE_KEY_SIZE = 32; @@ -42,23 +42,36 @@ export interface Ecdsa { readonly MESSAGE_HASH_SIZE: 32; /** Compact (`r ‖ s`) signature length in bytes. */ readonly SIGNATURE_SIZE: 64; - /** 32 random bytes from `options.rng` (default secure), unvalidated. */ + /** + * 32 random bytes from `options.rng` (default secure), unvalidated; the + * reference's `ecdsa_new_private_key_using` (`random_data`). A generator's + * own error, including rand's `InvalidGenerator`, propagates unwrapped. + * @throws {CryptoError} `InvalidParameter` unless `options` is an object or absent. + */ generatePrivateKey(options?: RngOptions): Uint8Array; /** * Compressed (33-byte) public key. - * @throws {CryptoError} `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar (0 or ≥ n). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar (0 or ≥ n). */ publicKey(privateKey: Uint8Array): Uint8Array; - /** @throws {CryptoError} `InvalidSize` on a wrong length; `InvalidData` when the bytes are not a point on the curve. */ + /** @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong length; `InvalidData` when the bytes are not a point on the curve. */ decompressPublicKey(compressed: Uint8Array): Uint8Array; - /** @throws {CryptoError} `InvalidSize` on a wrong length; `InvalidData` when the bytes are not a point on the curve. */ + /** + * 65 bytes: `04 ‖ x ‖ y`, or the hybrid `06`/`07` forms, whose low bit must + * equal the parity of y (libsecp256k1's parser, which the reference uses). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong length; `InvalidData` when the bytes are not a point on the curve or a hybrid prefix disagrees with y. + */ compressPublicKey(uncompressed: Uint8Array): Uint8Array; /** * Deterministic (RFC 6979) signature over `doubleSha256(message)`; 64-byte compact form. - * @throws {CryptoError} `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar. */ sign(privateKey: Uint8Array, message: Uint8Array): Uint8Array; - /** `false` on an invalid signature; throws only on wrong-length inputs. */ + /** + * `false` on an invalid signature, and for an unparseable key or an r or s + * ≥ n of the right length (the reference's `let Ok(..) = … else { return false; }`). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature. + */ verify(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array): boolean; } @@ -71,6 +84,7 @@ export const ecdsa: Ecdsa = { SIGNATURE_SIZE: 64, generatePrivateKey(options) { + requireOptions("ECDSA options", options, true); return randomBytes(ECDSA_PRIVATE_KEY_SIZE, { rng: options?.rng ?? secureRng() }); }, @@ -96,14 +110,26 @@ export const ecdsa: Ecdsa = { uncompressed, ECDSA_UNCOMPRESSED_PUBLIC_KEY_SIZE, ); - return guard( - () => secp256k1.Point.fromBytes(uncompressed).toBytes(true), - invalidPoint("ECDSA uncompressed public key"), - ); + const head = uncompressed[0]; + return guard(() => { + if (head === 0x06 || head === 0x07) { + // libsecp256k1 (`secp256k1_eckey_pubkey_parse`) accepts the hybrid + // prefixes when the low bit states y's parity; noble parses `04` only. + const plain = Uint8Array.from(uncompressed); + plain[0] = 0x04; + const point = secp256k1.Point.fromBytes(plain); + if ((point.toAffine().y & 1n) !== BigInt(head & 1)) { + throw new Error("hybrid prefix does not match the parity of y"); + } + return point.toBytes(true); + } + return secp256k1.Point.fromBytes(uncompressed).toBytes(true); + }, invalidPoint("ECDSA uncompressed public key")); }, sign(privateKey, message) { requireLength("ECDSA private key", privateKey, ECDSA_PRIVATE_KEY_SIZE); + requireBytes("ECDSA message", message); return guard( () => secp256k1.sign(doubleSha256(message), privateKey, { prehash: false }), invalidScalar("ECDSA private key"), @@ -113,6 +139,7 @@ export const ecdsa: Ecdsa = { verify(publicKey, signature, message) { requireLength("ECDSA public key", publicKey, ECDSA_PUBLIC_KEY_SIZE); requireLength("ECDSA signature", signature, ECDSA_SIGNATURE_SIZE); + requireBytes("ECDSA message", message); try { return secp256k1.verify(signature, doubleSha256(message), publicKey, { prehash: false, @@ -140,12 +167,14 @@ export interface Schnorr { readonly SIGNATURE_SIZE: 64; /** * x-only (32-byte) public key of a secp256k1 private key. - * @throws {CryptoError} `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar. */ publicKey(privateKey: Uint8Array): Uint8Array; /** - * BIP-340 signature. Aux-rand comes from `options.auxRand`, else 32 bytes drawn from `options.rng`. - * @throws {CryptoError} `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar. + * BIP-340 signature. Aux-rand comes from `options.auxRand`, else 32 bytes + * drawn from `options.rng` (the reference's `schnorr_sign_using`, + * `random_data(32)`); a generator's own error propagates unwrapped. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array` or a non-object `options`; `InvalidSize` on a wrong length; `InvalidData` when the key is not a valid scalar. */ sign( privateKey: Uint8Array, @@ -153,8 +182,9 @@ export interface Schnorr { options?: SchnorrSignOptions, ): Uint8Array; /** - * `false` on an invalid signature or a malformed key (BIP-340 vectors 5–14). - * @throws {CryptoError} `InvalidSize` on a wrong-length key or signature. + * `false` on an invalid signature or an unparseable key (BIP-340 vectors + * 5–14; the reference's `let Ok(pk) = … else { return false; }`). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature. */ verify(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array): boolean; } @@ -171,7 +201,11 @@ export const schnorr: Schnorr = { sign(privateKey, message, options) { requireLength("Schnorr private key", privateKey, ECDSA_PRIVATE_KEY_SIZE); - const auxRand = options?.auxRand ?? randomBytes(32, { rng: options?.rng ?? secureRng() }); + requireBytes("Schnorr message", message); + requireOptions("Schnorr options", options, true); + const given = options?.auxRand; + if (given !== undefined) requireBytes("Schnorr auxiliary randomness", given); + const auxRand = given ?? randomBytes(32, { rng: options?.rng ?? secureRng() }); if (auxRand.length !== 32) throw CryptoError.invalidSize("Schnorr auxiliary randomness", 32, auxRand.length); return guard( @@ -183,6 +217,7 @@ export const schnorr: Schnorr = { verify(publicKey, signature, message) { requireLength("Schnorr public key", publicKey, SCHNORR_PUBLIC_KEY_SIZE); requireLength("Schnorr signature", signature, SCHNORR_SIGNATURE_SIZE); + requireBytes("Schnorr message", message); try { return nobleSchnorr.verify(signature, message, publicKey); } catch { diff --git a/src/ed25519.ts b/src/ed25519.ts index 03274a2..88e77a8 100644 --- a/src/ed25519.ts +++ b/src/ed25519.ts @@ -6,8 +6,14 @@ import { ed25519 as noble } from "@noble/curves/ed25519.js"; import { bytesToNumberLE, equalBytes } from "@noble/curves/utils.js"; import { sha512 } from "@noble/hashes/sha2.js"; -import { type RandomNumberGenerator, type RngOptions, secureRng } from "@blockchaincommons/rand"; -import { requireLength } from "./error.js"; +import { + type RandomNumberGenerator, + type RngOptions, + RandError, + fillRandomBytes, + secureRng, +} from "@blockchaincommons/rand"; +import { requireBytes, requireLength, requireOptions } from "./error.js"; const ED25519_PUBLIC_KEY_SIZE = 32; const ED25519_PRIVATE_KEY_SIZE = 32; @@ -21,25 +27,34 @@ export interface Ed25519 { readonly PUBLIC_KEY_SIZE: 32; /** Signature length in bytes. */ readonly SIGNATURE_SIZE: 64; - /** 32 random bytes from `options.rng` (default secure). */ + /** + * 32 random bytes from `options.rng` (default secure). The reference's + * `ed25519_new_private_key_using` draws through `rand_core`'s `fill_bytes`, + * so the generator's `fillBytesPacked` is used when it has one (the packed + * stream of `SeededRng`), else `fillBytes` through rand's `fillRandomBytes`. + * A generator's own error propagates unwrapped; a `fillBytesPacked` that is + * present but not a function is rand's `RandError` `InvalidGenerator`. + * @throws {CryptoError} `InvalidParameter` unless `options` is an object or absent. + */ generatePrivateKey(options?: RngOptions): Uint8Array; /** * The public key of the 32-byte seed `privateKey` (RFC 8032). - * @throws {CryptoError} `InvalidSize` on a wrong-length key. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key. */ publicKey(privateKey: Uint8Array): Uint8Array; /** * Deterministic RFC 8032 signature (64 bytes). - * @throws {CryptoError} `InvalidSize` on a wrong-length key. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key. */ sign(privateKey: Uint8Array, message: Uint8Array): Uint8Array; /** * `(publicKey, signature, message)`, the same order as `ecdsa.verify` and `schnorr.verify`. * - * Uses the reference's uncofactored verification equation. Only canonical point - * encodings are accepted, and a small-order public key or `R` never - * verifies. `false` on any invalid or malformed input of the right length. - * @throws {CryptoError} `InvalidSize` on a wrong-length key or signature. + * Uses the reference's uncofactored verification equation (`verify_strict`). + * Only canonical point encodings are accepted, and a small-order public + * key or `R` never verifies. `false` on any invalid or malformed input of + * the right length, including a key the reference cannot decode. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature. */ verify(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array): boolean; } @@ -51,14 +66,25 @@ export const ed25519: Ed25519 = { SIGNATURE_SIZE: 64, generatePrivateKey(options) { + requireOptions("Ed25519 options", options, true); // The reference's `ed25519_new_private_key_using` reaches the generator // through rand_core generics (`SigningKey::generate` → `fill_bytes`): the // packed stream where a generator distinguishes one, else the same bytes // as `randomBytes`. const rng: RandomNumberGenerator = options?.rng ?? secureRng(); const key = new Uint8Array(ED25519_PRIVATE_KEY_SIZE); - if (rng.fillBytesPacked !== undefined) rng.fillBytesPacked(key); - else rng.fillBytes(key); + // Read once. rand never calls this optional member, so its contract is + // checked here, with rand's own error, as rand checks the members it calls. + // The single read is deliberate; the call below rebinds `this` to `rng`. + // eslint-disable-next-line @typescript-eslint/unbound-method + const packed: unknown = rng.fillBytesPacked; + if (packed === undefined) { + fillRandomBytes(key, { rng }); + } else if (typeof packed === "function") { + (packed as (dest: Uint8Array) => void).call(rng, key); + } else { + throw RandError.invalidGenerator("fillBytesPacked", packed); + } return key; }, @@ -69,15 +95,21 @@ export const ed25519: Ed25519 = { sign(privateKey, message) { requireLength("Ed25519 private key", privateKey, ED25519_PRIVATE_KEY_SIZE); + requireBytes("Ed25519 message", message); return noble.sign(message, privateKey); }, verify(publicKey, signature, message) { requireLength("Ed25519 public key", publicKey, ED25519_PUBLIC_KEY_SIZE); requireLength("Ed25519 signature", signature, ED25519_SIGNATURE_SIZE); + requireBytes("Ed25519 message", message); try { // Canonical decoding (zip215 = false) of the key and of R, then the - // small-order checks `verify_strict` makes, then the equation. + // small-order checks `verify_strict` makes, then the equation. The + // reference's decoder reduces a non-canonical y instead; the outcome is + // the same `false`, because an undecodable key is `false` on both sides + // and a decodable non-canonical key is small-order (rejected below and + // by `verify_strict`) or a point whose discrete logarithm nobody knows. const a = noble.Point.fromBytes(publicKey, false); const r = noble.Point.fromBytes(signature.subarray(0, 32), false); if (a.isSmallOrder() || r.isSmallOrder()) return false; diff --git a/src/error.ts b/src/error.ts index e260e96..723825d 100644 --- a/src/error.ts +++ b/src/error.ts @@ -3,10 +3,15 @@ * * @module error */ +import { isBytes } from "@noble/hashes/utils.js"; /** Machine-readable discriminant for a {@link CryptoError}. */ export type CryptoErrorCode = - "InvalidSize" | "InvalidData" | "InvalidParameter" | "AuthenticationFailed"; + | "InvalidSize" + | "InvalidData" + | "InvalidParameter" + | "NonContributoryKey" + | "AuthenticationFailed"; /** * The structured payload of a {@link CryptoError}, discriminated by `code`: @@ -26,13 +31,27 @@ export type CryptoErrorDetails = | { /** A key, point or signature of the right length that is not valid. */ readonly code: "InvalidData"; - /** The argument, e.g. `"X25519 public key"`. */ + /** The argument, e.g. `"ECDSA compressed public key"`. */ readonly what: string; } | { - /** A KDF or counter argument outside its domain. */ + /** + * An argument outside its domain: a number that is not an integer of + * the Rust width, an options object that is not an object, a boolean + * option that is not a boolean, or a byte argument that is not a + * `Uint8Array`. Also a KDF parameter set the backend rejects. + */ readonly code: "InvalidParameter"; - /** The argument, e.g. `"scrypt logN"`. */ + /** The argument, e.g. `"scrypt logN"` or `"ECDSA message"`. */ + readonly what: string; + } + | { + /** + * `x25519.sharedKey` was given a low-order peer key, so the shared + * secret would be all zero (the reference's `Error::NonContributoryKey`). + */ + readonly code: "NonContributoryKey"; + /** The argument: `"X25519 public key"`. */ readonly what: string; } | { @@ -43,12 +62,17 @@ export type CryptoErrorDetails = /** * Thrown for wrong-length keys, nonces, signatures and public keys * (`InvalidSize`), a key, point or signature of the right length that is not - * valid (`InvalidData`), a KDF or counter argument outside its domain - * (`InvalidParameter`), and AEAD tag mismatch (`AuthenticationFailed`). + * valid (`InvalidData`), an argument outside its domain, including a value + * of the wrong type (`InvalidParameter`), a low-order X25519 peer key + * (`NonContributoryKey`), and AEAD tag mismatch (`AuthenticationFailed`). * - * Every failure raised by this package is a `CryptoError`; when a backend - * error is what was caught, it is the `cause`. Instances come from the static - * factories only. + * Every failure of an argument or of a primitive is a `CryptoError`; when a + * backend error is what was caught, it is the `cause`. Two things propagate + * unwrapped, because they are not this package's: a generator's own error + * (`RandError` from `@blockchaincommons/rand`, including `InvalidGenerator` + * for a generator that lacks a method the draw calls), and an allocation + * failure outside the KDFs (`RangeError` from the engine). Instances come + * from the static factories only. * * @example * ```ts @@ -100,11 +124,24 @@ export class CryptoError extends Error { return new CryptoError(message, { code: "InvalidData", what }, cause); } - /** `what` (a KDF or counter argument) is outside its domain. */ + /** `what` (a number, an options object or a byte argument) is outside its domain. */ static invalidParameter(what: string, message: string, cause?: unknown): CryptoError { return new CryptoError(message, { code: "InvalidParameter", what }, cause); } + /** + * The X25519 peer key is a low-order point, so the shared secret would be + * all zero. The message is the reference's `Error::NonContributoryKey` + * Display text. + */ + static nonContributoryKey(cause?: unknown): CryptoError { + return new CryptoError( + "X25519 peer key produces an all-zero shared secret", + { code: "NonContributoryKey", what: "X25519 public key" }, + cause, + ); + } + /** AEAD authentication failed (wrong key, nonce, aad, or tampered data). */ static authenticationFailed(cause?: unknown): CryptoError { // The reference's `Error::Aead` displays as "AEAD error". @@ -112,7 +149,70 @@ export class CryptoError extends Error { } } -/** @internal Length precondition shared by the key and signature functions. */ -export function requireLength(what: string, bytes: Uint8Array, expected: number): void { +/** @internal A short description of a rejected value for `got …` clauses. */ +export function describeValue(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return `Array(${value.length})`; + if (typeof value !== "object") return typeof value; + const proto = Object.getPrototypeOf(value) as { constructor?: { name?: unknown } } | null; + const name = proto?.constructor?.name; + return typeof name === "string" && name !== "" ? name : "object"; +} + +/** + * @internal `value` must be a `Uint8Array` (from any realm; a `Buffer` is + * one). Every byte argument is checked this way before any other precondition, + * so a string, array or `ArrayBuffer` is `InvalidParameter` naming `what`. + */ +export function requireBytes(what: string, value: unknown): asserts value is Uint8Array { + if (!isBytes(value)) { + throw CryptoError.invalidParameter( + what, + `${what} must be a Uint8Array, got ${describeValue(value)}`, + ); + } +} + +/** + * @internal An options argument must be an object. An optional one may be + * `undefined`; a required one may not. + */ +export function requireOptions( + what: string, + value: unknown, + optional: boolean, +): asserts value is object | undefined { + if (value === undefined && optional) return; + if (typeof value !== "object" || value === null) { + throw CryptoError.invalidParameter( + what, + `${what} must be an object, got ${describeValue(value)}`, + ); + } +} + +/** @internal A boolean option: `undefined` (absent) or a boolean. Returns it. */ +export function expectBool(what: string, value: unknown): boolean | undefined { + if (value === undefined) return undefined; + if (typeof value !== "boolean") { + throw CryptoError.invalidParameter( + what, + `${what} must be a boolean, got ${describeValue(value)}`, + ); + } + return value; +} + +/** + * @internal Length precondition shared by the key and signature functions. + * The type check comes first, so a non-`Uint8Array` is `InvalidParameter` + * and a wrong length is `InvalidSize`. + */ +export function requireLength( + what: string, + bytes: unknown, + expected: number, +): asserts bytes is Uint8Array { + requireBytes(what, bytes); if (bytes.length !== expected) throw CryptoError.invalidSize(what, expected, bytes.length); } diff --git a/src/hash.ts b/src/hash.ts index 9f267a9..26cf6be 100644 --- a/src/hash.ts +++ b/src/hash.ts @@ -7,6 +7,7 @@ import { sha256 as nobleSha256, sha512 as nobleSha512 } from "@noble/hashes/sha2 import { hmac } from "@noble/hashes/hmac.js"; import { pbkdf2 } from "@noble/hashes/pbkdf2.js"; import { hkdf } from "@noble/hashes/hkdf.js"; +import { expectBool, requireBytes, requireOptions } from "./error.js"; import { backendRejected, expectInt, guard, U32_MAX } from "./domain.js"; /** Bytes in a CRC-32 checksum. */ @@ -24,8 +25,12 @@ for (let i = 0; i < 256; i++) { CRC32_TABLE[i] = crc >>> 0; } -/** CRC-32 (IEEE 802.3 / ISO-HDLC) as an unsigned 32-bit integer. */ +/** + * CRC-32 (IEEE 802.3 / ISO-HDLC) as an unsigned 32-bit integer. + * @throws {CryptoError} `InvalidParameter` unless `data` is a `Uint8Array`. + */ export function crc32(data: Uint8Array): number { + requireBytes("crc32 data", data); let crc = 0xffffffff; // Indexed loop: the iterator protocol over a typed array is 2× slower here. // eslint-disable-next-line @typescript-eslint/prefer-for-of @@ -41,59 +46,106 @@ export interface Crc32Options { readonly littleEndian?: boolean | undefined; } -/** CRC-32 as four bytes, big-endian unless `littleEndian` is set. */ +/** + * CRC-32 as four bytes, big-endian unless `littleEndian` is set. + * @throws {CryptoError} `InvalidParameter` unless `data` is a `Uint8Array`, + * `options` an object (or absent) and `littleEndian` a boolean (or absent). + */ export function crc32Bytes(data: Uint8Array, options?: Crc32Options): Uint8Array { + requireBytes("crc32 data", data); + requireOptions("crc32 options", options, true); + const littleEndian = expectBool("crc32 littleEndian", options?.littleEndian) ?? false; const result = new Uint8Array(4); - new DataView(result.buffer).setUint32(0, crc32(data), options?.littleEndian ?? false); + new DataView(result.buffer).setUint32(0, crc32(data), littleEndian); return result; } -/** SHA-256 of `data` (32 bytes). */ +/** + * SHA-256 of `data` (32 bytes). + * @throws {CryptoError} `InvalidParameter` unless `data` is a `Uint8Array`. + */ export function sha256(data: Uint8Array): Uint8Array { + requireBytes("sha256 data", data); return nobleSha256(data); } -/** `sha256(sha256(data))`, the Bitcoin message hash. */ +/** + * `sha256(sha256(data))`, the Bitcoin message hash. + * @throws {CryptoError} `InvalidParameter` unless `data` is a `Uint8Array`. + */ export function doubleSha256(data: Uint8Array): Uint8Array { - return sha256(sha256(data)); + requireBytes("doubleSha256 data", data); + return nobleSha256(nobleSha256(data)); } -/** SHA-512 of `data` (64 bytes). */ +/** + * SHA-512 of `data` (64 bytes). + * @throws {CryptoError} `InvalidParameter` unless `data` is a `Uint8Array`. + */ export function sha512(data: Uint8Array): Uint8Array { + requireBytes("sha512 data", data); return nobleSha512(data); } -/** HMAC-SHA-256 of `message` under `key` (32 bytes). */ +/** + * HMAC-SHA-256 of `message` under `key` (32 bytes). + * @throws {CryptoError} `InvalidParameter` unless both arguments are `Uint8Array`s. + */ export function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array { + requireBytes("hmacSha256 key", key); + requireBytes("hmacSha256 message", message); return hmac(nobleSha256, key, message); } -/** HMAC-SHA-512 of `message` under `key` (64 bytes). */ +/** + * HMAC-SHA-512 of `message` under `key` (64 bytes). + * @throws {CryptoError} `InvalidParameter` unless both arguments are `Uint8Array`s. + */ export function hmacSha512(key: Uint8Array, message: Uint8Array): Uint8Array { + requireBytes("hmacSha512 key", key); + requireBytes("hmacSha512 message", message); return hmac(nobleSha512, key, message); } /** Options for the PBKDF2 functions. */ export interface Pbkdf2Options { - /** PBKDF2 iteration count; an integer ≥ 1. */ + /** + * PBKDF2 iteration count; an integer in [1, 2^32 − 1]. The reference asserts + * `iterations > 0`, including for empty output, so 0 is `InvalidParameter`. + */ readonly iterations: number; - /** Derived key length in bytes. */ + /** + * Derived key length in bytes; an integer in [0, (2^32 − 1) · hLen] + * (RFC 8018 §5.2, hLen 32 or 64). 0 is an empty key on both sides. An + * allocation the host cannot make surfaces as `InvalidParameter` with the + * engine error as `cause`. + */ readonly dkLen: number; } -const pbkdf2Domain = (options: Pbkdf2Options): { c: number; dkLen: number } => ({ +const pbkdf2Domain = (options: Pbkdf2Options, hLen: number): { c: number; dkLen: number } => ({ + // Checked before the empty-output return: the reference's `assert!(iterations > 0)` + // comes before its allocation, so it fires for empty output too. c: expectInt("pbkdf2 iterations", options.iterations, 1, U32_MAX), // `dkLen: 0` is an empty key on both sides (the reference fills a zero-length Vec). - dkLen: expectInt("pbkdf2 dkLen", options.dkLen, 0, U32_MAX), + dkLen: expectInt("pbkdf2 dkLen", options.dkLen, 0, U32_MAX * hLen), }); -/** @throws {CryptoError} `InvalidParameter` unless `iterations` is an integer ≥ 1 and `dkLen` an integer ≥ 0. */ +/** + * PBKDF2-HMAC-SHA-256. + * @throws {CryptoError} `InvalidParameter` unless `password` and `salt` are + * `Uint8Array`s, `options` is an object, `iterations` an integer in + * [1, 2^32 − 1] and `dkLen` an integer in [0, (2^32 − 1) · 32]. + */ export function pbkdf2Sha256( password: Uint8Array, salt: Uint8Array, options: Pbkdf2Options, ): Uint8Array { - const domain = pbkdf2Domain(options); + requireBytes("pbkdf2 password", password); + requireBytes("pbkdf2 salt", salt); + requireOptions("pbkdf2 options", options, false); + const domain = pbkdf2Domain(options, SHA256_SIZE); if (domain.dkLen === 0) return new Uint8Array(0); return guard( () => pbkdf2(nobleSha256, password, salt, domain), @@ -101,13 +153,21 @@ export function pbkdf2Sha256( ); } -/** @throws {CryptoError} `InvalidParameter` unless `iterations` is an integer ≥ 1 and `dkLen` an integer ≥ 0. */ +/** + * PBKDF2-HMAC-SHA-512. + * @throws {CryptoError} `InvalidParameter` unless `password` and `salt` are + * `Uint8Array`s, `options` is an object, `iterations` an integer in + * [1, 2^32 − 1] and `dkLen` an integer in [0, (2^32 − 1) · 64]. + */ export function pbkdf2Sha512( password: Uint8Array, salt: Uint8Array, options: Pbkdf2Options, ): Uint8Array { - const domain = pbkdf2Domain(options); + requireBytes("pbkdf2 password", password); + requireBytes("pbkdf2 salt", salt); + requireOptions("pbkdf2 options", options, false); + const domain = pbkdf2Domain(options, SHA512_SIZE); if (domain.dkLen === 0) return new Uint8Array(0); return guard( () => pbkdf2(nobleSha512, password, salt, domain), @@ -123,13 +183,17 @@ export interface HkdfOptions { /** * HKDF-SHA-256 with no `info`, `dkLen` output bytes. - * @throws {CryptoError} `InvalidParameter` unless `dkLen` is an integer in [0, 255 · 32]. + * @throws {CryptoError} `InvalidParameter` unless `keyMaterial` and `salt` + * are `Uint8Array`s, `options` is an object and `dkLen` an integer in [0, 255 · 32]. */ export function hkdfSha256( keyMaterial: Uint8Array, salt: Uint8Array, options: HkdfOptions, ): Uint8Array { + requireBytes("hkdf key material", keyMaterial); + requireBytes("hkdf salt", salt); + requireOptions("hkdf options", options, false); const dkLen = expectInt("hkdf dkLen", options.dkLen, 0, 255 * SHA256_SIZE); return guard( () => hkdf(nobleSha256, keyMaterial, salt, undefined, dkLen), @@ -139,13 +203,17 @@ export function hkdfSha256( /** * HKDF-SHA-512 with no `info`, `dkLen` output bytes. - * @throws {CryptoError} `InvalidParameter` unless `dkLen` is an integer in [0, 255 · 64]. + * @throws {CryptoError} `InvalidParameter` unless `keyMaterial` and `salt` + * are `Uint8Array`s, `options` is an object and `dkLen` an integer in [0, 255 · 64]. */ export function hkdfSha512( keyMaterial: Uint8Array, salt: Uint8Array, options: HkdfOptions, ): Uint8Array { + requireBytes("hkdf key material", keyMaterial); + requireBytes("hkdf salt", salt); + requireOptions("hkdf options", options, false); const dkLen = expectInt("hkdf dkLen", options.dkLen, 0, 255 * SHA512_SIZE); return guard( () => hkdf(nobleSha512, keyMaterial, salt, undefined, dkLen), diff --git a/src/index.ts b/src/index.ts index 6daea0d..fe4739c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,8 +9,11 @@ * size constants (`ecdsa.PRIVATE_KEY_SIZE`, `chacha20Poly1305.TAG_SIZE`). * - The stack's two key derivations at the root: {@link deriveSigningPrivateKey} * and {@link deriveAgreementPrivateKey}. - * - Every failure is a {@link CryptoError} with a `code`; a backend's own - * error, when there is one, is its `cause`. + * - Every failure of an argument or of a primitive is a {@link CryptoError} + * with a `code`; a backend's own error, when there is one, is its `cause`. + * Byte arguments must be `Uint8Array`s (checked before anything else). A + * generator's own error (`RandError`), and allocation failure outside the + * KDFs, propagate as thrown. * - Every function that draws randomness takes `{ rng }` ({@link RngOptions} * from `@blockchaincommons/rand`), defaulting to the secure generator. * diff --git a/src/kdf.ts b/src/kdf.ts index fd50d9b..33ba597 100644 --- a/src/kdf.ts +++ b/src/kdf.ts @@ -5,8 +5,15 @@ */ import { scrypt as nobleScrypt } from "@noble/hashes/scrypt.js"; import { argon2id as nobleArgon2id } from "@noble/hashes/argon2.js"; -import { CryptoError } from "./error.js"; +import { CryptoError, requireBytes, requireOptions } from "./error.js"; import { backendRejected, expectInt, expectMinLength, guard, U32_MAX } from "./domain.js"; +import { scryptCore } from "./scrypt-core.js"; + +/** + * A working buffer larger than this goes to the paged core: every engine + * can hold 2^31 bytes in one typed array, JavaScriptCore no more than 2^32. + */ +const SINGLE_BUFFER_LIMIT = 2 ** 31; /** Options for {@link scrypt}. Defaults are the reference parameters. */ export interface ScryptOptions { @@ -16,17 +23,22 @@ export interface ScryptOptions { * with the defaults any length ≥ 1 is accepted (the reference's default path). */ readonly dkLen: number; - /** log₂ of the CPU/memory cost `N`. Default 17 (N = 131072). An integer in [1, 32] and below `16·r`. */ + /** + * log₂ of the CPU/memory cost `N`. Default 17 (N = 131072). An integer in + * [1, 32] and below `16·r`; the reference asserts `log_n > 0`, and its + * panic is `InvalidParameter` here. Values above 32 need at least 3.3 TiB. + */ readonly logN?: number | undefined; /** Block size. Default 8. */ readonly r?: number | undefined; /** Parallelism. Default 1. `r·p` must be below 2^30. */ readonly p?: number | undefined; /** - * The working-memory ceiling in bytes the backend enforces - * (`128·r·(N + p + 1)` bytes are needed). Default a little over 1 GiB - * (`128·8·(2^20 + 2)`); the reference has no configurable ceiling. Raising maxmem does not bypass - * the backend's logN limit or the runtime's allocation limits. + * An optional ceiling in bytes on the working memory, `128·r·(N + p + 1)`. + * By default there is none, as in the reference, which allocates whatever + * the parameters imply; a parameter set above the ceiling is + * `InvalidParameter`. A ceiling does not bypass the `logN` limit or the + * runtime's allocation limits. */ readonly maxmem?: number | undefined; } @@ -35,7 +47,8 @@ export interface ScryptOptions { const SCRYPT_MAX_DKLEN = 0xffffffff * 32; /** - * @throws {CryptoError} `InvalidParameter` when `dkLen` is outside its domain + * @throws {CryptoError} `InvalidParameter` unless `password` and `salt` are + * `Uint8Array`s and `options` an object; when `dkLen` is outside its domain * (see {@link ScryptOptions.dkLen}), `logN` not in [1, 32] or not below `16·r` * (scrypt requires `N < 2^(128·r/8)`), `r` or `p` not ≥ 1, `r·p` not below * 2^30, or the parameters exceed `maxmem`. @@ -45,6 +58,9 @@ export function scrypt( salt: Uint8Array, options: ScryptOptions, ): Uint8Array { + requireBytes("scrypt password", password); + requireBytes("scrypt salt", salt); + requireOptions("scrypt options", options, false); const parameterised = options.logN !== undefined || options.r !== undefined || options.p !== undefined; const dkLen = parameterised @@ -63,19 +79,30 @@ export function scrypt( if (r * p >= 2 ** 30) { throw CryptoError.invalidParameter("scrypt p", `scrypt r·p must be below 2^30, got ${r * p}`); } + // No ceiling unless asked: the reference has none. noble's own default (~1 GiB) + // would reject parameter sets the reference derives with. const maxmem = options.maxmem === undefined - ? undefined + ? Number.MAX_SAFE_INTEGER : expectInt("scrypt maxmem", options.maxmem, 1, Number.MAX_SAFE_INTEGER); + const N = 2 ** logN; + const blockBytes = 128 * r; + if (blockBytes * N > SINGLE_BUFFER_LIMIT || blockBytes * p > SINGLE_BUFFER_LIMIT) { + // Too large for one typed array on JavaScriptCore: the paged core, with + // the same `maxmem` rule and error shape as noble's. + const memUsed = blockBytes * (N + p + 1); + if (memUsed > maxmem) { + throw backendRejected("scrypt parameters")( + new Error(`"maxmem" limit was hit: memUsed(128*r*(N+p+1))=${memUsed}, maxmem=${maxmem}`), + ); + } + return guard( + () => scryptCore(password, salt, { N, r, p, dkLen }), + backendRejected("scrypt parameters"), + ); + } return guard( - () => - nobleScrypt(password, salt, { - N: 2 ** logN, - r, - p, - dkLen, - ...(maxmem === undefined ? {} : { maxmem }), - }), + () => nobleScrypt(password, salt, { N, r, p, dkLen, maxmem }), backendRejected("scrypt parameters"), ); } @@ -95,7 +122,8 @@ const ARGON2ID_P = 1; /** * Argon2id with the reference's fixed parameters (m 19456 KiB, t 2, p 1). - * @throws {CryptoError} `InvalidParameter` when `dkLen` is not an integer ≥ 4 + * @throws {CryptoError} `InvalidParameter` unless `password` and `salt` are + * `Uint8Array`s and `options` an object; when `dkLen` is not an integer ≥ 4 * or `salt` is shorter than 8 bytes. */ export function argon2id( @@ -103,6 +131,9 @@ export function argon2id( salt: Uint8Array, options: Argon2idOptions, ): Uint8Array { + requireBytes("argon2id password", password); + requireBytes("argon2id salt", salt); + requireOptions("argon2id options", options, false); const dkLen = expectInt("argon2id dkLen", options.dkLen, 4, U32_MAX); expectMinLength("argon2id salt", salt, 8); return guard( diff --git a/src/memzero.ts b/src/memzero.ts index f8d5830..e64b482 100644 --- a/src/memzero.ts +++ b/src/memzero.ts @@ -3,6 +3,9 @@ * * @module memzero */ +import { CryptoError, describeValue } from "./error.js"; + +/** The typed arrays {@link memzero} zeroes: every numeric kind, not `BigInt64Array`/`BigUint64Array` or a `DataView`. */ export type NumericTypedArray = | Uint8Array | Uint8ClampedArray @@ -14,12 +17,51 @@ export type NumericTypedArray = | Float32Array | Float64Array; -/** Overwrite every element with zero. */ +// Cross-realm safe: the tag comes from `Symbol.toStringTag`, not from `instanceof`. +const NUMERIC_TYPED_ARRAY_TAGS = new Set([ + "[object Uint8Array]", + "[object Uint8ClampedArray]", + "[object Uint16Array]", + "[object Uint32Array]", + "[object Int8Array]", + "[object Int16Array]", + "[object Int32Array]", + "[object Float32Array]", + "[object Float64Array]", +]); + +function requireNumericTypedArray( + what: string, + value: unknown, +): asserts value is NumericTypedArray { + if (!NUMERIC_TYPED_ARRAY_TAGS.has(Object.prototype.toString.call(value))) { + throw CryptoError.invalidParameter( + what, + `${what} must be a numeric typed array, got ${describeValue(value)}`, + ); + } +} + +/** + * Overwrite every element with zero. + * @throws {CryptoError} `InvalidParameter` unless `data` is a numeric typed array. + */ export function memzero(data: NumericTypedArray): void { + requireNumericTypedArray("memzero data", data); data.fill(0); } -/** {@link memzero} each array. */ +/** + * {@link memzero} each array. + * @throws {CryptoError} `InvalidParameter` unless `arrays` is an array of numeric typed arrays. + */ export function memzeroAll(arrays: readonly NumericTypedArray[]): void { - for (const arr of arrays) memzero(arr); + const list: unknown = arrays; + if (!Array.isArray(list)) { + throw CryptoError.invalidParameter( + "memzeroAll arrays", + `memzeroAll arrays must be an array, got ${describeValue(list)}`, + ); + } + for (const arr of list as readonly unknown[]) memzero(arr as NumericTypedArray); } diff --git a/src/scrypt-core.ts b/src/scrypt-core.ts new file mode 100644 index 0000000..221d21f --- /dev/null +++ b/src/scrypt-core.ts @@ -0,0 +1,248 @@ +/** + * RFC 7914 scrypt over paged working memory. + * + * noble keeps `V` (128·r·N bytes) and `B` (128·r·p bytes) each in one typed + * array, and JavaScriptCore (Bun, Safari) holds at most 2^32 bytes in one, + * so parameter sets the reference derives with (for example logN 22, r 9, + * 4.8 GiB) cannot run there. This core keeps both in pages of whole + * 128·r-byte blocks, each page at most `pageBytes` (2^30 by default), and + * {@link scrypt} dispatches to it only when a working buffer would exceed + * 2^31 bytes; everything smaller goes to noble, which is faster. The bytes + * are identical to noble's and to the reference's (`scrypt` 0.11.0). + * + * @module scrypt-core + * @internal + */ +import { hmac } from "@noble/hashes/hmac.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { rotl, swap32IfBE } from "@noble/hashes/utils.js"; + +/** Parameters for {@link scryptCore}; already validated by the caller. */ +export interface ScryptCoreParams { + /** CPU/memory cost, a power of two in [2, 2^32]. */ + readonly N: number; + /** Block size factor ≥ 1. */ + readonly r: number; + /** Parallelism ≥ 1. */ + readonly p: number; + /** Output length ≥ 1. */ + readonly dkLen: number; + /** Largest page in bytes; a page always holds at least one 128·r-byte block. Tests use tiny pages. */ + readonly pageBytes?: number | undefined; +} + +const DEFAULT_PAGE_BYTES = 2 ** 30; + +/** `units` blocks of `unitWords` 32-bit words, in pages of whole blocks. */ +class PagedBlocks { + readonly pages: Uint32Array[] = []; + readonly unitsPerPage: number; + constructor( + units: number, + readonly unitWords: number, + pageBytes: number, + ) { + this.unitsPerPage = Math.max(1, Math.floor(pageBytes / (unitWords * 4))); + for (let done = 0; done < units; done += this.unitsPerPage) { + const n = Math.min(this.unitsPerPage, units - done); + this.pages.push(new Uint32Array(n * unitWords)); + } + } + /** The page holding block `i` and the block's word offset in it. */ + page(i: number): Uint32Array { + return this.pages[Math.floor(i / this.unitsPerPage)]; + } + offset(i: number): number { + return (i % this.unitsPerPage) * this.unitWords; + } + /** The bytes of every page, in page order. */ + bytes(): Uint8Array[] { + return this.pages.map((page) => new Uint8Array(page.buffer, page.byteOffset, page.byteLength)); + } +} + +/** `out[oi..oi+16] = Salsa20/8(a[ai..ai+16] xor b[bi..bi+16])` (RFC 7914 §3). */ +function salsa20_8Xor( + out: Uint32Array, + oi: number, + a: Uint32Array, + ai: number, + b: Uint32Array, + bi: number, +): void { + const i0 = a[ai] ^ b[bi], + i1 = a[ai + 1] ^ b[bi + 1], + i2 = a[ai + 2] ^ b[bi + 2], + i3 = a[ai + 3] ^ b[bi + 3], + i4 = a[ai + 4] ^ b[bi + 4], + i5 = a[ai + 5] ^ b[bi + 5], + i6 = a[ai + 6] ^ b[bi + 6], + i7 = a[ai + 7] ^ b[bi + 7], + i8 = a[ai + 8] ^ b[bi + 8], + i9 = a[ai + 9] ^ b[bi + 9], + i10 = a[ai + 10] ^ b[bi + 10], + i11 = a[ai + 11] ^ b[bi + 11], + i12 = a[ai + 12] ^ b[bi + 12], + i13 = a[ai + 13] ^ b[bi + 13], + i14 = a[ai + 14] ^ b[bi + 14], + i15 = a[ai + 15] ^ b[bi + 15]; + let x0 = i0, + x1 = i1, + x2 = i2, + x3 = i3, + x4 = i4, + x5 = i5, + x6 = i6, + x7 = i7, + x8 = i8, + x9 = i9, + x10 = i10, + x11 = i11, + x12 = i12, + x13 = i13, + x14 = i14, + x15 = i15; + // Eight rounds: four double-rounds of column then row quarter-rounds. + for (let round = 0; round < 8; round += 2) { + x4 ^= rotl((x0 + x12) | 0, 7); + x8 ^= rotl((x4 + x0) | 0, 9); + x12 ^= rotl((x8 + x4) | 0, 13); + x0 ^= rotl((x12 + x8) | 0, 18); + x9 ^= rotl((x5 + x1) | 0, 7); + x13 ^= rotl((x9 + x5) | 0, 9); + x1 ^= rotl((x13 + x9) | 0, 13); + x5 ^= rotl((x1 + x13) | 0, 18); + x14 ^= rotl((x10 + x6) | 0, 7); + x2 ^= rotl((x14 + x10) | 0, 9); + x6 ^= rotl((x2 + x14) | 0, 13); + x10 ^= rotl((x6 + x2) | 0, 18); + x3 ^= rotl((x15 + x11) | 0, 7); + x7 ^= rotl((x3 + x15) | 0, 9); + x11 ^= rotl((x7 + x3) | 0, 13); + x15 ^= rotl((x11 + x7) | 0, 18); + x1 ^= rotl((x0 + x3) | 0, 7); + x2 ^= rotl((x1 + x0) | 0, 9); + x3 ^= rotl((x2 + x1) | 0, 13); + x0 ^= rotl((x3 + x2) | 0, 18); + x6 ^= rotl((x5 + x4) | 0, 7); + x7 ^= rotl((x6 + x5) | 0, 9); + x4 ^= rotl((x7 + x6) | 0, 13); + x5 ^= rotl((x4 + x7) | 0, 18); + x11 ^= rotl((x10 + x9) | 0, 7); + x8 ^= rotl((x11 + x10) | 0, 9); + x9 ^= rotl((x8 + x11) | 0, 13); + x10 ^= rotl((x9 + x8) | 0, 18); + x12 ^= rotl((x15 + x14) | 0, 7); + x13 ^= rotl((x12 + x15) | 0, 9); + x14 ^= rotl((x13 + x12) | 0, 13); + x15 ^= rotl((x14 + x13) | 0, 18); + } + out[oi] = (i0 + x0) | 0; + out[oi + 1] = (i1 + x1) | 0; + out[oi + 2] = (i2 + x2) | 0; + out[oi + 3] = (i3 + x3) | 0; + out[oi + 4] = (i4 + x4) | 0; + out[oi + 5] = (i5 + x5) | 0; + out[oi + 6] = (i6 + x6) | 0; + out[oi + 7] = (i7 + x7) | 0; + out[oi + 8] = (i8 + x8) | 0; + out[oi + 9] = (i9 + x9) | 0; + out[oi + 10] = (i10 + x10) | 0; + out[oi + 11] = (i11 + x11) | 0; + out[oi + 12] = (i12 + x12) | 0; + out[oi + 13] = (i13 + x13) | 0; + out[oi + 14] = (i14 + x14) | 0; + out[oi + 15] = (i15 + x15) | 0; +} + +/** + * `out[oi..] = BlockMix(input[ii..])` over `2r` 64-byte sub-blocks (RFC 7914 + * §4): `X = B[2r−1]; for i: X = Salsa(X xor B[i]); Y[i] = X`, with the even + * `Y` first and the odd `Y` after them. `out` may not overlap `input`. + */ +function blockMix(input: Uint32Array, ii: number, out: Uint32Array, oi: number, r: number): void { + let even = oi; + let odd = oi + 16 * r; + // The running X starts as the last sub-block; the first odd slot holds it + // until that slot is written (its consumer runs first). + const last = ii + (2 * r - 1) * 16; + for (let t = 0; t < 16; t++) out[odd + t] = input[last + t]; + let prev = odd; + for (let i = 0; i < r; i++) { + salsa20_8Xor(out, even, out, prev, input, ii + 32 * i); // Y[2i] + salsa20_8Xor(out, odd, out, even, input, ii + 32 * i + 16); // Y[2i+1] + prev = odd; + even += 16; + odd += 16; + } +} + +/** One big-endian 32-bit block index, as PBKDF2's `INT(i)`. */ +function int32be(i: number): Uint8Array { + return Uint8Array.from([(i >>> 24) & 0xff, (i >>> 16) & 0xff, (i >>> 8) & 0xff, i & 0xff]); +} + +/** + * The scrypt KDF of RFC 7914 with `V` and `B` in pages. `password` and + * `salt` are used as given; the caller validates the parameters. + */ +export function scryptCore( + password: Uint8Array, + salt: Uint8Array, + { N, r, p, dkLen, pageBytes = DEFAULT_PAGE_BYTES }: ScryptCoreParams, +): Uint8Array { + const unitWords = 32 * r; // 128·r bytes + const B = new PagedBlocks(p, unitWords, pageBytes); + const V = new PagedBlocks(N, unitWords, pageBytes); + + // B = PBKDF2-HMAC-SHA-256(P, S, 1, 128·r·p), block by block into the pages: + // T_i = HMAC(P, S ‖ INT(i)). A page holds whole 128·r-byte blocks, so a + // 32-byte T_i never straddles two pages. + const prf = hmac.create(sha256, password); + let block = 1; + for (const bytes of B.bytes()) { + for (let off = 0; off < bytes.length; off += 32, block++) { + bytes.set(prf.clone().update(salt).update(int32be(block)).digest(), off); + } + } + for (const page of B.pages) swap32IfBE(page); + + const X = new Uint32Array(unitWords); + const T = new Uint32Array(unitWords); + for (let unit = 0; unit < p; unit++) { + const bPage = B.page(unit); + const bOff = B.offset(unit); + // V[0] = B_unit; V[j] = BlockMix(V[j−1]); X = BlockMix(V[N−1]). + V.page(0).set(bPage.subarray(bOff, bOff + unitWords), V.offset(0)); + for (let j = 1; j < N; j++) { + blockMix(V.page(j - 1), V.offset(j - 1), V.page(j), V.offset(j), r); + } + blockMix(V.page(N - 1), V.offset(N - 1), X, 0, r); + // X = BlockMix(X xor V[Integerify(X) mod N]), N times. Integerify reads + // the first word of the last sub-block; N is a power of two ≤ 2^32. + for (let j = 0; j < N; j++) { + const k = (X[unitWords - 16] & (N - 1)) >>> 0; + const kPage = V.page(k); + const kOff = V.offset(k); + for (let t = 0; t < unitWords; t++) T[t] = X[t] ^ kPage[kOff + t]; + blockMix(T, 0, X, 0, r); + } + bPage.set(X, bOff); + } + for (const page of B.pages) swap32IfBE(page); + + // DK = PBKDF2-HMAC-SHA-256(P, B, 1, dkLen): one HMAC state absorbs the + // whole of B once; each output block clones it and appends INT(i). + const base = hmac.create(sha256, password); + for (const bytes of B.bytes()) base.update(bytes); + const out = new Uint8Array(dkLen); + for (let i = 1, off = 0; off < dkLen; i++, off += 32) { + const t = base.clone().update(int32be(i)).digest(); + out.set(t.subarray(0, Math.min(32, dkLen - off)), off); + } + for (const page of V.pages) page.fill(0); + for (const page of B.pages) page.fill(0); + X.fill(0); + T.fill(0); + return out; +} diff --git a/src/stream.ts b/src/stream.ts index 124d301..df23367 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -6,7 +6,7 @@ * @module stream */ import { chacha20 as nobleChacha20 } from "@noble/ciphers/chacha.js"; -import { CryptoError, requireLength } from "./error.js"; +import { CryptoError, requireBytes, requireLength, requireOptions } from "./error.js"; import { backendRejected, expectInt, guard, U32_MAX } from "./domain.js"; /** Options for {@link chacha20}. */ @@ -18,8 +18,10 @@ export interface Chacha20Options { /** * XORs `data` with the ChaCha20 keystream for `key` (32 bytes) and `nonce` * (12 bytes), starting at block `counter`. Applying it twice restores the - * input. - * @throws {CryptoError} `InvalidSize` on a wrong-length key or nonce; + * input. There is no `bc-crypto` function for this; it is provenance-mark's + * `ChaCha20::new` + `apply_keystream` (the `chacha20` crate). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array` argument or + * a non-object `options`; `InvalidSize` on a wrong-length key or nonce; * `InvalidParameter` if `counter` is outside [0, 2^32 - 2] or the data * would use the backend-reserved block counter 2^32 - 1. */ @@ -27,19 +29,22 @@ export function chacha20( key: Uint8Array, nonce: Uint8Array, data: Uint8Array, - { counter = 0 }: Chacha20Options = {}, -): Uint8Array { + options?: Chacha20Options, +): Uint8Array { requireLength("ChaCha20 key", key, 32); requireLength("ChaCha20 nonce", nonce, 12); - expectInt("ChaCha20 counter", counter, 0, U32_MAX - 1); + requireBytes("ChaCha20 data", data); + requireOptions("ChaCha20 options", options, true); + const counter = expectInt("ChaCha20 counter", options?.counter ?? 0, 0, U32_MAX - 1); if (Math.ceil(data.length / 64) > U32_MAX - counter) { throw CryptoError.invalidParameter( "ChaCha20 counter", "ChaCha20 data exceeds the remaining block counter capacity", ); } + // noble writes into a fresh `Uint8Array(data.length)`, an ArrayBuffer-backed view. return guard( - () => nobleChacha20(key, nonce, data, undefined, counter), + () => nobleChacha20(key, nonce, data, undefined, counter) as Uint8Array, backendRejected("ChaCha20 parameters"), ); } diff --git a/src/x25519.ts b/src/x25519.ts index f3afe5d..b2611c4 100644 --- a/src/x25519.ts +++ b/src/x25519.ts @@ -6,7 +6,7 @@ import { x25519 as noble } from "@noble/curves/ed25519.js"; import { type RngOptions, randomBytes, secureRng } from "@blockchaincommons/rand"; import { hkdfSha256 } from "./hash.js"; -import { CryptoError, requireLength } from "./error.js"; +import { CryptoError, requireBytes, requireLength, requireOptions } from "./error.js"; import { guard } from "./domain.js"; const X25519_PRIVATE_KEY_SIZE = 32; @@ -21,16 +21,20 @@ const SIGNING_SALT = textEncoder.encode("signing"); * The stack's signing-key derivation: HKDF-SHA-256(keyMaterial, salt "signing") * → 32 bytes, used for ECDSA, Schnorr and Ed25519 private keys alike * (the reference's `derive_signing_private_key` ≡ `ecdsa_derive_private_key`). + * @throws {CryptoError} `InvalidParameter` unless `keyMaterial` is a `Uint8Array`. */ export function deriveSigningPrivateKey(keyMaterial: Uint8Array): Uint8Array { + requireBytes("signing key material", keyMaterial); return hkdfSha256(keyMaterial, SIGNING_SALT, { dkLen: 32 }); } /** * The stack's agreement-key derivation: HKDF-SHA-256(keyMaterial, salt * "agreement") → 32 bytes, an X25519 private key. + * @throws {CryptoError} `InvalidParameter` unless `keyMaterial` is a `Uint8Array`. */ export function deriveAgreementPrivateKey(keyMaterial: Uint8Array): Uint8Array { + requireBytes("agreement key material", keyMaterial); return hkdfSha256(keyMaterial, AGREEMENT_SALT, { dkLen: X25519_PRIVATE_KEY_SIZE }); } @@ -40,17 +44,26 @@ export interface X25519 { readonly PRIVATE_KEY_SIZE: 32; /** Public key length in bytes. */ readonly PUBLIC_KEY_SIZE: 32; - /** 32 random bytes from `options.rng` (default secure), unvalidated. */ + /** + * 32 random bytes from `options.rng` (default secure), unvalidated; the + * reference's `x25519_new_private_key_using` (`random_data`). A generator's + * own error, including rand's `InvalidGenerator`, propagates unwrapped. + * @throws {CryptoError} `InvalidParameter` unless `options` is an object or absent. + */ generatePrivateKey(options?: RngOptions): Uint8Array; /** * The X25519 public key of `privateKey` (clamped per RFC 7748). - * @throws {CryptoError} `InvalidSize` on a wrong-length key. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key. */ publicKey(privateKey: Uint8Array): Uint8Array; /** - * X25519 Diffie-Hellman, then HKDF-SHA-256 with salt "agreement" → 32 bytes. - * @throws {CryptoError} `InvalidData` when `publicKey` is a low-order point - * (the reference derives a predictable key instead; divergence D2). + * X25519 Diffie-Hellman, then HKDF-SHA-256 with salt "agreement" → 32 bytes + * (the reference's `try_x25519_shared_key`). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; + * `InvalidSize` on a wrong length; `NonContributoryKey` when `publicKey` + * is a low-order point, i.e. the shared secret would be all zero, as the + * reference's `try_x25519_shared_key` returns `Err(Error::NonContributoryKey)` + * (its `x25519_shared_key` wrapper panics on the same input). */ sharedKey(privateKey: Uint8Array, publicKey: Uint8Array): Uint8Array; } @@ -61,6 +74,7 @@ export const x25519: X25519 = { PUBLIC_KEY_SIZE: 32, generatePrivateKey(options) { + requireOptions("X25519 options", options, true); return randomBytes(X25519_PRIVATE_KEY_SIZE, { rng: options?.rng ?? secureRng() }); }, @@ -72,9 +86,12 @@ export const x25519: X25519 = { sharedKey(privateKey, publicKey) { requireLength("X25519 private key", privateKey, X25519_PRIVATE_KEY_SIZE); requireLength("X25519 public key", publicKey, X25519_PUBLIC_KEY_SIZE); + // Both arguments are 32 bytes here and every 32-byte private key is + // clamped and valid, so noble's only failure is its low-order set, which + // equals dalek's `!was_contributory()`: the reference's `NonContributoryKey`. const secret = guard( () => noble.getSharedSecret(privateKey, publicKey), - (cause) => CryptoError.invalidData("X25519 public key", "low-order point", cause), + (cause) => CryptoError.nonContributoryKey(cause), ); return hkdfSha256(secret, AGREEMENT_SALT, { dkLen: 32 }); }, diff --git a/tests/__snapshots__/golden.test.ts.snap b/tests/__snapshots__/golden.test.ts.snap index 653781f..cf53aa1 100644 --- a/tests/__snapshots__/golden.test.ts.snap +++ b/tests/__snapshots__/golden.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`golden: freeze additions (B1–B5) > B1: ed25519.verify on a small-order key / non-canonical encodings (today: true) 1`] = ` +exports[`golden: freeze additions (B1–B5) > B1: ed25519.verify on a small-order key / non-canonical encodings (false, as verify_strict) 1`] = ` [ "false", "false", @@ -8,14 +8,14 @@ exports[`golden: freeze additions (B1–B5) > B1: ed25519.verify on a small-orde ] `; -exports[`golden: freeze additions (B1–B5) > B2: x25519.sharedKey with a low-order public key (today: noble's Error) 1`] = ` +exports[`golden: freeze additions (B1–B5) > B2: x25519.sharedKey with a low-order public key (the reference's NonContributoryKey) 1`] = ` [ - "throw:CryptoError:low-order point", - "throw:CryptoError:low-order point", + "throw:CryptoError:X25519 peer key produces an all-zero shared secret", + "throw:CryptoError:X25519 peer key produces an all-zero shared secret", ] `; -exports[`golden: freeze additions (B1–B5) > B3: verify on malformed keys and signatures of the right length (today: false) 1`] = ` +exports[`golden: freeze additions (B1–B5) > B3: verify on malformed keys and signatures of the right length (false on both sides) 1`] = ` [ "false", "false", diff --git a/tests/corpus/corpus.ts b/tests/corpus/corpus.ts index 15c189a..295c551 100644 --- a/tests/corpus/corpus.ts +++ b/tests/corpus/corpus.ts @@ -245,8 +245,9 @@ const FF64 = "ff".repeat(64); /** * Every encoding of a low-order point (RFC 7748 §6.1, little-endian): 0, 1, * the two order-8 points, p − 1, p, p + 1, and (bit 255 is masked on both - * sides) two of them with the high bit set. The reference derives the same - * zero-secret key for all of them; the port rejects them (D2). + * sides) two of them with the high bit set. The reference's + * `try_x25519_shared_key` returns `Err(NonContributoryKey)` for every one; + * the port throws `CryptoError` `NonContributoryKey` with the same message. */ export const X25519_LOW_ORDER: string[] = [ "0000000000000000000000000000000000000000000000000000000000000000", @@ -259,8 +260,20 @@ export const X25519_LOW_ORDER: string[] = [ "0000000000000000000000000000000000000000000000000000000000000080", "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", ]; +/** The other five high-bit encodings of the same seven low-order u values. */ +export const X25519_LOW_ORDER_HIGH_BIT: string[] = [ + "0100000000000000000000000000000000000000000000000000000000000080", + "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880", + "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7", + "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", +]; +/** u = p + 2, a non-canonical encoding of u = 2: a valid point (a control). */ +const X25519_P_PLUS_2 = "efffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"; /** Ed25519's group order L, for a non-canonical `s` (`s + L`). */ const ED_L = (1n << 252n) + 27742317777372353535851937790883648493n; +/** Ed25519's field prime p = 2^255 − 19; y = p + k (k < 19) is a non-canonical encoding of y = k. */ +const ED_P = (1n << 255n) - 19n; const leHexToBigInt = (h: string): bigint => BigInt("0x" + (h.match(/../g) ?? []).reverse().join("")); const bigIntToLeHex = (v: bigint, bytes: number): string => { @@ -269,6 +282,24 @@ const bigIntToLeHex = (v: bigint, bytes: number): string => { out += ((v >> BigInt(8 * i)) & 0xffn).toString(16).padStart(2, "0"); return out; }; +/** The 32-byte Ed25519 encoding of `y` (< 2^255) with the sign bit set or clear. */ +const edEncoding = (y: bigint, sign: boolean): string => + bigIntToLeHex(sign ? y | (1n << 255n) : y, 32); +/** + * Non-canonical `y = p + k` encodings that dalek's `CompressedEdwardsY::decompress` + * still decodes (it reduces y first; the reduced point has a square root), and the + * ones it rejects. Executed against noble's `Point.fromBytes(enc, true)`, which + * applies the same rule; CRYPTO-04's boundary test pins both lists. + */ +export const ED_NONCANONICAL_DECODABLE_K: number[] = [0, 1, 3, 4, 5, 6, 9, 10, 14, 15, 16, 18]; +export const ED_NONCANONICAL_UNDECODABLE_K: number[] = [2, 7, 8, 11, 12, 13, 17]; +/** secp256k1's p + 1, an x coordinate outside the field on both sides. */ +const SECP_P_PLUS_1 = "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc30"; +const SECP_N_HEX = SECP_N.toString(16).padStart(64, "0"); +/** The secp256k1 generator G (y even) and p − Gy (odd). */ +const SECP_GX = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const SECP_GY = "483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8"; +const SECP_NEG_GY = "b7c52588d95c3b9aa25b0403f1eef75702e84bb7597aabe663b82f6f04ef2777"; const firstSigned = (scheme: "ecdsa" | "schnorr" | "ed25519") => { const t = SIGNED.find((x) => x.scheme === scheme); @@ -312,14 +343,92 @@ function* verify(): Generator { yield verifyOf("schnorr", sc.pub, FF64, sc.msg); yield verifyOf("ed25519", FF32, ed.sig, ed.msg); yield verifyOf("ed25519", ed.pub, FF64, ed.msg); + // Signatures with r or s in {0, n} and a key whose x exceeds the field: `false` on both + // sides (the reference's `let Ok(..) = … else { return false; }`; libsecp256k1 parses + // r = 0 and s = 0 and rejects r, s >= n; the verification then fails). + const ONE32 = "00".repeat(31) + "01"; + const ZERO32 = "00".repeat(32); + yield verifyOf("ecdsa", ec.pub, ZERO32 + ONE32, ec.msg); + yield verifyOf("ecdsa", ec.pub, SECP_N_HEX + ONE32, ec.msg); + yield verifyOf("ecdsa", ec.pub, ONE32 + SECP_N_HEX, ec.msg); + yield verifyOf("ecdsa", ec.pub, ONE32 + ZERO32, ec.msg); + yield verifyOf("ecdsa", "02" + SECP_P_PLUS_1, ec.sig, ec.msg); + // Ed25519 public keys dalek cannot decode (y = p + k for these k, both sign bits), with a + // signature that verifies under the fixture's real key: `false` on both sides. + for (const k of ED_NONCANONICAL_UNDECODABLE_K) + for (const sign of [false, true]) + yield verifyOf("ed25519", edEncoding(ED_P + BigInt(k), sign), ed.sig, ed.msg); + // Non-canonical A encodings dalek decodes (to a point other than the signer's) and the + // two x = 0 sign-bit encodings; TypeScript decodes canonically. Both sides: `false`. + for (const k of ED_NONCANONICAL_DECODABLE_K) + for (const sign of [false, true]) + yield verifyOf("ed25519", edEncoding(ED_P + BigInt(k), sign), ed.sig, ed.msg); + yield verifyOf("ed25519", edEncoding(1n, true), ed.sig, ed.msg); + yield verifyOf("ed25519", edEncoding(ED_P - 1n, true), ed.sig, ed.msg); + // The same encodings as R (with the fixture's real s): dalek compares the canonical + // re-encoding of [s]B − [k]A with the bytes of R, so a non-canonical R never verifies. + for (let k = 0; k <= 18; k++) + for (const sign of [false, true]) + yield verifyOf( + "ed25519", + ed.pub, + edEncoding(ED_P + BigInt(k), sign) + ed.sig.slice(64), + ed.msg, + ); + yield verifyOf("ed25519", ed.pub, edEncoding(1n, true) + ed.sig.slice(64), ed.msg); + yield verifyOf("ed25519", ed.pub, edEncoding(ED_P - 1n, true) + ed.sig.slice(64), ed.msg); +} +/** + * Success paths the corpus never pinned against the reference: point decompression and + * compression with values, and AEAD decryption of literal sealed buffers (key 00…1f, + * nonce 00…0b). Every input is a literal, so a regression on either side is a MISMATCH. + */ +function* roundTrips(): Generator { + const GX = SECP_GX; + const GY = SECP_GY; + const X1 = "00".repeat(31) + "01"; + const Y_OF_X1 = "4218f20ae6c646b363db68605822fb14264ca8d2587fdd6fbc750d587e76a7ee"; + yield { k: "ecdsaDecompress", pub: hx("02" + GX) }; + yield { k: "ecdsaDecompress", pub: hx("03" + GX) }; + yield { k: "ecdsaDecompress", pub: hx("02" + X1) }; + yield { k: "ecdsaDecompress", pub: hx("02" + SECP_P_PLUS_1) }; + yield { k: "ecdsaCompress", pub: hx("04" + GX + GY) }; + yield { k: "ecdsaCompress", pub: hx("04" + X1 + Y_OF_X1) }; + yield { k: "ecdsaCompress", pub: hx("04" + SECP_P_PLUS_1 + Y_OF_X1) }; + const key = cyc(32); + const nonce = cyc(12); + const sealedWithAad = hx("e19e646c4637d22fc5ef5b23ea7a2c99eb2a042ad2377566f86b65"); + const sealed = hx("e19e646c4637d22fc5ef5b18f74a8dd13d3bbce0be3ebb508fb959"); + const sealedEmpty = hx("295a498b8841a1c5f55d4d606f731159"); + yield { k: "aeadDecrypt", ct: sealedWithAad, key, nonce, aad: txt("ad") }; + yield { k: "aeadDecrypt", ct: sealedWithAad, key, nonce }; + yield { k: "aeadDecrypt", ct: sealed, key, nonce }; + yield { k: "aeadDecrypt", ct: sealedEmpty, key, nonce }; + yield { k: "aeadDecrypt", ct: sealedEmpty, key, nonce, aad: cyc(0) }; } function* faults(): Generator { const zero = hx("00".repeat(32)); const n = hx("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); const pw = txt("pw"); const salt = cyc(16, 0x50); - // Low-order X25519 peers. + // Low-order X25519 peers: every encoding for two private keys, and the p + 2 control + // (a valid point) for each, so the rejected set is pinned exactly on both sides. for (const pub of X25519_LOW_ORDER) yield { k: "x25519Shared", priv: cyc(32, 1), pub: hx(pub) }; + for (const pub of X25519_LOW_ORDER_HIGH_BIT) + yield { k: "x25519Shared", priv: cyc(32, 1), pub: hx(pub) }; + for (const pub of [...X25519_LOW_ORDER, ...X25519_LOW_ORDER_HIGH_BIT]) + yield { k: "x25519Shared", priv: cyc(32, 0x99), pub: hx(pub) }; + yield { k: "x25519Shared", priv: cyc(32, 1), pub: hx(X25519_P_PLUS_2) }; + yield { k: "x25519Shared", priv: cyc(32, 0x99), pub: hx(X25519_P_PLUS_2) }; + // libsecp256k1's hybrid uncompressed encodings: 06 states an even y, 07 an odd y. + yield { k: "ecdsaCompress", pub: hx("06" + SECP_GX + SECP_GY) }; + yield { k: "ecdsaCompress", pub: hx("07" + SECP_GX + SECP_GY) }; + yield { k: "ecdsaCompress", pub: hx("07" + SECP_GX + SECP_NEG_GY) }; + yield { k: "ecdsaCompress", pub: hx("06" + SECP_GX + SECP_NEG_GY) }; + yield { k: "ecdsaCompress", pub: hx("05" + SECP_GX + SECP_GY) }; + yield { k: "ecdsaCompress", pub: hx("06" + SECP_P_PLUS_1 + SECP_GY) }; + // Above noble's former default memory ceiling (~1 GiB): the reference has none. + yield { k: "scrypt", pw, salt, len: 32, n: 17, r: 64, p: 1 }; // Scalar, point, and KDF domain checks. yield { k: "ecdsaPub", priv: zero }; yield { k: "schnorrPub", priv: zero }; @@ -356,12 +465,31 @@ function* faults(): Generator { // The parameterised path rejects len 8 (10..=64); the default path accepts it — on both sides. yield { k: "scrypt", pw, salt, len: 8, n: 4, r: 8, p: 1 }; yield { k: "scrypt", pw, salt, len: 8 }; - // A6: the JS-only keystream on an honest input (js-only in the reference harness) + // The JS-only keystream on an honest input (js-only J1 in the reference harness). yield { k: "chacha20", key: cyc(32, 0x10), nonce: cyc(12, 0xa0), d: cyc(64) }; yield { k: "chacha20", key: cyc(32, 0x10), nonce: cyc(12, 0xa0), d: cyc(64), counter: 1 }; + // Width probes: numbers the reference's `u8`/`u32` parameters cannot receive. + // `InvalidParameter` here; js-only (J4) in the harness, never a truncated value. + yield { k: "scrypt", pw, salt, len: 32, n: 256, r: 8, p: 1 }; + yield { k: "pbkdf2Sha256", pw, salt, iter: 4294967296, len: 32 }; + yield { k: "scrypt", pw, salt, len: 32, n: 4, r: 4294967297, p: 1 }; +} +/** A recipe whose number exceeds the reference's width (the harness's J4 class). */ +const isWidthProbe = (r: Recipe): boolean => + (r.k === "scrypt" && ((r.n ?? 0) > 0xff || (r.r ?? 0) > 0xffffffff || (r.p ?? 0) > 0xffffffff)) || + ((r.k === "pbkdf2Sha256" || r.k === "pbkdf2Sha512") && r.iter > 0xffffffff); +/** Recipes the frozen baseline bundle cannot run (no `chacha20`) or is not asked to (width probes). */ +export const noBaseline = (r: Recipe): boolean => r.k === "chacha20" || isWidthProbe(r); +/** + * Vectors too heavy for the golden file (`tests/vectors/heavy.json`): replayed + * in CI by the Rust harness, by Bun (`scripts/check-heavy-vectors.ts`) and by + * Node (`CRYPTO_HEAVY=1`, `tests/heavy-vectors.test.ts`). + */ +export function* heavyRecipes(): Generator { + // 128·9·2^22 bytes of V (4.8 GiB): more than one typed array on JavaScriptCore, + // so the paged scrypt core; the reference derives it in about 6 s. + yield { k: "scrypt", pw: txt("pw"), salt: cyc(16, 0x50), len: 32, n: 22, r: 9, p: 1 }; } -/** Recipes the frozen baseline bundle cannot run (no `chacha20`). */ -export const noBaseline = (r: Recipe): boolean => r.k === "chacha20"; export const categories: Record Generator> = { hashes, @@ -370,6 +498,7 @@ export const categories: Record Generator> = { keys, verify, faults, + roundTrips, }; export function* allRecipes(): Generator { for (const g of Object.values(categories)) yield* g(); @@ -384,4 +513,5 @@ export function* goldenRecipes(): Generator { yield* keys(); yield* verify(); yield* faults(); + yield* roundTrips(); } diff --git a/tests/crypto.property.test.ts b/tests/crypto.property.test.ts index 0497dbe..63971fb 100644 --- a/tests/crypto.property.test.ts +++ b/tests/crypto.property.test.ts @@ -2,7 +2,11 @@ * Property tests: round-trips and tamper detection over generated inputs. */ import fc from "fast-check"; +import { runInNewContext } from "node:vm"; +import { isBytes } from "@noble/hashes/utils.js"; +import { RandError } from "@blockchaincommons/rand"; import * as c from "../src"; +import { ED_NONCANONICAL_DECODABLE_K } from "./corpus/corpus"; const u8 = (max = 256) => fc.uint8Array({ maxLength: max }); const key = fc.uint8Array({ minLength: 32, maxLength: 32 }); @@ -198,20 +202,334 @@ describe("properties for B1, B2, B4", () => { }), { numRuns: 100 }, ); + // A non-canonical A the reference's decoder accepts (y = p + k reduced to + // y = k) is a point other than the signer's, so no honest signature + // verifies under it, on either side. + const P = (1n << 255n) - 19n; + const nonCanonicalA = ED_NONCANONICAL_DECODABLE_K.flatMap((k) => + [false, true].map((sign) => { + const y = P + BigInt(k); + const b = Uint8Array.from({ length: 32 }, (_, i) => Number((y >> BigInt(8 * i)) & 0xffn)); + if (sign) b[31] |= 0x80; + return b; + }), + ); + fc.assert( + fc.property(key, u8(), (priv, msg) => { + const sig = c.ed25519.sign(priv, msg); + return nonCanonicalA.every((a) => !c.ed25519.verify(a, sig, msg)); + }), + { numRuns: 50 }, + ); + }); + it("x25519.sharedKey rejects every low-order encoding with NonContributoryKey, the reference's message (B2)", () => { + // The nine RFC 7748 §6.1 encodings and the other five high-bit variants of the same + // seven u values; anything else agrees with noble's ladder. + const lowOrder = [ + "0000000000000000000000000000000000000000000000000000000000000000", + "0100000000000000000000000000000000000000000000000000000000000000", + "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800", + "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157", + "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "0000000000000000000000000000000000000000000000000000000000000080", + "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "0100000000000000000000000000000000000000000000000000000000000080", + "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880", + "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7", + "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + ].map((h) => Uint8Array.from(Buffer.from(h, "hex"))); + fc.assert( + fc.property(key, (priv) => + lowOrder.every((pub) => { + try { + c.x25519.sharedKey(priv, pub); + return false; + } catch (e) { + return ( + c.CryptoError.isCryptoError(e) && + e.code === "NonContributoryKey" && + e.details.code === "NonContributoryKey" && + e.details.what === "X25519 public key" && + e.message === "X25519 peer key produces an all-zero shared secret" && + e.cause instanceof Error + ); + } + }), + ), + { numRuns: 50 }, + ); + // A non-canonical encoding of a valid point (u = p + 2) is a value, as in the reference. + const pPlus2 = Uint8Array.from( + Buffer.from("efffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", "hex"), + ); + expect(c.x25519.sharedKey(new Uint8Array(32).fill(1), pPlus2)).toHaveLength(32); + }); +}); + +describe("argument types: every byte, options and boolean argument is checked first", () => { + // Rust's types make a non-byte argument impossible; here it is `InvalidParameter` + // naming the argument, before lengths, domains, predicates and backends see it. + const K32 = new Uint8Array(32).fill(7); + const N12 = new Uint8Array(12); + const M = new Uint8Array([1, 2, 3]); + const SALT16 = new Uint8Array(16).fill(0x50); + const P33 = c.ecdsa.publicKey(K32); + const SIG64 = c.ecdsa.sign(K32, M); + const EDPUB = c.ed25519.publicKey(K32); + const EDSIG = c.ed25519.sign(K32, M); + const SEALED = c.chacha20Poly1305.encrypt(K32, N12, M); + const pb = { iterations: 1, dkLen: 32 }; + const B = (x: unknown): Uint8Array => x as Uint8Array; + const O = (x: unknown): T => x as T; + /** + * `[what, call with the bad value in that position, optional?]` for every byte + * position; an optional one (`aad`, `auxRand`) treats `undefined` as absent. + */ + const bytePositions: [string, (bad: unknown) => unknown, boolean?][] = [ + ["crc32 data", (b) => c.crc32(B(b))], + ["crc32 data", (b) => c.crc32Bytes(B(b))], + ["sha256 data", (b) => c.sha256(B(b))], + ["doubleSha256 data", (b) => c.doubleSha256(B(b))], + ["sha512 data", (b) => c.sha512(B(b))], + ["hmacSha256 key", (b) => c.hmacSha256(B(b), M)], + ["hmacSha256 message", (b) => c.hmacSha256(K32, B(b))], + ["hmacSha512 key", (b) => c.hmacSha512(B(b), M)], + ["hmacSha512 message", (b) => c.hmacSha512(K32, B(b))], + ["pbkdf2 password", (b) => c.pbkdf2Sha256(B(b), SALT16, pb)], + ["pbkdf2 salt", (b) => c.pbkdf2Sha256(M, B(b), pb)], + ["pbkdf2 password", (b) => c.pbkdf2Sha512(B(b), SALT16, pb)], + ["pbkdf2 salt", (b) => c.pbkdf2Sha512(M, B(b), pb)], + ["hkdf key material", (b) => c.hkdfSha256(B(b), SALT16, { dkLen: 32 })], + ["hkdf salt", (b) => c.hkdfSha256(K32, B(b), { dkLen: 32 })], + ["hkdf key material", (b) => c.hkdfSha512(B(b), SALT16, { dkLen: 32 })], + ["hkdf salt", (b) => c.hkdfSha512(K32, B(b), { dkLen: 32 })], + ["scrypt password", (b) => c.scrypt(B(b), SALT16, { dkLen: 32, logN: 4 })], + ["scrypt salt", (b) => c.scrypt(M, B(b), { dkLen: 32, logN: 4 })], + ["argon2id password", (b) => c.argon2id(B(b), SALT16, { dkLen: 32 })], + ["argon2id salt", (b) => c.argon2id(M, B(b), { dkLen: 32 })], + ["ChaCha20-Poly1305 key", (b) => c.chacha20Poly1305.encrypt(B(b), N12, M)], + ["ChaCha20-Poly1305 nonce", (b) => c.chacha20Poly1305.encrypt(K32, B(b), M)], + ["ChaCha20-Poly1305 plaintext", (b) => c.chacha20Poly1305.encrypt(K32, N12, B(b))], + ["ChaCha20-Poly1305 aad", (b) => c.chacha20Poly1305.encrypt(K32, N12, M, { aad: B(b) }), true], + ["ChaCha20-Poly1305 key", (b) => c.chacha20Poly1305.decrypt(B(b), N12, SEALED)], + ["ChaCha20-Poly1305 nonce", (b) => c.chacha20Poly1305.decrypt(K32, B(b), SEALED)], + ["ChaCha20-Poly1305 sealed data", (b) => c.chacha20Poly1305.decrypt(K32, N12, B(b))], + [ + "ChaCha20-Poly1305 aad", + (b) => c.chacha20Poly1305.decrypt(K32, N12, SEALED, { aad: B(b) }), + true, + ], + ["ChaCha20 key", (b) => c.chacha20(B(b), N12, M)], + ["ChaCha20 nonce", (b) => c.chacha20(K32, B(b), M)], + ["ChaCha20 data", (b) => c.chacha20(K32, N12, B(b))], + ["X25519 private key", (b) => c.x25519.publicKey(B(b))], + ["X25519 private key", (b) => c.x25519.sharedKey(B(b), K32)], + ["X25519 public key", (b) => c.x25519.sharedKey(K32, B(b))], + ["signing key material", (b) => c.deriveSigningPrivateKey(B(b))], + ["agreement key material", (b) => c.deriveAgreementPrivateKey(B(b))], + ["ECDSA private key", (b) => c.ecdsa.publicKey(B(b))], + ["ECDSA compressed public key", (b) => c.ecdsa.decompressPublicKey(B(b))], + ["ECDSA uncompressed public key", (b) => c.ecdsa.compressPublicKey(B(b))], + ["ECDSA private key", (b) => c.ecdsa.sign(B(b), M)], + ["ECDSA message", (b) => c.ecdsa.sign(K32, B(b))], + ["ECDSA public key", (b) => c.ecdsa.verify(B(b), SIG64, M)], + ["ECDSA signature", (b) => c.ecdsa.verify(P33, B(b), M)], + ["ECDSA message", (b) => c.ecdsa.verify(P33, SIG64, B(b))], + ["Schnorr private key", (b) => c.schnorr.publicKey(B(b))], + ["Schnorr private key", (b) => c.schnorr.sign(B(b), M, { auxRand: K32 })], + ["Schnorr message", (b) => c.schnorr.sign(K32, B(b), { auxRand: K32 })], + ["Schnorr auxiliary randomness", (b) => c.schnorr.sign(K32, M, { auxRand: B(b) }), true], + ["Schnorr public key", (b) => c.schnorr.verify(B(b), SIG64, M)], + ["Schnorr signature", (b) => c.schnorr.verify(K32, B(b), M)], + ["Schnorr message", (b) => c.schnorr.verify(K32, SIG64, B(b))], + ["Ed25519 private key", (b) => c.ed25519.publicKey(B(b))], + ["Ed25519 private key", (b) => c.ed25519.sign(B(b), M)], + ["Ed25519 message", (b) => c.ed25519.sign(K32, B(b))], + ["Ed25519 public key", (b) => c.ed25519.verify(B(b), EDSIG, M)], + ["Ed25519 signature", (b) => c.ed25519.verify(EDPUB, B(b), M)], + ["Ed25519 message", (b) => c.ed25519.verify(EDPUB, EDSIG, B(b))], + ]; + /** Options positions: a required options object may not be missing; an optional one may. */ + const optionsPositions: [string, (bad: unknown) => unknown][] = [ + ["crc32 options", (o) => c.crc32Bytes(M, O(o))], + ["pbkdf2 options", (o) => c.pbkdf2Sha256(M, SALT16, O(o))], + ["pbkdf2 options", (o) => c.pbkdf2Sha512(M, SALT16, O(o))], + ["hkdf options", (o) => c.hkdfSha256(K32, SALT16, O(o))], + ["hkdf options", (o) => c.hkdfSha512(K32, SALT16, O(o))], + ["scrypt options", (o) => c.scrypt(M, SALT16, O(o))], + ["argon2id options", (o) => c.argon2id(M, SALT16, O(o))], + ["ChaCha20-Poly1305 options", (o) => c.chacha20Poly1305.encrypt(K32, N12, M, O(o))], + ["ChaCha20-Poly1305 options", (o) => c.chacha20Poly1305.decrypt(K32, N12, SEALED, O(o))], + ["ChaCha20 options", (o) => c.chacha20(K32, N12, M, O(o))], + ["X25519 options", (o) => c.x25519.generatePrivateKey(O(o))], + ["ECDSA options", (o) => c.ecdsa.generatePrivateKey(O(o))], + ["Schnorr options", (o) => c.schnorr.sign(K32, M, O(o))], + ["Ed25519 options", (o) => c.ed25519.generatePrivateKey(O(o))], + ]; + const rejects = (what: string, f: () => unknown): boolean => { + try { + f(); + return false; + } catch (e) { + return ( + c.CryptoError.isCryptoError(e) && + e.code === "InvalidParameter" && + e.details.code === "InvalidParameter" && + e.details.what === what + ); + } + }; + const notBytes = fc.anything().filter((v) => !isBytes(v)); + const notObject = fc + .anything() + .filter((v) => v !== undefined && (typeof v !== "object" || v === null)); + + it("a byte position given anything but a Uint8Array is InvalidParameter naming it", () => { + for (const [what, call, optional] of bytePositions) { + const bad = optional === true ? notBytes.filter((v) => v !== undefined) : notBytes; + fc.assert( + fc.property(bad, (v) => rejects(what, () => call(v))), + { numRuns: 100 }, + ); + } + }); + it("an options position given a non-object is InvalidParameter naming it", () => { + for (const [what, call] of optionsPositions) { + fc.assert( + fc.property(notObject, (bad) => rejects(what, () => call(bad))), + { numRuns: 100 }, + ); + } + }); + it("memzero and memzeroAll require numeric typed arrays", () => { + const notTyped = fc.anything().filter((v) => !ArrayBuffer.isView(v)); + fc.assert( + fc.property(notTyped, (bad) => rejects("memzero data", () => c.memzero(O(bad)))), + { numRuns: 100 }, + ); + fc.assert( + fc.property( + notTyped.filter((v) => !Array.isArray(v)), + (bad) => rejects("memzeroAll arrays", () => c.memzeroAll(O(bad))), + ), + { numRuns: 100 }, + ); + expect(rejects("memzero data", () => c.memzero(O(new DataView(new ArrayBuffer(4)))))).toBe( + true, + ); + expect(rejects("memzero data", () => c.memzero(O(new BigUint64Array(1))))).toBe(true); + expect(rejects("memzero data", () => c.memzeroAll([O("x")]))).toBe(true); + }); + it("littleEndian must be a boolean", () => { + expect(rejects("crc32 littleEndian", () => c.crc32Bytes(M, { littleEndian: O(1) }))).toBe(true); + expect(c.crc32Bytes(M, { littleEndian: undefined })).toEqual(c.crc32Bytes(M)); }); - it("x25519.sharedKey rejects a low-order public key with CryptoError InvalidData (B2)", () => { - const one = new Uint8Array(32); - one[0] = 1; - for (const pub of [new Uint8Array(32), one]) { - let err: unknown; + it("the executed regressions: engine TypeErrors, silent acceptance, misattribution", () => { + const what = (f: () => unknown): string => { try { - c.x25519.sharedKey(new Uint8Array(32).fill(7), pub); + f(); + return "value"; } catch (e) { - err = e; + return c.CryptoError.isCryptoError(e) + ? `${e.code}:${e.details.code === "AuthenticationFailed" ? "" : e.details.what}` + : `${(e as Error).name}`; } - expect(c.CryptoError.isCryptoError(err) && err.code === "InvalidData").toBe(true); - expect((err as Error).cause).toBeInstanceOf(Error); + }; + expect(what(() => c.sha256(O("abc")))).toBe("InvalidParameter:sha256 data"); + expect(what(() => c.chacha20Poly1305.encrypt(O(Array(32)), N12, M))).toBe( + "InvalidParameter:ChaCha20-Poly1305 key", + ); + expect(what(() => c.crc32(O(undefined)))).toBe("InvalidParameter:crc32 data"); + expect(what(() => c.scrypt(M, SALT16, O(undefined)))).toBe("InvalidParameter:scrypt options"); + expect(what(() => c.pbkdf2Sha256(M, SALT16, O(undefined)))).toBe( + "InvalidParameter:pbkdf2 options", + ); + expect(what(() => c.crc32(O([1, 2, 3])))).toBe("InvalidParameter:crc32 data"); + expect(what(() => c.crc32Bytes(O("abc")))).toBe("InvalidParameter:crc32 data"); + expect(what(() => c.ed25519.verify(EDPUB, EDSIG, O("msg")))).toBe( + "InvalidParameter:Ed25519 message", + ); + expect(what(() => c.ed25519.verify(O(Array(32)), EDSIG, M))).toBe( + "InvalidParameter:Ed25519 public key", + ); + expect(what(() => c.ecdsa.verify(O(Array(33)), SIG64, M))).toBe( + "InvalidParameter:ECDSA public key", + ); + expect(what(() => c.ecdsa.sign(K32, O("msg")))).toBe("InvalidParameter:ECDSA message"); + expect(what(() => c.schnorr.sign(K32, O("msg"), { auxRand: K32 }))).toBe( + "InvalidParameter:Schnorr message", + ); + expect(what(() => c.x25519.sharedKey(O(Array(32)), O(Array(32))))).toBe( + "InvalidParameter:X25519 private key", + ); + expect(what(() => c.ecdsa.publicKey(O("x".repeat(32))))).toBe( + "InvalidParameter:ECDSA private key", + ); + expect(what(() => c.deriveAgreementPrivateKey(O("x")))).toBe( + "InvalidParameter:agreement key material", + ); + // The message names the argument and the received type. + expect(() => c.ecdsa.sign(K32, O("msg"))).toThrow( + "ECDSA message must be a Uint8Array, got string", + ); + expect(() => c.crc32(O([1, 2, 3]))).toThrow("crc32 data must be a Uint8Array, got Array(3)"); + expect(() => c.crc32(O(new ArrayBuffer(3)))).toThrow( + "crc32 data must be a Uint8Array, got ArrayBuffer", + ); + expect(() => c.scrypt(M, SALT16, O(null))).toThrow( + "scrypt options must be an object, got null", + ); + }); + it("a Buffer and a Uint8Array from another realm are accepted", () => { + const buffer = Buffer.from("abc"); + const foreign = runInNewContext("new Uint8Array([97, 98, 99])") as Uint8Array; + expect(c.sha256(buffer)).toEqual(c.sha256(new TextEncoder().encode("abc"))); + expect(c.sha256(foreign)).toEqual(c.sha256(buffer)); + expect(c.crc32(foreign)).toBe(c.crc32(buffer)); + expect(c.chacha20Poly1305.encrypt(K32, N12, foreign)).toEqual( + c.chacha20Poly1305.encrypt(K32, N12, buffer), + ); + }); + it("a fillBytesPacked that is present but not a function is rand's InvalidGenerator", () => { + let err: unknown; + try { + c.ed25519.generatePrivateKey({ + rng: O({ fillBytesPacked: 1, fillBytes() {} }), + }); + } catch (e) { + err = e; } + expect(RandError.isRandError(err)).toBe(true); + expect(RandError.isRandError(err) && err.code).toBe("InvalidGenerator"); + expect( + RandError.isRandError(err) && err.details.code === "InvalidGenerator" && err.details.method, + ).toBe("fillBytesPacked"); + expect(err).not.toBeInstanceOf(TypeError); + expect(c.CryptoError.isCryptoError(err)).toBe(false); + }); +}); + +describe("verify never throws for inputs of the right length", () => { + // The reference returns `false` for an unparseable key or signature + // (`let Ok(..) = … else { return false; }`), so does the port. + const isBool = (f: () => unknown): boolean => typeof f() === "boolean"; + it("ecdsa, schnorr and ed25519 return a boolean for random keys and signatures", () => { + const k33 = fc.uint8Array({ minLength: 33, maxLength: 33 }); + const k32 = fc.uint8Array({ minLength: 32, maxLength: 32 }); + const sig = fc.uint8Array({ minLength: 64, maxLength: 64 }); + fc.assert( + fc.property(k33, k32, sig, u8(16), (ecPub, pub32, s, m) => + [ + () => c.ecdsa.verify(ecPub, s, m), + () => c.schnorr.verify(pub32, s, m), + () => c.ed25519.verify(pub32, s, m), + ].every(isBool), + ), + { numRuns: 200 }, + ); }); }); diff --git a/tests/crypto.test.ts b/tests/crypto.test.ts index 4106083..47bce42 100644 --- a/tests/crypto.test.ts +++ b/tests/crypto.test.ts @@ -22,7 +22,14 @@ import { memzero, CryptoError, } from "../src/index.js"; -import { SecureRng, SeededRng, randomBytes, testRandomBytes } from "@blockchaincommons/rand"; +import { + type RandomNumberGenerator, + RandError, + SecureRng, + SeededRng, + randomBytes, + testRandomBytes, +} from "@blockchaincommons/rand"; // Helper to convert hex string to Uint8Array function hexToBytes(hex: string): Uint8Array { @@ -955,12 +962,143 @@ describe("CryptoError", () => { expect((e as CryptoError).code).toBe("AuthenticationFailed"); } }); - test("verify never throws on malformed signatures of the right length", () => { + test("verify returns false and never throws for malformed keys and signatures of the right length", () => { + // The reference's `let Ok(..) = … else { return false; }` on every parse failure. const priv = new Uint8Array(32).fill(7); + const msg = new Uint8Array(0); const bad = new Uint8Array(64).fill(0xff); - expect(ecdsa.verify(ecdsa.publicKey(priv), bad, new Uint8Array(0))).toBe(false); - expect(schnorr.verify(schnorr.publicKey(priv), bad, new Uint8Array(0))).toBe(false); - expect(ed25519.verify(ed25519.publicKey(priv), bad, new Uint8Array(0))).toBe(false); + const ff32 = new Uint8Array(32).fill(0xff); + const hex = (h: string): Uint8Array => Uint8Array.from(Buffer.from(h, "hex")); + // signatures + expect(ecdsa.verify(ecdsa.publicKey(priv), bad, msg)).toBe(false); + expect(schnorr.verify(schnorr.publicKey(priv), bad, msg)).toBe(false); + expect(ed25519.verify(ed25519.publicKey(priv), bad, msg)).toBe(false); + // keys the parsers reject: x ≥ p (ECDSA, Schnorr) and an undecodable Ed25519 y + const ecSig = ecdsa.sign(priv, msg); + expect(ecdsa.verify(Uint8Array.from([2, ...ff32]), ecSig, msg)).toBe(false); + expect( + ecdsa.verify( + hex("02fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc30"), + ecSig, + msg, + ), + ).toBe(false); + expect( + schnorr.verify(ff32, schnorr.sign(priv, msg, { auxRand: new Uint8Array(32) }), msg), + ).toBe(false); + // y = p + 2 with the sign bit clear: no square root, so dalek and noble both fail to decode. + expect( + ed25519.verify( + hex("efffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"), + ed25519.sign(priv, msg), + msg, + ), + ).toBe(false); + // r or s in {0, n} + const n = "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"; + const one = "00".repeat(31) + "01"; + const zero = "00".repeat(32); + for (const sig of [zero + one, n + one, one + n, one + zero]) { + expect(ecdsa.verify(ecdsa.publicKey(priv), hex(sig), msg)).toBe(false); + } + }); +}); + +describe("generators (the reference's rand_core paths)", () => { + const hex = (b: Uint8Array): string => Buffer.from(b).toString("hex"); + test("ed25519 draws the packed stream when the generator has one, else fillBytes", () => { + // `ed25519_new_private_key_using` reaches the generator through + // `RngCore::fill_bytes`: on the seeded generator that is the packed + // stream (`fillBytesPacked`); a generator without one gives the same + // bytes as `randomBytes` (one 64-bit step per byte). + const packed = ed25519.generatePrivateKey({ rng: SeededRng.forTesting() }); + const seeded = SeededRng.forTesting(); + const viaPacked = new Uint8Array(32); + seeded.fillBytesPacked(viaPacked); + expect(hex(packed)).toBe(hex(viaPacked)); + expect(hex(packed).startsWith("7e061813")).toBe(true); + + const inner = SeededRng.forTesting(); + const fallbackOnly: RandomNumberGenerator = { + nextU32: () => inner.nextU32(), + nextU64: () => inner.nextU64(), + fillBytes: (dest) => inner.fillBytes(dest), + }; + const fallback = ed25519.generatePrivateKey({ rng: fallbackOnly }); + expect(hex(fallback)).toBe(hex(randomBytes(32, { rng: SeededRng.forTesting() }))); + expect(hex(fallback).startsWith("7eb559bb")).toBe(true); + expect(hex(fallback)).not.toBe(hex(packed)); + }); + test("a generator that lacks a method throws rand's InvalidGenerator, unwrapped", () => { + const empty = { rng: {} as RandomNumberGenerator }; + const cases: [string, () => unknown][] = [ + ["x25519.generatePrivateKey", () => x25519.generatePrivateKey(empty)], + ["ecdsa.generatePrivateKey", () => ecdsa.generatePrivateKey(empty)], + [ + "schnorr.sign without auxRand", + () => schnorr.sign(new Uint8Array(32).fill(7), new Uint8Array(1), empty), + ], + ["ed25519.generatePrivateKey", () => ed25519.generatePrivateKey(empty)], + ]; + for (const [name, f] of cases) { + let err: unknown; + try { + f(); + } catch (e) { + err = e; + } + expect(RandError.isRandError(err), name).toBe(true); + expect(RandError.isRandError(err) && err.code, name).toBe("InvalidGenerator"); + expect( + RandError.isRandError(err) && err.details.code === "InvalidGenerator" && err.details.method, + name, + ).toBe("fillBytes"); + expect(CryptoError.isCryptoError(err), name).toBe(false); + expect(err instanceof TypeError, name).toBe(false); + } + // A `fillBytesPacked` that is present but not a function (rand never calls it). + let err: unknown; + try { + ed25519.generatePrivateKey({ + rng: { fillBytesPacked: 1, fillBytes() {} } as unknown as RandomNumberGenerator, + }); + } catch (e) { + err = e; + } + expect( + RandError.isRandError(err) && err.details.code === "InvalidGenerator" && err.details.method, + ).toBe("fillBytesPacked"); + }); +}); + +describe("ECDSA hybrid uncompressed keys (libsecp256k1's 06/07 prefixes)", () => { + const hex = (h: string): Uint8Array => Uint8Array.from(Buffer.from(h, "hex")); + const GX = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const GY = "483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8"; + const NEG_GY = "b7c52588d95c3b9aa25b0403f1eef75702e84bb7597aabe663b82f6f04ef2777"; + test("a prefix that states y's parity compresses like 04", () => { + expect(Buffer.from(ecdsa.compressPublicKey(hex("06" + GX + GY))).toString("hex")).toBe( + "02" + GX, + ); + expect(Buffer.from(ecdsa.compressPublicKey(hex("07" + GX + NEG_GY))).toString("hex")).toBe( + "03" + GX, + ); + }); + test("a prefix that contradicts y's parity, an unknown prefix or x >= p is InvalidData", () => { + for (const bad of [ + "07" + GX + GY, + "06" + GX + NEG_GY, + "05" + GX + GY, + "06" + "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc30" + GY, + ]) { + let err: unknown; + try { + ecdsa.compressPublicKey(hex(bad)); + } catch (e) { + err = e; + } + expect(CryptoError.isCryptoError(err) && err.code).toBe("InvalidData"); + } }); }); diff --git a/tests/differential.test.ts b/tests/differential.test.ts index 477401c..a8896fe 100644 --- a/tests/differential.test.ts +++ b/tests/differential.test.ts @@ -24,7 +24,10 @@ import { categories, noBaseline } from "./corpus/corpus"; const here = dirname(fileURLToPath(import.meta.url)); const BASELINE_SHA256 = "d3a5a82546fd0424232ba32ea1c1bd485e08f35f3f241edc90c8476fb1559655"; -/** The only allowed differences. Error classes changed from AeadError/Error to CryptoError. */ +/** + * Allowed differences between the pre-redesign baseline and the tree; error + * class names are not compared (only whether a recipe throws or has a value). + */ const TOMBSTONES: { id: string; landed: boolean; matches: (r: Recipe) => boolean }[] = [ { id: "T5-uncofactored-ed25519", @@ -35,8 +38,12 @@ const TOMBSTONES: { id: string; landed: boolean; matches: (r: Recipe) => boolean ED25519_STRICT_FIXTURES.some((f) => !f.valid && "hex" in r.sig && f.signature === r.sig.hex), }, { - // Strict Ed25519 verification (`verify_strict`): the baseline accepts - // non-canonical/small-order encodings, the working tree rejects them. + // Strict Ed25519 verification (`verify_strict`): the baseline accepted + // small-order and non-canonical encodings, the tree rejects them. The + // reference's decoder reduces a non-canonical y where the tree decodes + // canonically; the outcome is the same `false`, because an undecodable + // key is `false` on both sides and a decodable non-canonical key is + // small-order or would need a discrete logarithm to verify. id: "T1", landed: true, matches: (r) => @@ -73,6 +80,27 @@ const TOMBSTONES: { id: string; landed: boolean; matches: (r: Recipe) => boolean matches: (r) => (r.k === "pbkdf2Sha256" || r.k === "pbkdf2Sha512") && r.len === 0 && r.iter >= 1, }, + { + // Hybrid `06`/`07` uncompressed keys compress on the tree, as libsecp256k1 + // parses them for the reference; the baseline (noble) rejected every + // prefix but `04`. + id: "T9-hybrid-uncompressed", + landed: true, + matches: (r) => + r.k === "ecdsaCompress" && + "hex" in r.pub && + (r.pub.hex.startsWith("06") || r.pub.hex.startsWith("07")), + }, + { + // scrypt has no default memory ceiling on the tree (the reference has + // none); the baseline kept noble's default of 128·8·(2^20 + 2) bytes. + id: "T10-scrypt-maxmem", + landed: true, + matches: (r) => + r.k === "scrypt" && + r.n !== undefined && + 128 * (r.r ?? 8) * (2 ** r.n + (r.p ?? 1) + 1) > 128 * 8 * (2 ** 20 + 2), + }, ]; const baseline = baselineAdapterFor(baselineMod, randBaseline); diff --git a/tests/golden.test.ts b/tests/golden.test.ts index 6587a73..85ee50b 100644 --- a/tests/golden.test.ts +++ b/tests/golden.test.ts @@ -194,20 +194,20 @@ describe("golden: freeze additions (B1–B5)", () => { const nonCanonicalRSignature = new Uint8Array(64); nonCanonicalRSignature.set(nonCanonicalIdentity, 0); - it("B1: ed25519.verify on a small-order key / non-canonical encodings (today: true)", () => { + it("B1: ed25519.verify on a small-order key / non-canonical encodings (false, as verify_strict)", () => { expect([ outcome(() => c.ed25519.verify(identity, identitySignature, msg)), outcome(() => c.ed25519.verify(nonCanonicalIdentity, identitySignature, msg)), outcome(() => c.ed25519.verify(identity, nonCanonicalRSignature, msg)), ]).toMatchSnapshot(); }); - it("B2: x25519.sharedKey with a low-order public key (today: noble's Error)", () => { + it("B2: x25519.sharedKey with a low-order public key (the reference's NonContributoryKey)", () => { expect([ outcome(() => c.x25519.sharedKey(PRIV, new Uint8Array(32))), outcome(() => c.x25519.sharedKey(PRIV, one(32))), ]).toMatchSnapshot(); }); - it("B3: verify on malformed keys and signatures of the right length (today: false)", () => { + it("B3: verify on malformed keys and signatures of the right length (false on both sides)", () => { const ecdsaPub = c.ecdsa.publicKey(PRIV); expect([ outcome(() => c.ecdsa.verify(ecdsaPub, fill(64, 0xff), msg)), diff --git a/tests/heavy-vectors.test.ts b/tests/heavy-vectors.test.ts new file mode 100644 index 0000000..3e30fa0 --- /dev/null +++ b/tests/heavy-vectors.test.ts @@ -0,0 +1,32 @@ +/** + * The heavy vectors (tests/vectors/heavy.json) on Node. Skipped unless + * `CRYPTO_HEAVY=1`: the logN 22, r 9 scrypt vector needs about 5 GiB and + * several seconds. CI runs it; Bun runs the same file through + * `scripts/check-heavy-vectors.ts`. + */ +import { readFileSync } from "node:fs"; +import process from "node:process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as src from "../src"; +import * as rand from "@blockchaincommons/rand"; +import { materialize, redesignedAdapterFor, type Recipe } from "./vectors/recipes"; + +const here = dirname(fileURLToPath(import.meta.url)); +const { count, vectors } = JSON.parse(readFileSync(join(here, "vectors/heavy.json"), "utf8")) as { + count: number; + vectors: { recipe: Recipe; expect: string }[]; +}; +const api = redesignedAdapterFor(src, rand); + +describe.skipIf(process.env["CRYPTO_HEAVY"] !== "1")("heavy vectors (CRYPTO_HEAVY=1)", () => { + it("fixture is self-consistent", () => { + expect(vectors.length).toBe(count); + expect(vectors.length).toBeGreaterThan(0); + }); + vectors.forEach((v, i) => { + it(`#${i} ${v.recipe.k}`, { timeout: 600_000 }, () => { + expect(materialize(api, v.recipe)).toBe(v.expect); + }); + }); +}); diff --git a/tests/kdf-backend.test.ts b/tests/kdf-backend.test.ts new file mode 100644 index 0000000..f484697 --- /dev/null +++ b/tests/kdf-backend.test.ts @@ -0,0 +1,96 @@ +/** + * What reaches the KDF backends: PBKDF2's `dkLen` domain is RFC 8018's + * (2^32 − 1)·hLen, and scrypt passes no memory ceiling unless asked. Spies + * on the noble entry points show the forwarded options without deriving. + */ +import { vi } from "vitest"; +import * as c from "../src"; +import { pbkdf2 } from "@noble/hashes/pbkdf2.js"; +import { scrypt as nobleScrypt } from "@noble/hashes/scrypt.js"; +import type * as Pbkdf2Module from "@noble/hashes/pbkdf2.js"; +import type * as ScryptModule from "@noble/hashes/scrypt.js"; + +vi.mock("@noble/hashes/pbkdf2.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, pbkdf2: vi.fn(actual.pbkdf2) }; +}); +vi.mock("@noble/hashes/scrypt.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, scrypt: vi.fn(actual.scrypt) }; +}); + +const pw = new TextEncoder().encode("pw"); +const salt = new Uint8Array(16).fill(0x50); +const U32_MAX = 2 ** 32 - 1; + +describe("PBKDF2 dkLen up to (2^32 − 1)·hLen; iterations 0 stays InvalidParameter", () => { + afterEach(() => { + vi.mocked(pbkdf2).mockReset(); + }); + it("a dkLen above the old u32 cap reaches noble (2^32, no derivation)", () => { + vi.mocked(pbkdf2).mockImplementationOnce(() => new Uint8Array(0)); + c.pbkdf2Sha256(pw, salt, { iterations: 1, dkLen: 2 ** 32 }); + expect(vi.mocked(pbkdf2)).toHaveBeenCalledTimes(1); + expect(vi.mocked(pbkdf2).mock.calls[0]?.[3]).toEqual({ c: 1, dkLen: 2 ** 32 }); + }); + it("the bound is the RFC 8018 one for each hash", () => { + vi.mocked(pbkdf2).mockImplementation(() => new Uint8Array(0)); + expect(() => c.pbkdf2Sha256(pw, salt, { iterations: 1, dkLen: U32_MAX * 32 })).not.toThrow(); + expect(() => c.pbkdf2Sha512(pw, salt, { iterations: 1, dkLen: U32_MAX * 64 })).not.toThrow(); + expect(() => c.pbkdf2Sha256(pw, salt, { iterations: 1, dkLen: U32_MAX * 32 + 1 })).toThrow( + `pbkdf2 dkLen must be an integer in [0, ${U32_MAX * 32}], got ${U32_MAX * 32 + 1}`, + ); + expect(() => c.pbkdf2Sha512(pw, salt, { iterations: 1, dkLen: U32_MAX * 64 + 1 })).toThrow( + `pbkdf2 dkLen must be an integer in [0, ${U32_MAX * 64}], got ${U32_MAX * 64 + 1}`, + ); + }); + it("iterations 0 is InvalidParameter at every length, including empty output (the reference asserts)", () => { + for (const dkLen of [0, 32, 65]) { + for (const f of [c.pbkdf2Sha256, c.pbkdf2Sha512]) { + let err: unknown; + try { + f(pw, salt, { iterations: 0, dkLen }); + } catch (e) { + err = e; + } + expect(c.CryptoError.isCryptoError(err) && err.code).toBe("InvalidParameter"); + expect((err as Error).message).toBe( + `pbkdf2 iterations must be an integer in [1, ${U32_MAX}], got 0`, + ); + } + } + expect(vi.mocked(pbkdf2)).not.toHaveBeenCalled(); + }); +}); + +describe("scrypt has no default memory ceiling", () => { + afterEach(() => { + vi.mocked(nobleScrypt).mockReset(); + }); + it("forwards MAX_SAFE_INTEGER unless maxmem is given", () => { + vi.mocked(nobleScrypt).mockImplementation(() => new Uint8Array(32)); + c.scrypt(pw, salt, { dkLen: 32, logN: 4 }); + c.scrypt(pw, salt, { dkLen: 32, logN: 4, maxmem: 1 << 30 }); + const calls = vi.mocked(nobleScrypt).mock.calls; + expect(calls).toHaveLength(2); + expect(calls[0]?.[2]).toEqual({ + N: 16, + r: 8, + p: 1, + dkLen: 32, + maxmem: Number.MAX_SAFE_INTEGER, + }); + expect(calls[1]?.[2]).toEqual({ N: 16, r: 8, p: 1, dkLen: 32, maxmem: 1 << 30 }); + }); + it("an explicit ceiling is still enforced by the backend, with the cause attached", () => { + let err: unknown; + try { + c.scrypt(pw, salt, { dkLen: 32, logN: 4, maxmem: 1 }); + } catch (e) { + err = e; + } + expect(c.CryptoError.isCryptoError(err) && err.is("InvalidParameter")).toBe(true); + expect((err as Error).cause).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/maxmem/); + }); +}); diff --git a/tests/rust-validation/Cargo.lock b/tests/rust-validation/Cargo.lock index 75425d3..464f690 100644 --- a/tests/rust-validation/Cargo.lock +++ b/tests/rust-validation/Cargo.lock @@ -45,8 +45,6 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bc-crypto" version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e74e8d1d886ad04ed17badc8856d7303168cabe9201bba100a621171aa41bd7" dependencies = [ "argon2", "bc-rand", diff --git a/tests/rust-validation/Cargo.toml b/tests/rust-validation/Cargo.toml index 8701ea7..45f956a 100644 --- a/tests/rust-validation/Cargo.toml +++ b/tests/rust-validation/Cargo.toml @@ -12,3 +12,11 @@ serde_json = "1" hex = "0.4" rand_core = "0.6" rand = "0.9" + +# The reference is the bc-rust/bc-crypto-rust working tree (commit 4f2b791, tag +# 0.14.0, plus its input-validation edits; CI reproduces it from +# reference/bc-crypto-rust-4f2b791-edits.patch). Its Cargo.toml still says +# 0.14.0, so this patch replaces the registry crate of the same version. Drop +# it, and re-pin above, when the release that contains the edits ships. +[patch.crates-io] +bc-crypto = { path = "../../../../../bc-rust/bc-crypto-rust" } diff --git a/tests/rust-validation/README.md b/tests/rust-validation/README.md index 5b8e0f4..5fc9a4c 100644 --- a/tests/rust-validation/README.md +++ b/tests/rust-validation/README.md @@ -1,19 +1,72 @@ # Rust reference cross-validation -Replays `tests/vectors/vectors.json` against `bc-crypto = 0.14.0`. +Replays vector files against the `bc-crypto` reference. The reference is the +`bc-rust/bc-crypto-rust` working tree: commit `4f2b791` (tag 0.14.0) plus its +input-validation edits, which `Cargo.toml` patches over the registry crate of +the same version with `[patch.crates-io]`. The released `bc-crypto` 0.14.0 +differs from it on four points (`ecdsa_verify`, `schnorr_verify` and +`ed25519_verify` return `false` for an unparseable input; `try_x25519_shared_key` +returns `Err(NonContributoryKey)`; `scrypt_opt` asserts `log_n > 0`; the PBKDF2 +functions assert `iterations > 0`) and is not the reference. The edits are kept +byte for byte in `reference/bc-crypto-rust-4f2b791-edits.patch`, which CI applies +to a fresh checkout. When the release that contains them ships, the pin moves to +it, the patch and the CI step go, and no result line changes. ```sh cd tests/rust-validation -cargo run --release -- ../vectors/vectors.json +cargo run --release --offline -- ../vectors/vectors.json # the golden file +bun ../../scripts/generate-vectors.ts --full /tmp/crypto-full.json +cargo run --release --offline -- /tmp/crypto-full.json # the whole corpus +cargo run --release --offline -- ../vectors/heavy.json # about 5 GiB, seconds ``` -Exit 0 iff every vector matches the Rust reference or is an allowlisted -expected divergence. The allowlist (`expected_divergence()`) is the -machine-readable twin of `RUST_DIVERGENCES.md`; keep them in sync. -Not wired into CI (needs a Rust toolchain); run it manually when updating vectors or the reference version. +Result lines on 2026-09-14 (stderr names the resolved reference and the js-only classes): -The reviewed exceptions are exact recipes in `expected-divergences.json`. -Pass `--strict` after the vectors path to reject every behavioral divergence -while still classifying the raw ChaCha20 extension as JS-only. Use this mode -when checking an upstream candidate that fixes D2–D5. Normal validation remains -pinned to the published 0.14.0 crate. +``` +807 vectors - 793 match, 14 js-only, 0 MISMATCH +1195 vectors - 1180 match, 15 js-only, 0 MISMATCH +1 vectors - 1 match, 0 js-only, 0 MISMATCH +``` + +## What is compared + +- Every recipe is parsed into the reference's argument types first, outside + `catch_unwind`. An input no Rust signature can receive is **js-only**, counted + apart from matches and mismatches: J1, no reference function (raw ChaCha20); + J2, a fixed-size argument of the wrong length; J3, sealed data under 16 bytes; + J4, a number that is not an integer of the Rust width (`u8` logN, `u32` + iterations, r and p, `usize` lengths). Nothing is truncated or cast. +- The reference call alone runs under `catch_unwind`: a panic is a throw. Every + failure on both sides normalises to `throw`, so error codes and messages are + not compared here; the golden and property suites pin the TypeScript codes. +- `x25519Shared` is the one kind compared verbatim: the reference's + `try_x25519_shared_key` returns `Err(NonContributoryKey)`, rendered + `throw:NonContributoryKey|`, and the TypeScript adapter renders its + `CryptoError` as `throw:|`. +- There is no exception list. Any other difference is a MISMATCH and the exit + code is 1. + +## The heavy vectors + +`tests/vectors/heavy.json` holds scrypt with logN 22, r 9 (4.8 GiB of `V`), +which exercises the paged scrypt core. Rust replays it as above; +`bun scripts/check-heavy-vectors.ts` replays it on JavaScriptCore, where one +typed array cannot hold it, and `CRYPTO_HEAVY=1 bunx vitest run tests/heavy-vectors.test.ts` +on Node. Regenerate it with `bun scripts/generate-vectors.ts --heavy`. + +## CI + +The `rust-validation` job in `.github/workflows/ci.yml` clones bc-crypto-rust +at `4f2b791` into the path the patch names, applies +`reference/bc-crypto-rust-4f2b791-edits.patch`, then runs the golden file, the +generated full corpus and the heavy file, followed by the Bun and Node heavy +checks. A MISMATCH anywhere fails the job. + +## Maintenance + +When the reference moves: update the pin in `Cargo.toml` (and drop the patch +once the release contains the edits), run `cargo update -p bc-crypto`, update +`.github/versions.yml`, regenerate the vectors, run the three replays and copy +the result lines into `RUST_DIVERGENCES.md`. A new difference is a bug on one +side: fix it, or record it in `RUST_DIVERGENCES.md` with an input, both +outcomes, the reason no TypeScript design can match, and a vector. diff --git a/tests/rust-validation/expected-divergences.json b/tests/rust-validation/expected-divergences.json deleted file mode 100644 index f04ee59..0000000 --- a/tests/rust-validation/expected-divergences.json +++ /dev/null @@ -1,391 +0,0 @@ -[ - { - "id": "D3", - "recipe": { - "k": "schnorrVerify", - "msg": { - "hex": "07" - }, - "pub": { - "hex": "85bf7562262bbd6940085748f3be6afa52ae317155181ece31b66351ccffa4b0" - }, - "sig": { - "hex": "69ad15f51bffa7d79ca3a6fb1738c969f6bb2b2abffd127a57fe46d96f9f240ee385628b589ce4c963f91142778f57818f0aaa8eaefec7ee89cf091dda6b476b" - } - } - }, - { - "id": "D3", - "recipe": { - "k": "ed25519Verify", - "msg": { - "hex": "07" - }, - "pub": { - "hex": "78b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" - }, - "sig": { - "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" - } - } - }, - { - "id": "D3", - "recipe": { - "k": "schnorrVerify", - "msg": { - "hex": "0708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223242526" - }, - "pub": { - "hex": "85bf7562262bbd6940085748f3be6afa52ae317155181ece31b66351ccffa4b0" - }, - "sig": { - "hex": "fdd3d922bb5ed67691aafd48a3ed76e8d08f8732a58e6c25f9d88167736f3966cfb0c3c5ae1d9aa01e4b53332a337d24af10c0a4c79487dcb2b24e12ebbde053" - } - } - }, - { - "id": "D3", - "recipe": { - "k": "ed25519Verify", - "msg": { - "hex": "0708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223242526" - }, - "pub": { - "hex": "78b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" - }, - "sig": { - "hex": "4179b28697408ffdd26004ce6eae9ad37563cde680739b518e0355839d5d360ffaf39a83901306d938ee721023b10852547cc6b3df53f817d1b3286c52961f02" - } - } - }, - { - "id": "D3", - "recipe": { - "k": "ed25519Verify", - "msg": { - "hex": "07" - }, - "pub": { - "hex": "c4db2c53c2bcf3dd886f05e60ac9828ed244e61a4fdc09277d58bdf4af1ba29f" - }, - "sig": { - "hex": "00570f2ce93b14bb79c6e0dca3610f8773e6642e0e9ca1613e705c2458693a30ff4f44c6fb1c9c2dab00747f8c828f6dad0bc26475746a217e275679af260206" - } - } - }, - { - "id": "D3", - "recipe": { - "k": "ed25519Verify", - "msg": { - "hex": "0708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223242526" - }, - "pub": { - "hex": "c4db2c53c2bcf3dd886f05e60ac9828ed244e61a4fdc09277d58bdf4af1ba29f" - }, - "sig": { - "hex": "216c37de1c5859650c2ad0f82df40bbcd1237391dd373fbda99ede5bc06d1954cdda66a25e484d612c763e372ea853014f0c0dba6372d2bb334df8311bf6c505" - } - } - }, - { - "id": "D3", - "recipe": { - "k": "schnorrVerify", - "msg": { - "hex": "07" - }, - "pub": { - "hex": "78be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - }, - "sig": { - "hex": "a253125c21c6a27d40360468457ccff4d212df1dce56763d00fde225fc607378dd2e6ec73b9d6b8260a18707108790995ec20a73a9b529a7e2b88a4eeb01a7d5" - } - } - }, - { - "id": "D3", - "recipe": { - "k": "schnorrVerify", - "msg": { - "hex": "0708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223242526" - }, - "pub": { - "hex": "78be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - }, - "sig": { - "hex": "c924867389915d88fcdd3bf1f6d0674f3938d1509c7310eab4c073a6be06c5ad810c152fa8efe6d2e429413d1521544daf6c069ec61b3facb39d5bc0bb2a8e8d" - } - } - }, - { - "id": "D3", - "recipe": { - "k": "schnorrVerify", - "msg": { - "hex": "243f6a8885a308d313198a2e03707344a4093822299f31d0082efa98ec4e6c89" - }, - "pub": { - "hex": "eefdea4cdb677750a420fee807eacf21eb9898ae79b9768766e4faa04a2d4a34" - }, - "sig": { - "hex": "6cff5c3ba86c69ea4b7376f31a9bcb4f74c1976089b2d9963da2e5543e17776969e89b4c5564d00349106b8497785dd7d1d713a8ae82b32fa79d5f7fc407d39b" - } - } - }, - { - "id": "D3", - "recipe": { - "k": "schnorrVerify", - "msg": { - "hex": "243f6a8885a308d313198a2e03707344a4093822299f31d0082efa98ec4e6c89" - }, - "pub": { - "hex": "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc30" - }, - "sig": { - "hex": "6cff5c3ba86c69ea4b7376f31a9bcb4f74c1976089b2d9963da2e5543e17776969e89b4c5564d00349106b8497785dd7d1d713a8ae82b32fa79d5f7fc407d39b" - } - } - }, - { - "id": "D3", - "recipe": { - "k": "ecdsaVerify", - "msg": { - "hex": "07" - }, - "pub": { - "hex": "0284bf7562262bbd6940085748f3be6afa52ae317155181ece31b66351ccffa4b0" - }, - "sig": { - "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" - } - } - }, - { - "id": "D3", - "recipe": { - "k": "ecdsaVerify", - "msg": { - "hex": "07" - }, - "pub": { - "hex": "02ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" - }, - "sig": { - "hex": "7817c1ef7da0216b52440f59d066ddcf82fd78754ec07b4b6fc05f4b7ad3482b7a4041c2e0885d313a6bd751aa210818a66cfeda154095a98b5f06dcf4868967" - } - } - }, - { - "id": "D3", - "recipe": { - "k": "schnorrVerify", - "msg": { - "hex": "07" - }, - "pub": { - "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" - }, - "sig": { - "hex": "69ad15f51bffa7d79ca3a6fb1738c969f6bb2b2abffd127a57fe46d96f9f240ee385628b589ce4c963f91142778f57818f0aaa8eaefec7ee89cf091dda6b476b" - } - } - }, - { - "id": "D2", - "recipe": { - "k": "x25519Shared", - "priv": { - "cycle": 32, - "start": 1 - }, - "pub": { - "hex": "0000000000000000000000000000000000000000000000000000000000000000" - } - } - }, - { - "id": "D2", - "recipe": { - "k": "x25519Shared", - "priv": { - "cycle": 32, - "start": 1 - }, - "pub": { - "hex": "0100000000000000000000000000000000000000000000000000000000000000" - } - } - }, - { - "id": "D2", - "recipe": { - "k": "x25519Shared", - "priv": { - "cycle": 32, - "start": 1 - }, - "pub": { - "hex": "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800" - } - } - }, - { - "id": "D2", - "recipe": { - "k": "x25519Shared", - "priv": { - "cycle": 32, - "start": 1 - }, - "pub": { - "hex": "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157" - } - } - }, - { - "id": "D2", - "recipe": { - "k": "x25519Shared", - "priv": { - "cycle": 32, - "start": 1 - }, - "pub": { - "hex": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" - } - } - }, - { - "id": "D2", - "recipe": { - "k": "x25519Shared", - "priv": { - "cycle": 32, - "start": 1 - }, - "pub": { - "hex": "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" - } - } - }, - { - "id": "D2", - "recipe": { - "k": "x25519Shared", - "priv": { - "cycle": 32, - "start": 1 - }, - "pub": { - "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" - } - } - }, - { - "id": "D2", - "recipe": { - "k": "x25519Shared", - "priv": { - "cycle": 32, - "start": 1 - }, - "pub": { - "hex": "0000000000000000000000000000000000000000000000000000000000000080" - } - } - }, - { - "id": "D2", - "recipe": { - "k": "x25519Shared", - "priv": { - "cycle": 32, - "start": 1 - }, - "pub": { - "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" - } - } - }, - { - "id": "D4", - "recipe": { - "k": "scrypt", - "len": 32, - "n": 0, - "p": 1, - "pw": { - "text": "pw" - }, - "r": 8, - "salt": { - "cycle": 16, - "start": 80 - } - } - }, - { - "id": "D5", - "recipe": { - "iter": 0, - "k": "pbkdf2Sha256", - "len": 0, - "pw": { - "text": "pw" - }, - "salt": { - "cycle": 16, - "start": 80 - } - } - }, - { - "id": "D5", - "recipe": { - "iter": 0, - "k": "pbkdf2Sha256", - "len": 32, - "pw": { - "text": "pw" - }, - "salt": { - "cycle": 16, - "start": 80 - } - } - }, - { - "id": "D5", - "recipe": { - "iter": 0, - "k": "pbkdf2Sha512", - "len": 0, - "pw": { - "text": "pw" - }, - "salt": { - "cycle": 16, - "start": 80 - } - } - }, - { - "id": "D5", - "recipe": { - "iter": 0, - "k": "pbkdf2Sha512", - "len": 32, - "pw": { - "text": "pw" - }, - "salt": { - "cycle": 16, - "start": 80 - } - } - } -] diff --git a/tests/rust-validation/reference/bc-crypto-rust-4f2b791-edits.patch b/tests/rust-validation/reference/bc-crypto-rust-4f2b791-edits.patch new file mode 100644 index 0000000..f43aff9 --- /dev/null +++ b/tests/rust-validation/reference/bc-crypto-rust-4f2b791-edits.patch @@ -0,0 +1,658 @@ +diff --git a/CHANGELOG.md b/CHANGELOG.md +new file mode 100644 +index 0000000..462153b +--- /dev/null ++++ b/CHANGELOG.md +@@ -0,0 +1,14 @@ ++# Changelog ++ ++## Unreleased ++ ++- ECDSA, Schnorr, and Ed25519 verification return false when public-key or ++ applicable signature parsing fails, instead of panicking. ++- Added `try_x25519_shared_key`, returning `Error::NonContributoryKey` when ++ the peer produces an all-zero shared secret. `x25519_shared_key` delegates ++ to it and panics on rejection; successful agreements retain their bytes. ++- PBKDF2 SHA-256/SHA-512 reject zero iterations, including with empty output. ++- `scrypt_opt` rejects log_n = 0 instead of deriving with N = 1. ++ ++These changes are not published as part of 0.14.0. See MIGRATION.md for ++caller changes and the compatibility impact of invalid historical inputs. +diff --git a/MIGRATION.md b/MIGRATION.md +new file mode 100644 +index 0000000..7e0ce79 +--- /dev/null ++++ b/MIGRATION.md +@@ -0,0 +1,22 @@ ++# Migrating to the unreleased input-validation changes ++ ++Valid keys, signatures, and KDF parameters retain their cryptographic outputs. ++ ++Use `try_x25519_shared_key` for peer keys supplied by another party and handle ++`Error::NonContributoryKey`. The original array-returning `x25519_shared_key` ++remains available but now panics on the same invalid peers. Code exhaustively ++matching the public Error enum must handle the new variant. Do not encrypt ++new data with a key obtained from a non-contributory peer. ++ ++Verification functions now return false instead of panicking on malformed ++public keys or ECDSA compact signatures. Remove panic-based handling for ++these validation failures. Fixed-size argument requirements are unchanged. ++ ++PBKDF2 iterations and scrypt_opt log_n must be positive. This also applies ++to PBKDF2 with empty output. Historical records with zero costs no longer ++derive through these entry points. If recovery is needed, use a separately ++reviewed legacy path; silently substituting parameters can produce different ++keys (in particular, scrypt N = 1 and N = 2 do not derive the same result). ++ ++These are local changes pending publication. Consumers pinned to the ++published bc-crypto 0.14.0 still observe the previous behavior. +diff --git a/README.md b/README.md +index a3b4ab0..dc80d21 100644 +--- a/README.md ++++ b/README.md +@@ -120,3 +120,11 @@ The following keys may be used to communicate sensitive information to developer + | Christopher Allen | FDFE 14A5 4ECB 30FC 5D22 74EF F8D3 6C91 3574 05ED | + + You can import a key by running the following command with that individual’s fingerprint: `gpg --recv-keys ""` Ensure that you put quotes around fingerprints that contain spaces. ++ ++### Unreleased input-validation changes ++ ++The working tree adds a recoverable `try_x25519_shared_key` API and rejects ++non-contributory X25519 peers. Verification returns false on malformed ++inputs rather than panicking; PBKDF2 iterations and `scrypt_opt` log_n must ++be positive. These changes are not in the published 0.14.0 crate. See ++[CHANGELOG.md](./CHANGELOG.md) and [MIGRATION.md](./MIGRATION.md). +diff --git a/src/ecdsa_signing.rs b/src/ecdsa_signing.rs +index 57c41b1..8425245 100644 +--- a/src/ecdsa_signing.rs ++++ b/src/ecdsa_signing.rs +@@ -1,8 +1,7 @@ + use secp256k1::{Message, PublicKey, Secp256k1, SecretKey, ecdsa::Signature}; + + use crate::{ +- ECDSA_PRIVATE_KEY_SIZE, ECDSA_PUBLIC_KEY_SIZE, ECDSA_SIGNATURE_SIZE, +- hash::double_sha256, ++ ECDSA_PRIVATE_KEY_SIZE, ECDSA_PUBLIC_KEY_SIZE, ECDSA_SIGNATURE_SIZE, hash::double_sha256, + }; + + /// ECDSA signs the given message using the given private key. +@@ -11,8 +10,7 @@ pub fn ecdsa_sign( + message: impl AsRef<[u8]>, + ) -> [u8; ECDSA_SIGNATURE_SIZE] { + let secp = Secp256k1::new(); +- let sk = SecretKey::from_byte_array(*private_key) +- .expect("32 bytes, within curve order"); ++ let sk = SecretKey::from_byte_array(*private_key).expect("32 bytes, within curve order"); + let hash = double_sha256(message.as_ref()); + let msg = Message::from_digest(hash); + let sig = secp.sign_ecdsa(msg, &sk); +@@ -28,12 +26,14 @@ pub fn ecdsa_verify( + message: impl AsRef<[u8]>, + ) -> bool { + let secp = Secp256k1::new(); +- let pk = PublicKey::from_slice(public_key) +- .expect("33 or 65 bytes, serialized according to the spec"); ++ let Ok(pk) = PublicKey::from_slice(public_key) else { ++ return false; ++ }; + let hash = double_sha256(message.as_ref()); + let msg = Message::from_digest(hash); +- let sig = Signature::from_compact(signature) +- .expect("64 bytes, signature according to the spec"); ++ let Ok(sig) = Signature::from_compact(signature) else { ++ return false; ++ }; + secp.verify_ecdsa(msg, &sig, &pk).is_ok() + } + +@@ -43,8 +43,7 @@ mod tests { + use hex_literal::hex; + + use crate::{ +- ecdsa_new_private_key_using, ecdsa_public_key_from_private_key, +- ecdsa_sign, ecdsa_verify, ++ ecdsa_new_private_key_using, ecdsa_public_key_from_private_key, ecdsa_sign, ecdsa_verify, + }; + + const MESSAGE: &[u8] = b"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."; +diff --git a/src/ed25519_signing.rs b/src/ed25519_signing.rs +index d99ec98..59c282e 100644 +--- a/src/ed25519_signing.rs ++++ b/src/ed25519_signing.rs +@@ -31,8 +31,9 @@ pub fn ed25519_verify( + message: &[u8], + signature: &[u8; ED25519_SIGNATURE_SIZE], + ) -> bool { +- let verifying_key = +- ed25519_dalek::VerifyingKey::from_bytes(public_key).unwrap(); ++ let Ok(verifying_key) = ed25519_dalek::VerifyingKey::from_bytes(public_key) else { ++ return false; ++ }; + let signature = Signature::from_bytes(signature); + verifying_key.verify_strict(message, &signature).is_ok() + } +@@ -42,9 +43,8 @@ mod tests { + use hex_literal::hex; + + use crate::{ +- ED25519_PRIVATE_KEY_SIZE, ED25519_PUBLIC_KEY_SIZE, +- ED25519_SIGNATURE_SIZE, ed25519_public_key_from_private_key, +- ed25519_sign, ed25519_verify, ++ ED25519_PRIVATE_KEY_SIZE, ED25519_PUBLIC_KEY_SIZE, ED25519_SIGNATURE_SIZE, ++ ed25519_public_key_from_private_key, ed25519_sign, ed25519_verify, + }; + + #[test] +@@ -60,16 +60,12 @@ mod tests { + .unwrap(); + assert_eq!( + private_key, +- hex!( +- "7eb559bbbf6cce2632cf9f194aeb50943de7e1cbad54dcfab27a42759f5e2fed" +- ) ++ hex!("7eb559bbbf6cce2632cf9f194aeb50943de7e1cbad54dcfab27a42759f5e2fed") + ); + let public_key = ed25519_public_key_from_private_key(&private_key); + assert_eq!( + public_key, +- hex!( +- "76f863e1024d8ff6cd8ad56c434e01dbbf2999cfc2f132fc7f41ca19fed7a97c" +- ) ++ hex!("76f863e1024d8ff6cd8ad56c434e01dbbf2999cfc2f132fc7f41ca19fed7a97c") + ); + let signature = ed25519_sign(&private_key, MESSAGE); + assert_eq!( +diff --git a/src/error.rs b/src/error.rs +index 15e951a..46485e8 100644 +--- a/src/error.rs ++++ b/src/error.rs +@@ -3,12 +3,16 @@ use thiserror::Error; + + #[derive(Debug, Error)] + pub enum Error { ++ #[error("X25519 peer key produces an all-zero shared secret")] ++ NonContributoryKey, + #[error("AEAD error")] + Aead(AeadError), + } + + impl From for Error { +- fn from(error: AeadError) -> Self { Error::Aead(error) } ++ fn from(error: AeadError) -> Self { ++ Error::Aead(error) ++ } + } + + pub type Result = std::result::Result; +diff --git a/src/hash.rs b/src/hash.rs +index c37a6d3..b2a6dcf 100644 +--- a/src/hash.rs ++++ b/src/hash.rs +@@ -8,15 +8,14 @@ pub const SHA256_SIZE: usize = 32; + pub const SHA512_SIZE: usize = 64; + + /// Computes the CRC-32 checksum of the given data. +-pub fn crc32(data: impl AsRef<[u8]>) -> u32 { crc32fast::hash(data.as_ref()) } ++pub fn crc32(data: impl AsRef<[u8]>) -> u32 { ++ crc32fast::hash(data.as_ref()) ++} + + /// Computes the SHA-256 hash of the given data, returning the hash as a + /// 4-byte vector that can be returned in either big-endian or little-endian + /// format. +-pub fn crc32_data_opt( +- data: impl AsRef<[u8]>, +- little_endian: bool, +-) -> [u8; CRC32_SIZE] { ++pub fn crc32_data_opt(data: impl AsRef<[u8]>, little_endian: bool) -> [u8; CRC32_SIZE] { + let checksum: u32 = crc32(data); + let mut result = [0u8; 4]; + if little_endian { +@@ -58,10 +57,7 @@ pub fn sha512(data: impl AsRef<[u8]>) -> [u8; SHA512_SIZE] { + } + + /// Computes the HMAC-SHA-256 for the given key and message. +-pub fn hmac_sha256( +- key: impl AsRef<[u8]>, +- message: impl AsRef<[u8]>, +-) -> [u8; SHA256_SIZE] { ++pub fn hmac_sha256(key: impl AsRef<[u8]>, message: impl AsRef<[u8]>) -> [u8; SHA256_SIZE] { + let mut mac = Hmac::::new_from_slice(key.as_ref()).unwrap(); + mac.update(message.as_ref()); + let result = mac.finalize(); +@@ -69,10 +65,7 @@ pub fn hmac_sha256( + } + + /// Computes the HMAC-SHA-512 for the given key and message. +-pub fn hmac_sha512( +- key: impl AsRef<[u8]>, +- message: impl AsRef<[u8]>, +-) -> [u8; SHA512_SIZE] { ++pub fn hmac_sha512(key: impl AsRef<[u8]>, message: impl AsRef<[u8]>) -> [u8; SHA512_SIZE] { + let mut mac = Hmac::::new_from_slice(key.as_ref()).unwrap(); + mac.update(message.as_ref()); + let result = mac.finalize(); +@@ -80,24 +73,30 @@ pub fn hmac_sha512( + } + + /// Computes the PBKDF2-HMAC-SHA-256 for the given password. ++/// ++/// Panics if iterations is zero, including for empty output. + pub fn pbkdf2_hmac_sha256( + pass: impl AsRef<[u8]>, + salt: impl AsRef<[u8]>, + iterations: u32, + key_len: usize, + ) -> Vec { ++ assert!(iterations > 0, "PBKDF2 iterations must be positive"); + let mut key = vec![0u8; key_len]; + pbkdf2_hmac::(pass.as_ref(), salt.as_ref(), iterations, &mut key); + key + } + + /// Computes the PBKDF2-HMAC-SHA-512 for the given password. ++/// ++/// Panics if iterations is zero, including for empty output. + pub fn pbkdf2_hmac_sha512( + pass: impl AsRef<[u8]>, + salt: impl AsRef<[u8]>, + iterations: u32, + key_len: usize, + ) -> Vec { ++ assert!(iterations > 0, "PBKDF2 iterations must be positive"); + let mut key = vec![0u8; key_len]; + pbkdf2_hmac::(pass.as_ref(), salt.as_ref(), iterations, &mut key); + key +@@ -132,8 +131,8 @@ mod tests { + use hex_literal::hex; + + use crate::hash::{ +- crc32, crc32_data, crc32_data_opt, hmac_sha256, hmac_sha512, +- pbkdf2_hmac_sha256, sha256, sha512, ++ crc32, crc32_data, crc32_data_opt, hmac_sha256, hmac_sha512, pbkdf2_hmac_sha256, sha256, ++ sha512, + }; + + #[test] +@@ -147,9 +146,7 @@ mod tests { + #[test] + fn test_sha256() { + let input = "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"; +- let expected = hex!( +- "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" +- ); ++ let expected = hex!("248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"); + let result = sha256(input.as_bytes()); + assert_eq!(result, expected); + } +@@ -170,9 +167,7 @@ mod tests { + let message = b"Hi There"; + assert_eq!( + hmac_sha256(key, message), +- hex!( +- "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" +- ) ++ hex!("b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7") + ); + assert_eq!( + hmac_sha512(key, message), +@@ -186,9 +181,7 @@ mod tests { + fn test_pbkdf2_hmac_sha256() { + assert_eq!( + pbkdf2_hmac_sha256("password", "salt", 1, 32), +- hex!( +- "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b" +- ) ++ hex!("120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b") + ); + } + +@@ -198,9 +191,7 @@ mod tests { + let salt = hex!("8e94ef805b93e683ff18"); + assert_eq!( + super::hkdf_hmac_sha256(key_material, salt, 32), +- hex!( +- "13485067e21af17c0900f70d885f02593c0e61e46f86450e4a0201a54c14db76" +- ) ++ hex!("13485067e21af17c0900f70d885f02593c0e61e46f86450e4a0201a54c14db76") + ); + } + } +diff --git a/src/lib.rs b/src/lib.rs +index 8e17c91..34796f3 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -33,8 +33,8 @@ pub use error::{Error, Result}; + /// The `hash` module contains functions for hashing data. + pub mod hash; + pub use hash::{ +- CRC32_SIZE, SHA256_SIZE, SHA512_SIZE, double_sha256, hkdf_hmac_sha256, +- hmac_sha256, hmac_sha512, pbkdf2_hmac_sha256, sha256, sha512, ++ CRC32_SIZE, SHA256_SIZE, SHA512_SIZE, double_sha256, hkdf_hmac_sha256, hmac_sha256, ++ hmac_sha512, pbkdf2_hmac_sha256, sha256, sha512, + }; + + mod memzero; +@@ -42,29 +42,26 @@ pub use memzero::{memzero, memzero_vec_vec_u8}; + + mod symmetric_encryption; + pub use symmetric_encryption::{ +- SYMMETRIC_AUTH_SIZE, SYMMETRIC_KEY_SIZE, SYMMETRIC_NONCE_SIZE, +- aead_chacha20_poly1305_decrypt, aead_chacha20_poly1305_decrypt_with_aad, +- aead_chacha20_poly1305_encrypt, aead_chacha20_poly1305_encrypt_with_aad, ++ SYMMETRIC_AUTH_SIZE, SYMMETRIC_KEY_SIZE, SYMMETRIC_NONCE_SIZE, aead_chacha20_poly1305_decrypt, ++ aead_chacha20_poly1305_decrypt_with_aad, aead_chacha20_poly1305_encrypt, ++ aead_chacha20_poly1305_encrypt_with_aad, + }; + + mod public_key_encryption; + pub use public_key_encryption::{ +- X25519_PRIVATE_KEY_SIZE, X25519_PUBLIC_KEY_SIZE, +- derive_agreement_private_key, derive_signing_private_key, +- x25519_new_private_key_using, x25519_public_key_from_private_key, +- x25519_shared_key, ++ X25519_PRIVATE_KEY_SIZE, X25519_PUBLIC_KEY_SIZE, derive_agreement_private_key, ++ derive_signing_private_key, try_x25519_shared_key, x25519_new_private_key_using, ++ x25519_public_key_from_private_key, x25519_shared_key, + }; + + #[cfg(feature = "secp256k1")] + mod ecdsa_keys; + #[cfg(feature = "secp256k1")] + pub use ecdsa_keys::{ +- ECDSA_MESSAGE_HASH_SIZE, ECDSA_PRIVATE_KEY_SIZE, ECDSA_PUBLIC_KEY_SIZE, +- ECDSA_SIGNATURE_SIZE, ECDSA_UNCOMPRESSED_PUBLIC_KEY_SIZE, +- SCHNORR_PUBLIC_KEY_SIZE, ecdsa_compress_public_key, +- ecdsa_decompress_public_key, ecdsa_derive_private_key, +- ecdsa_new_private_key_using, ecdsa_public_key_from_private_key, +- schnorr_public_key_from_private_key, ++ ECDSA_MESSAGE_HASH_SIZE, ECDSA_PRIVATE_KEY_SIZE, ECDSA_PUBLIC_KEY_SIZE, ECDSA_SIGNATURE_SIZE, ++ ECDSA_UNCOMPRESSED_PUBLIC_KEY_SIZE, SCHNORR_PUBLIC_KEY_SIZE, ecdsa_compress_public_key, ++ ecdsa_decompress_public_key, ecdsa_derive_private_key, ecdsa_new_private_key_using, ++ ecdsa_public_key_from_private_key, schnorr_public_key_from_private_key, + }; + + #[cfg(feature = "secp256k1")] +@@ -76,8 +73,8 @@ pub use ecdsa_signing::{ecdsa_sign, ecdsa_verify}; + mod schnorr_signing; + #[cfg(feature = "secp256k1")] + pub use schnorr_signing::{ +- SCHNORR_SIGNATURE_SIZE, schnorr_sign, schnorr_sign_using, +- schnorr_sign_with_aux_rand, schnorr_verify, ++ SCHNORR_SIGNATURE_SIZE, schnorr_sign, schnorr_sign_using, schnorr_sign_with_aux_rand, ++ schnorr_verify, + }; + + #[cfg(feature = "ed25519")] +@@ -85,8 +82,8 @@ mod ed25519_signing; + #[cfg(feature = "ed25519")] + pub use ed25519_signing::{ + ED25519_PRIVATE_KEY_SIZE, ED25519_PUBLIC_KEY_SIZE, ED25519_SIGNATURE_SIZE, +- ed25519_new_private_key_using, ed25519_public_key_from_private_key, +- ed25519_sign, ed25519_verify, ++ ed25519_new_private_key_using, ed25519_public_key_from_private_key, ed25519_sign, ++ ed25519_verify, + }; + + mod scrypt; +diff --git a/src/public_key_encryption.rs b/src/public_key_encryption.rs +index 9296ba0..f3f363b 100644 +--- a/src/public_key_encryption.rs ++++ b/src/public_key_encryption.rs +@@ -28,16 +28,10 @@ pub fn derive_agreement_private_key( + /// Derive a 32-byte signing private key from the given key material. + /// + /// Enforces domain separation from agreement keys by using the "signing" salt. +-pub fn derive_signing_private_key( +- key_material: impl AsRef<[u8]>, +-) -> [u8; GENERIC_PUBLIC_KEY_SIZE] { +- hkdf_hmac_sha256( +- key_material, +- "signing".as_bytes(), +- GENERIC_PUBLIC_KEY_SIZE, +- ) +- .try_into() +- .unwrap() ++pub fn derive_signing_private_key(key_material: impl AsRef<[u8]>) -> [u8; GENERIC_PUBLIC_KEY_SIZE] { ++ hkdf_hmac_sha256(key_material, "signing".as_bytes(), GENERIC_PUBLIC_KEY_SIZE) ++ .try_into() ++ .unwrap() + } + + /// Create a new X25519 private key using the given random number generator. +@@ -58,16 +52,34 @@ pub fn x25519_public_key_from_private_key( + + /// Compute the shared symmetric key from the given X25519 private and public + /// keys. ++/// ++/// Panics if the peer produces an all-zero shared secret. For a recoverable ++/// error, use [`try_x25519_shared_key`]. + pub fn x25519_shared_key( + x25519_private_key: &[u8; X25519_PRIVATE_KEY_SIZE], + x25519_public_key: &[u8; X25519_PUBLIC_KEY_SIZE], + ) -> [u8; SYMMETRIC_KEY_SIZE] { ++ try_x25519_shared_key(x25519_private_key, x25519_public_key) ++ .expect("X25519 peer key must be contributory") ++} ++ ++/// Computes an agreed key, rejecting peers that produce an all-zero secret. ++/// Use this checked API when the public key comes from untrusted input. ++pub fn try_x25519_shared_key( ++ x25519_private_key: &[u8; X25519_PRIVATE_KEY_SIZE], ++ x25519_public_key: &[u8; X25519_PUBLIC_KEY_SIZE], ++) -> crate::Result<[u8; SYMMETRIC_KEY_SIZE]> { + let sk = StaticSecret::from(*x25519_private_key); + let pk = PublicKey::from(*x25519_public_key); + let shared_secret = sk.diffie_hellman(&pk); +- hkdf_hmac_sha256(shared_secret.as_bytes(), "agreement".as_bytes(), 32) +- .try_into() +- .unwrap() ++ if !shared_secret.was_contributory() { ++ return Err(crate::Error::NonContributoryKey); ++ } ++ Ok( ++ hkdf_hmac_sha256(shared_secret.as_bytes(), "agreement".as_bytes(), 32) ++ .try_into() ++ .unwrap(), ++ ) + } + + #[cfg(test)] +@@ -76,9 +88,8 @@ mod tests { + use hex_literal::hex; + + use crate::{ +- derive_agreement_private_key, derive_signing_private_key, +- x25519_new_private_key_using, x25519_public_key_from_private_key, +- x25519_shared_key, ++ derive_agreement_private_key, derive_signing_private_key, x25519_new_private_key_using, ++ x25519_public_key_from_private_key, x25519_shared_key, + }; + + #[test] +@@ -87,34 +98,24 @@ mod tests { + let private_key = x25519_new_private_key_using(&mut rng); + assert_eq!( + private_key, +- hex!( +- "7eb559bbbf6cce2632cf9f194aeb50943de7e1cbad54dcfab27a42759f5e2fed" +- ) ++ hex!("7eb559bbbf6cce2632cf9f194aeb50943de7e1cbad54dcfab27a42759f5e2fed") + ); + let public_key = x25519_public_key_from_private_key(&private_key); + assert_eq!( + public_key, +- hex!( +- "f1bd7a7e118ea461eba95126a3efef543ebb78439d1574bedcbe7d89174cf025" +- ) ++ hex!("f1bd7a7e118ea461eba95126a3efef543ebb78439d1574bedcbe7d89174cf025") + ); + +- let derived_x25519_private_key = +- derive_agreement_private_key(b"password"); ++ let derived_x25519_private_key = derive_agreement_private_key(b"password"); + assert_eq!( + derived_x25519_private_key, +- hex!( +- "7b19769132648ff43ae60cbaa696d5be3f6d53e6645db72e2d37516f0729619f" +- ) ++ hex!("7b19769132648ff43ae60cbaa696d5be3f6d53e6645db72e2d37516f0729619f") + ); + +- let derived_signing_private_key = +- derive_signing_private_key(b"password"); ++ let derived_signing_private_key = derive_signing_private_key(b"password"); + assert_eq!( + derived_signing_private_key, +- hex!( +- "05cc550daa75058e613e606d9898fedf029e395911c43273a208b7e0e88e271b" +- ) ++ hex!("05cc550daa75058e613e606d9898fedf029e395911c43273a208b7e0e88e271b") + ); + } + +@@ -122,21 +123,15 @@ mod tests { + fn test_key_agreement() { + let mut rng = make_fake_random_number_generator(); + let alice_private_key = x25519_new_private_key_using(&mut rng); +- let alice_public_key = +- x25519_public_key_from_private_key(&alice_private_key); ++ let alice_public_key = x25519_public_key_from_private_key(&alice_private_key); + let bob_private_key = x25519_new_private_key_using(&mut rng); +- let bob_public_key = +- x25519_public_key_from_private_key(&bob_private_key); +- let alice_shared_key = +- x25519_shared_key(&alice_private_key, &bob_public_key); +- let bob_shared_key = +- x25519_shared_key(&bob_private_key, &alice_public_key); ++ let bob_public_key = x25519_public_key_from_private_key(&bob_private_key); ++ let alice_shared_key = x25519_shared_key(&alice_private_key, &bob_public_key); ++ let bob_shared_key = x25519_shared_key(&bob_private_key, &alice_public_key); + assert_eq!(alice_shared_key, bob_shared_key); + assert_eq!( + alice_shared_key, +- hex!( +- "1e9040d1ff45df4bfca7ef2b4dd2b11101b40d91bf5bf83f8c83d53f0fbb6c23" +- ) ++ hex!("1e9040d1ff45df4bfca7ef2b4dd2b11101b40d91bf5bf83f8c83d53f0fbb6c23") + ); + } + } +diff --git a/src/schnorr_signing.rs b/src/schnorr_signing.rs +index 0dd44b4..bb7aa55 100644 +--- a/src/schnorr_signing.rs ++++ b/src/schnorr_signing.rs +@@ -45,8 +45,9 @@ pub fn schnorr_verify( + ) -> bool { + let secp = Secp256k1::new(); + let sig = Signature::from_byte_array(*schnorr_signature); +- let pk = XOnlyPublicKey::from_byte_array(*schnorr_public_key) +- .expect("32 bytes, serialized according to the spec"); ++ let Ok(pk) = XOnlyPublicKey::from_byte_array(*schnorr_public_key) else { ++ return false; ++ }; + secp.verify_schnorr(&sig, message.as_ref(), &pk).is_ok() + } + +@@ -177,7 +178,6 @@ mod tests { + } + + #[test] +- #[should_panic] // public key not on the curve + fn test_5() { + run_test_vector(TestVector { + secret_key: None, +@@ -286,7 +286,6 @@ mod tests { + } + + #[test] +- #[should_panic] // public key is not a valid X coordinate because it exceeds the field size + fn test_14() { + run_test_vector(TestVector { + secret_key: None, +diff --git a/src/scrypt.rs b/src/scrypt.rs +index 982a00d..cf78b7f 100644 +--- a/src/scrypt.rs ++++ b/src/scrypt.rs +@@ -32,11 +32,11 @@ pub fn scrypt( + ) -> Vec { + let params = scrypt::Params::recommended(); + let mut output = vec![0u8; output_len]; +- scrypt_hash(pass.as_ref(), salt.as_ref(), ¶ms, &mut output) +- .expect("scrypt failed"); ++ scrypt_hash(pass.as_ref(), salt.as_ref(), ¶ms, &mut output).expect("scrypt failed"); + output + } + ++/// Derives with explicit costs; panics when log_n is zero or parameters are invalid. + pub fn scrypt_opt( + pass: impl AsRef<[u8]>, + salt: impl AsRef<[u8]>, +@@ -46,11 +46,10 @@ pub fn scrypt_opt( + r: u32, // Must be greater than 0 and less than or equal to `4294967295` + p: u32, // Must be greater than 0 and less than `4294967295` + ) -> Vec { +- let params = scrypt::Params::new(log_n, r, p, output_len) +- .expect("Invalid Scrypt parameters"); ++ assert!(log_n > 0, "scrypt log_n must be positive"); ++ let params = scrypt::Params::new(log_n, r, p, output_len).expect("Invalid Scrypt parameters"); + let mut output = vec![0u8; output_len]; +- scrypt_hash(pass.as_ref(), salt.as_ref(), ¶ms, &mut output) +- .expect("scrypt failed"); ++ scrypt_hash(pass.as_ref(), salt.as_ref(), ¶ms, &mut output).expect("scrypt failed"); + output + } + +diff --git a/tests/invalid_inputs.rs b/tests/invalid_inputs.rs +new file mode 100644 +index 0000000..fb96cdc +--- /dev/null ++++ b/tests/invalid_inputs.rs +@@ -0,0 +1,47 @@ ++use bc_crypto::*; ++ ++#[test] ++fn low_order_x25519_is_rejected() { ++ let private = [7; 32]; ++ assert!(matches!( ++ try_x25519_shared_key(&private, &[0; 32]), ++ Err(Error::NonContributoryKey) ++ )); ++ assert!(std::panic::catch_unwind(|| x25519_shared_key(&private, &[0; 32])).is_err()); ++ let public = x25519_public_key_from_private_key(&[9; 32]); ++ assert_eq!( ++ try_x25519_shared_key(&private, &public).unwrap(), ++ x25519_shared_key(&private, &public) ++ ); ++} ++ ++#[test] ++fn zero_kdf_costs_are_rejected_even_for_empty_output() { ++ for len in [0, 32] { ++ assert!( ++ std::panic::catch_unwind(|| hash::pbkdf2_hmac_sha256(b"pw", b"salt", 0, len)).is_err() ++ ); ++ assert!( ++ std::panic::catch_unwind(|| hash::pbkdf2_hmac_sha512(b"pw", b"salt", 0, len)).is_err() ++ ); ++ } ++ assert!(std::panic::catch_unwind(|| scrypt_opt(b"pw", b"salt", 32, 0, 8, 1)).is_err()); ++} ++ ++#[cfg(feature = "secp256k1")] ++#[test] ++fn malformed_secp256k1_inputs_return_false() { ++ assert!(!ecdsa_verify(&[255; 33], &[255; 64], b"msg")); ++ let public = ecdsa_public_key_from_private_key(&[1; 32]); ++ assert!(!ecdsa_verify(&public, &[255; 64], b"msg")); ++ assert!(!schnorr_verify(&[255; 32], &[255; 64], b"msg")); ++} ++ ++#[cfg(feature = "ed25519")] ++#[test] ++fn malformed_ed25519_key_returns_false() { ++ // Compressed y=2 does not decompress on edwards25519. ++ let mut public = [0; 32]; ++ public[0] = 2; ++ assert!(!ed25519_verify(&public, b"msg", &[255; 64])); ++} diff --git a/tests/rust-validation/src/main.rs b/tests/rust-validation/src/main.rs index 7248216..04ecfae 100644 --- a/tests/rust-validation/src/main.rs +++ b/tests/rust-validation/src/main.rs @@ -1,13 +1,26 @@ -//! Replays tests/vectors/vectors.json against bc-crypto 0.14.0. +//! Replays a vectors file against the bc-crypto reference. //! -//! cargo run --release -- ../vectors/vectors.json +//! cargo run --release --offline -- ../vectors/vectors.json +//! cargo run --release --offline -- (bun scripts/generate-vectors.ts --full ) +//! cargo run --release --offline -- ../vectors/heavy.json //! -//! Outcomes are compared after normalising every failure (TS `throw:`, -//! Rust `Err`/panic) to "throw"; the harness checks WHAT succeeds and the -//! bytes it produces, not error taxonomy. -use bc_crypto::*; +//! Every recipe is parsed into the reference's argument types before the call, +//! outside `catch_unwind`. An input the Rust signature cannot receive is +//! `js-only` (J1: no reference function; J2: a fixed-size argument of the wrong +//! length; J3: sealed data under 16 bytes; J4: a number that is not an integer +//! of the Rust width): never a match, never a mismatch. The reference call runs +//! under `catch_unwind`, so a panic is a throw. Outcomes are compared after +//! normalising every failure (TS `throw:`, Rust panic or `Err`) to +//! "throw", except `x25519Shared`, whose `Err(NonContributoryKey)` is rendered +//! `throw:NonContributoryKey|` and compared verbatim with the TS +//! adapter's `throw:|`. There is no exception list: any other +//! difference is a MISMATCH, and the process exits 1. use bc_crypto::hash::{crc32, crc32_data_opt, hkdf_hmac_sha512, pbkdf2_hmac_sha512}; +use bc_crypto::*; use bc_rand::SeededRandomNumberGenerator; +use serde::Deserialize; +use serde_json::Value; +use std::panic::{catch_unwind, AssertUnwindSafe}; /// bc-crypto's ed25519 keygen wants a rand_core 0.6 `CryptoRngCore`; bc-rand's /// generator implements rand_core 0.9. The bridge forwards each method to the @@ -22,139 +35,228 @@ impl rand_core::RngCore for Bridge<'_> { fn try_fill_bytes(&mut self, dest: &mut [u8]) -> std::result::Result<(), rand_core::Error> { self.fill_bytes(dest); Ok(()) } } impl rand_core::CryptoRng for Bridge<'_> {} -use serde::Deserialize; -use std::panic::{catch_unwind, AssertUnwindSafe}; #[derive(Deserialize)] struct File { count: usize, vectors: Vec } #[derive(Deserialize)] -struct Vector { recipe: serde_json::Value, expect: String } +struct Vector { recipe: Value, expect: String } + +/// The js-only class of an input no reference call can receive. +type JsOnly = &'static str; -fn bytes(v: &serde_json::Value) -> Vec { +enum Outcome { Value(String), Throw } + +fn bytes(v: &Value) -> Vec { if let Some(h) = v.get("hex") { return hex::decode(h.as_str().unwrap()).unwrap(); } if let Some(t) = v.get("text") { return t.as_str().unwrap().as_bytes().to_vec(); } let n = v["cycle"].as_u64().unwrap() as usize; let start = v.get("start").and_then(|s| s.as_u64()).unwrap_or(0) as usize; (0..n).map(|i| ((start + i) & 0xff) as u8).collect() } -fn seed(v: &serde_json::Value) -> [u64; 4] { +fn seed(v: &Value) -> [u64; 4] { let a: Vec = v.as_array().unwrap().iter().map(|s| s.as_str().unwrap().parse().unwrap()).collect(); [a[0], a[1], a[2], a[3]] } +/// A fixed-size argument; a wrong length is J2. +fn fixed(v: Vec) -> std::result::Result<[u8; N], JsOnly> { v.try_into().map_err(|_| "J2") } +/// Width-checked numbers; a fraction, a negative or a value past the width is J4. +fn u8_of(v: &Value) -> std::result::Result { v.as_u64().and_then(|n| u8::try_from(n).ok()).ok_or("J4") } +fn u32_of(v: &Value) -> std::result::Result { v.as_u64().and_then(|n| u32::try_from(n).ok()).ok_or("J4") } +fn usize_of(v: &Value) -> std::result::Result { v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or("J4") } +fn bool_of(v: &Value) -> std::result::Result { v.as_bool().ok_or("J4") } fn norm(s: &str) -> String { if s.starts_with("throw") { "throw".into() } else { s.to_string() } } -/// Fixed-size view; a wrong length is what TypeScript reports as a throw. -fn fixed(v: Vec) -> Option<[u8; N]> { v.try_into().ok() } -fn run(r: &serde_json::Value) -> String { +/// The reference call alone runs under `catch_unwind`: a panic is a throw. +fn attempt(f: impl FnOnce() -> Option) -> Outcome { + match catch_unwind(AssertUnwindSafe(f)) { + Ok(Some(s)) => Outcome::Value(s), + _ => Outcome::Throw, + } +} + +fn run(r: &Value) -> std::result::Result { let k = r["k"].as_str().unwrap(); let b = |key: &str| bytes(&r[key]); - let out: std::result::Result, ()> = catch_unwind(AssertUnwindSafe(|| -> Option { - Some(match k { - "sha256" => hex::encode(sha256(b("d"))), - "doubleSha256" => hex::encode(double_sha256(&b("d"))), - "sha512" => hex::encode(sha512(b("d"))), - "crc32" => crc32(b("d")).to_string(), - "crc32Bytes" => hex::encode(crc32_data_opt(b("d"), r["le"].as_bool().unwrap())), - "hmacSha256" => hex::encode(hmac_sha256(b("key"), b("d"))), - "hmacSha512" => hex::encode(hmac_sha512(b("key"), b("d"))), - "pbkdf2Sha256" => hex::encode(pbkdf2_hmac_sha256(b("pw"), b("salt"), r["iter"].as_u64().unwrap() as u32, r["len"].as_u64().unwrap() as usize)), - "pbkdf2Sha512" => hex::encode(pbkdf2_hmac_sha512(b("pw"), b("salt"), r["iter"].as_u64().unwrap() as u32, r["len"].as_u64().unwrap() as usize)), - "hkdfSha256" => hex::encode(hkdf_hmac_sha256(b("key"), b("salt"), r["len"].as_u64().unwrap() as usize)), - "hkdfSha512" => hex::encode(hkdf_hmac_sha512(b("key"), b("salt"), r["len"].as_u64().unwrap() as usize)), - "scrypt" => { - let len = r["len"].as_u64().unwrap() as usize; - match r.get("n").and_then(|n| n.as_u64()) { - None => hex::encode(scrypt(b("pw"), b("salt"), len)), - // recipe `n` IS log2(N) (frozen recipe semantics) - Some(n) => hex::encode(scrypt_opt(b("pw"), b("salt"), len, n as u8, r["r"].as_u64().unwrap() as u32, r["p"].as_u64().unwrap() as u32)), + Ok(match k { + "sha256" => { let d = b("d"); attempt(move || Some(hex::encode(sha256(d)))) } + "doubleSha256" => { let d = b("d"); attempt(move || Some(hex::encode(double_sha256(&d)))) } + "sha512" => { let d = b("d"); attempt(move || Some(hex::encode(sha512(d)))) } + "crc32" => { let d = b("d"); attempt(move || Some(crc32(d).to_string())) } + "crc32Bytes" => { let d = b("d"); let le = bool_of(&r["le"])?; attempt(move || Some(hex::encode(crc32_data_opt(d, le)))) } + "hmacSha256" => { let (key, d) = (b("key"), b("d")); attempt(move || Some(hex::encode(hmac_sha256(key, d)))) } + "hmacSha512" => { let (key, d) = (b("key"), b("d")); attempt(move || Some(hex::encode(hmac_sha512(key, d)))) } + "pbkdf2Sha256" => { + let (pw, salt) = (b("pw"), b("salt")); + let (iter, len) = (u32_of(&r["iter"])?, usize_of(&r["len"])?); + attempt(move || Some(hex::encode(pbkdf2_hmac_sha256(pw, salt, iter, len)))) + } + "pbkdf2Sha512" => { + let (pw, salt) = (b("pw"), b("salt")); + let (iter, len) = (u32_of(&r["iter"])?, usize_of(&r["len"])?); + attempt(move || Some(hex::encode(pbkdf2_hmac_sha512(pw, salt, iter, len)))) + } + "hkdfSha256" => { let (key, salt, len) = (b("key"), b("salt"), usize_of(&r["len"])?); attempt(move || Some(hex::encode(hkdf_hmac_sha256(key, salt, len)))) } + "hkdfSha512" => { let (key, salt, len) = (b("key"), b("salt"), usize_of(&r["len"])?); attempt(move || Some(hex::encode(hkdf_hmac_sha512(key, salt, len)))) } + "scrypt" => { + let (pw, salt, len) = (b("pw"), b("salt"), usize_of(&r["len"])?); + match r.get("n") { + None => attempt(move || Some(hex::encode(scrypt(pw, salt, len)))), + // recipe `n` IS log2(N) (frozen recipe semantics) + Some(n) => { + let (log_n, rr, p) = (u8_of(n)?, u32_of(&r["r"])?, u32_of(&r["p"])?); + attempt(move || Some(hex::encode(scrypt_opt(pw, salt, len, log_n, rr, p)))) } } - "argon2id" => hex::encode(argon2id(b("pw"), b("salt"), r["len"].as_u64().unwrap() as usize)), - // JS-only (report A6): no reference analog; classified J1 before comparison. - "chacha20" => return None, - "aeadEncrypt" => { - let (ct, tag) = match r.get("aad") { - None => aead_chacha20_poly1305_encrypt(b("pt"), &fixed::<32>(b("key"))?, &fixed::<12>(b("nonce"))?), - Some(a) => aead_chacha20_poly1305_encrypt_with_aad(b("pt"), &fixed::<32>(b("key"))?, &fixed::<12>(b("nonce"))?, bytes(a)), + } + "argon2id" => { let (pw, salt, len) = (b("pw"), b("salt"), usize_of(&r["len"])?); attempt(move || Some(hex::encode(argon2id(pw, salt, len)))) } + // Raw ChaCha20 has no bc-crypto function (provenance-mark's `ChaCha20::new` + `apply_keystream`). + "chacha20" => return Err("J1"), + "aeadEncrypt" => { + let (key, nonce, pt) = (fixed::<32>(b("key"))?, fixed::<12>(b("nonce"))?, b("pt")); + let aad = r.get("aad").map(bytes); + attempt(move || { + let (ct, tag) = match aad { + None => aead_chacha20_poly1305_encrypt(pt, &key, &nonce), + Some(a) => aead_chacha20_poly1305_encrypt_with_aad(pt, &key, &nonce, a), }; - let mut all = ct; all.extend_from_slice(&tag); hex::encode(all) - } - "aeadDecrypt" => { - let all = b("ct"); - if all.len() < 16 { return None; } + let mut all = ct; all.extend_from_slice(&tag); Some(hex::encode(all)) + }) + } + "aeadDecrypt" => { + let (key, nonce) = (fixed::<32>(b("key"))?, fixed::<12>(b("nonce"))?); + let all = b("ct"); + if all.len() < 16 { return Err("J3"); } + let aad = r.get("aad").map(bytes); + attempt(move || { let (ct, tag) = all.split_at(all.len() - 16); let mut t = [0u8; 16]; t.copy_from_slice(tag); - let res = match r.get("aad") { - None => aead_chacha20_poly1305_decrypt(ct, &fixed::<32>(b("key"))?, &fixed::<12>(b("nonce"))?, &t), - Some(a) => aead_chacha20_poly1305_decrypt_with_aad(ct, &fixed::<32>(b("key"))?, &fixed::<12>(b("nonce"))?, bytes(a), &t), + let res = match aad { + None => aead_chacha20_poly1305_decrypt(ct, &key, &nonce, &t), + Some(a) => aead_chacha20_poly1305_decrypt_with_aad(ct, &key, &nonce, a, &t), }; - match res { Ok(pt) => hex::encode(pt), Err(_) => return None } - } - "x25519Pub" => hex::encode(x25519_public_key_from_private_key(&fixed::<32>(b("priv"))?)), - "x25519Shared" => hex::encode(x25519_shared_key(&fixed::<32>(b("priv"))?, &fixed::<32>(b("pub"))?)), - "deriveAgreement" => hex::encode(derive_agreement_private_key(b("km"))), - "deriveSigning" => hex::encode(derive_signing_private_key(b("km"))), - "ecdsaDerive" => hex::encode(ecdsa_derive_private_key(b("km"))), - "ecdsaPub" => hex::encode(ecdsa_public_key_from_private_key(&fixed::<32>(b("priv"))?)), - "ecdsaDecompress" => hex::encode(ecdsa_decompress_public_key(&fixed::<33>(b("pub"))?)), - "ecdsaCompress" => hex::encode(ecdsa_compress_public_key(&fixed::<65>(b("pub"))?)), - "ecdsaSign" => hex::encode(ecdsa_sign(&fixed::<32>(b("priv"))?, b("msg"))), - "ecdsaVerify" => if ecdsa_verify(&fixed::<33>(b("pub"))?, &fixed::<64>(b("sig"))?, b("msg")) { "1".into() } else { "0".into() }, - "schnorrPub" => hex::encode(schnorr_public_key_from_private_key(&fixed::<32>(b("priv"))?)), - "schnorrSignAux" => hex::encode(schnorr_sign_with_aux_rand(&fixed::<32>(b("priv"))?, b("msg"), &fixed::<32>(b("aux"))?)), - "schnorrSignRng" => { let mut rng = SeededRandomNumberGenerator::new(seed(&r["seed"])); hex::encode(schnorr_sign_using(&fixed::<32>(b("priv"))?, b("msg"), &mut rng)) } - "schnorrVerify" => if schnorr_verify(&fixed::<32>(b("pub"))?, &fixed::<64>(b("sig"))?, b("msg")) { "1".into() } else { "0".into() }, - "ed25519Pub" => hex::encode(ed25519_public_key_from_private_key(&fixed::<32>(b("priv"))?)), - "ed25519Sign" => hex::encode(ed25519_sign(&fixed::<32>(b("priv"))?, &b("msg"))), - "ed25519Verify" => if ed25519_verify(&fixed::<32>(b("pub"))?, &b("msg"), &fixed::<64>(b("sig"))?) { "1".into() } else { "0".into() }, - "newPriv" => { - let mut rng = SeededRandomNumberGenerator::new(seed(&r["seed"])); - match r["alg"].as_str().unwrap() { + res.ok().map(hex::encode) + }) + } + "x25519Pub" => { let priv_ = fixed::<32>(b("priv"))?; attempt(move || Some(hex::encode(x25519_public_key_from_private_key(&priv_)))) } + // The reference's one error value on this path, rendered `throw:|` and compared verbatim. + "x25519Shared" => { + let (priv_, pub_) = (fixed::<32>(b("priv"))?, fixed::<32>(b("pub"))?); + attempt(move || Some(match try_x25519_shared_key(&priv_, &pub_) { + Ok(key) => hex::encode(key), + Err(e) => format!("throw:NonContributoryKey|{e}"), + })) + } + "deriveAgreement" => { let km = b("km"); attempt(move || Some(hex::encode(derive_agreement_private_key(km)))) } + "deriveSigning" => { let km = b("km"); attempt(move || Some(hex::encode(derive_signing_private_key(km)))) } + "ecdsaDerive" => { let km = b("km"); attempt(move || Some(hex::encode(ecdsa_derive_private_key(km)))) } + "ecdsaPub" => { let priv_ = fixed::<32>(b("priv"))?; attempt(move || Some(hex::encode(ecdsa_public_key_from_private_key(&priv_)))) } + "ecdsaDecompress" => { let pub_ = fixed::<33>(b("pub"))?; attempt(move || Some(hex::encode(ecdsa_decompress_public_key(&pub_)))) } + "ecdsaCompress" => { let pub_ = fixed::<65>(b("pub"))?; attempt(move || Some(hex::encode(ecdsa_compress_public_key(&pub_)))) } + "ecdsaSign" => { let (priv_, msg) = (fixed::<32>(b("priv"))?, b("msg")); attempt(move || Some(hex::encode(ecdsa_sign(&priv_, msg)))) } + "ecdsaVerify" => { + let (pub_, sig, msg) = (fixed::<33>(b("pub"))?, fixed::<64>(b("sig"))?, b("msg")); + attempt(move || Some(if ecdsa_verify(&pub_, &sig, msg) { "1".into() } else { "0".into() })) + } + "schnorrPub" => { let priv_ = fixed::<32>(b("priv"))?; attempt(move || Some(hex::encode(schnorr_public_key_from_private_key(&priv_)))) } + "schnorrSignAux" => { + let (priv_, msg, aux) = (fixed::<32>(b("priv"))?, b("msg"), fixed::<32>(b("aux"))?); + attempt(move || Some(hex::encode(schnorr_sign_with_aux_rand(&priv_, msg, &aux)))) + } + "schnorrSignRng" => { + let (priv_, msg, s) = (fixed::<32>(b("priv"))?, b("msg"), seed(&r["seed"])); + attempt(move || { let mut rng = SeededRandomNumberGenerator::new(s); Some(hex::encode(schnorr_sign_using(&priv_, msg, &mut rng))) }) + } + "schnorrVerify" => { + let (pub_, sig, msg) = (fixed::<32>(b("pub"))?, fixed::<64>(b("sig"))?, b("msg")); + attempt(move || Some(if schnorr_verify(&pub_, &sig, msg) { "1".into() } else { "0".into() })) + } + "ed25519Pub" => { let priv_ = fixed::<32>(b("priv"))?; attempt(move || Some(hex::encode(ed25519_public_key_from_private_key(&priv_)))) } + "ed25519Sign" => { let (priv_, msg) = (fixed::<32>(b("priv"))?, b("msg")); attempt(move || Some(hex::encode(ed25519_sign(&priv_, &msg)))) } + "ed25519Verify" => { + let (pub_, sig, msg) = (fixed::<32>(b("pub"))?, fixed::<64>(b("sig"))?, b("msg")); + attempt(move || Some(if ed25519_verify(&pub_, &msg, &sig) { "1".into() } else { "0".into() })) + } + "newPriv" => { + let s = seed(&r["seed"]); + let alg = r["alg"].as_str().unwrap().to_string(); + attempt(move || { + let mut rng = SeededRandomNumberGenerator::new(s); + Some(match alg.as_str() { "ecdsa" => hex::encode(ecdsa_new_private_key_using(&mut rng)), "ed25519" => hex::encode(ed25519_new_private_key_using(&mut Bridge(&mut rng))), _ => hex::encode(x25519_new_private_key_using(&mut rng)), - } - } - other => panic!("unknown recipe kind {other}"), - }) - })).map_err(|_| ()); - match out { Ok(Some(s)) => s, _ => "throw".into() } + }) + }) + } + other => panic!("unknown recipe kind {other}"), + }) } -/// Recipes with no reference analog (RUST_DIVERGENCES.md §2): the raw ChaCha20 -/// keystream. Classified before comparison, whatever the outcome. -fn js_only(r: &serde_json::Value) -> bool { r["k"].as_str().unwrap() == "chacha20" } - -fn expected_divergence(r: &serde_json::Value, got: &str, want: &str) -> Option<&'static str> { - // Only the reviewed recipes may use an exception; new cases fail closed. - static EXPECTED: std::sync::LazyLock = std::sync::LazyLock::new(|| - serde_json::from_str(include_str!("../expected-divergences.json")).unwrap()); - let entry = EXPECTED.as_array()?.iter().find(|entry| entry["recipe"] == *r)?; - match entry["id"].as_str()? { - "D2" if want == "throw" && got == hex::encode(hkdf_hmac_sha256([0u8; 32], b"agreement", 32)) => Some("D2"), - "D3" if got == "throw" && want == "0" => Some("D3"), - "D4" if got != "throw" && want == "throw" => Some("D4"), - "D5" if got != "throw" && want == "throw" => Some("D5"), - _ => None, +/// The resolved `bc-crypto` source, from this harness's Cargo.lock: the +/// registry version, or the `[patch.crates-io]` path with its git HEAD and a +/// dirty flag (the reference is a working tree until the release that +/// contains its edits ships). +fn provenance() -> String { + let dir = env!("CARGO_MANIFEST_DIR"); + let lock = std::fs::read_to_string(format!("{dir}/Cargo.lock")).unwrap_or_default(); + let mut version = String::from("?"); + let mut source: Option = None; + let mut in_pkg = false; + for line in lock.lines() { + let line = line.trim(); + if line == "[[package]]" { in_pkg = false; continue; } + if line == "name = \"bc-crypto\"" { in_pkg = true; continue; } + if in_pkg { + if let Some(v) = line.strip_prefix("version = ") { version = v.trim_matches('"').to_string(); } + if let Some(s) = line.strip_prefix("source = ") { source = Some(s.trim_matches('"').to_string()); } + if line.starts_with("dependencies") || line.starts_with("checksum") { in_pkg = false; } + } } + if let Some(s) = source { return format!("bc-crypto {version} ({s})"); } + let manifest = std::fs::read_to_string(format!("{dir}/Cargo.toml")).unwrap_or_default(); + let path = manifest + .lines() + .find(|l| l.trim_start().starts_with("bc-crypto = { path = ")) + .and_then(|l| l.split('"').nth(1)) + .map(|p| format!("{dir}/{p}")) + .unwrap_or_else(|| "?".into()); + let git = |args: &[&str]| { + std::process::Command::new("git").arg("-C").arg(&path).args(args).output().ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + }; + let head = git(&["rev-parse", "--short", "HEAD"]).unwrap_or_else(|| "?".into()); + let dirty = git(&["status", "--porcelain", "--untracked-files=all"]).map(|s| !s.is_empty()).unwrap_or(false); + let canonical = std::fs::canonicalize(&path).map(|p| p.display().to_string()).unwrap_or(path); + format!("bc-crypto {version} patched from {canonical} (HEAD {head}{})", if dirty { ", dirty" } else { "" }) } fn main() { std::panic::set_hook(Box::new(|_| {})); - let strict = std::env::args().any(|arg| arg == "--strict"); - let path = std::env::args().skip(1).find(|arg| arg != "--strict").expect("path"); + let path = std::env::args().nth(1).expect("path to a vectors file"); let file: File = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!(file.count, file.vectors.len()); - let (mut ok, mut expected, mut mismatch) = (0, 0, 0); + eprintln!("reference: {}", provenance()); + let (mut ok, mut js, mut mismatch) = (0, 0, 0); + let mut classes = std::collections::BTreeMap::<&str, usize>::new(); for v in &file.vectors { - if js_only(&v.recipe) { expected += 1; eprintln!("js-only [J1] {}", v.recipe); continue; } - let got = run(&v.recipe); - let want = norm(&v.expect); + let (got, want) = match run(&v.recipe) { + Err(class) => { + js += 1; + *classes.entry(class).or_default() += 1; + eprintln!("js-only [{class}] {}", v.recipe); + continue; + } + Ok(Outcome::Throw) => ("throw".to_string(), norm(&v.expect)), + // A value, or the error value of `x25519Shared`: compared verbatim. + Ok(Outcome::Value(s)) => (s, v.expect.clone()), + }; if got == want { ok += 1; continue; } - if let Some(id) = (!strict).then(|| expected_divergence(&v.recipe, &got, &want)).flatten() { expected += 1; eprintln!("expected-divergence [{id}] {}", v.recipe); continue; } mismatch += 1; eprintln!("MISMATCH {}\n rust: {}\n ts: {}", v.recipe, got, v.expect); } - println!("{} vectors - {ok} match, {expected} expected-divergence/js-only, {mismatch} MISMATCH", file.vectors.len()); + let tallies: Vec = classes.iter().map(|(c, n)| format!("{c} {n}")).collect(); + eprintln!("js-only classes: {}", if tallies.is_empty() { "none".into() } else { tallies.join(", ") }); + println!("{} vectors - {ok} match, {js} js-only, {mismatch} MISMATCH", file.vectors.len()); std::process::exit(if mismatch == 0 { 0 } else { 1 }); } diff --git a/tests/scrypt-core.test.ts b/tests/scrypt-core.test.ts new file mode 100644 index 0000000..135ea4b --- /dev/null +++ b/tests/scrypt-core.test.ts @@ -0,0 +1,67 @@ +/** + * The paged scrypt core equals noble (and so the reference) byte for byte, + * under forced tiny pages as well as the default page size, and reproduces + * the RFC 7914 §12 vectors. + */ +import fc from "fast-check"; +import { scrypt as nobleScrypt } from "@noble/hashes/scrypt.js"; +import { scryptCore } from "../src/scrypt-core"; + +const hex = (b: Uint8Array): string => Buffer.from(b).toString("hex"); +const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s); + +describe("scrypt core", () => { + it("equals noble for small parameter sets under one-block, three-block and default pages", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 10 }), + fc.integer({ min: 1, max: 8 }), + fc.integer({ min: 1, max: 3 }), + fc.integer({ min: 10, max: 64 }), + fc.uint8Array({ maxLength: 40 }), + fc.uint8Array({ maxLength: 40 }), + fc.constantFrom("one", "three", "default"), + (logN, r, p, dkLen, pw, salt, pages) => { + const N = 2 ** logN; + const pageBytes = pages === "one" ? 128 * r : pages === "three" ? 3 * 128 * r : undefined; + const want = hex(nobleScrypt(pw, salt, { N, r, p, dkLen, maxmem: 2 ** 40 })); + return hex(scryptCore(pw, salt, { N, r, p, dkLen, pageBytes })) === want; + }, + ), + { numRuns: 40 }, + ); + }); + it("RFC 7914 §12 vectors", () => { + expect(hex(scryptCore(utf8(""), utf8(""), { N: 16, r: 1, p: 1, dkLen: 64 }))).toBe( + "77d6576238657b203b19ca42c18a0497f16b4844e3074ae8dfdffa3fede21442fcd0069ded0948f8326a753a0fc81f17e8d3e0fb2e0d3628cf35e20c38d18906", + ); + expect( + hex(scryptCore(utf8("password"), utf8("NaCl"), { N: 1024, r: 8, p: 16, dkLen: 64 })), + ).toBe( + "fdbabe1c9d3472007856e7190d01e9fe7c6ad7cbc8237830e77376634b3731622eaf30d92e22a3886ff109279d9830dac727afb94a83ee6d8360cbdfa2cc0640", + ); + expect( + hex( + scryptCore(utf8("pleaseletmein"), utf8("SodiumChloride"), { + N: 16384, + r: 8, + p: 1, + dkLen: 64, + pageBytes: 3 * 128 * 8, + }), + ), + ).toBe( + "7023bdcb3afd7348461c06cd81fd38ebfda8fbba904f8e3ea9b543f6545da1f2d5432955613f0fcf62d49705242a9af9e61e85dc0d651e40dfcf017b45575887", + ); + }); + it("pages hold whole blocks and the last page may be short", () => { + // N = 5 blocks of 128·r bytes in pages of 2 blocks: the core must still + // address every block; a wrong page size would corrupt the mix. + const pw = utf8("pw"); + const salt = utf8("salt"); + const want = hex(nobleScrypt(pw, salt, { N: 8, r: 2, p: 3, dkLen: 32 })); + for (const pageBytes of [256, 512, 700, 1024, 4096]) { + expect(hex(scryptCore(pw, salt, { N: 8, r: 2, p: 3, dkLen: 32, pageBytes }))).toBe(want); + } + }); +}); diff --git a/tests/strict-boundaries.test.ts b/tests/strict-boundaries.test.ts index 4d5c4b2..cbc08a2 100644 --- a/tests/strict-boundaries.test.ts +++ b/tests/strict-boundaries.test.ts @@ -1,12 +1,86 @@ +import { ed25519 as noble } from "@noble/curves/ed25519.js"; import { ED25519_STRICT_FIXTURES } from "./corpus/ed25519-strict-fixtures"; +import { ED_NONCANONICAL_DECODABLE_K, ED_NONCANONICAL_UNDECODABLE_K } from "./corpus/corpus"; +import { SIGNED } from "./corpus/verify-fixtures"; import { ed25519, chacha20, scrypt, CryptoError } from "../src"; const bytes = (s: string) => Uint8Array.from(Buffer.from(s, "hex")); +const hex = (b: Uint8Array) => Buffer.from(b).toString("hex"); describe("Ed25519 uncofactored equation", () => { it.each(ED25519_STRICT_FIXTURES)("handles torsion: $valid ($signature)", (f) => { expect(ed25519.verify(bytes(f.publicKey), bytes(f.signature), bytes(f.message))).toBe(f.valid); }); }); + +/** + * The decoder boundary: dalek's `CompressedEdwardsY::decompress` reduces a + * non-canonical y (`y = p + k` reads as `y = k`) and decodes it when the + * reduced point has a square root; the tree decodes canonically and rejects + * every such encoding. noble's `zip215 = true` applies dalek's rule, so the + * two lists in the corpus pin exactly which encodings each decoder takes. + * The difference is unobservable through `verify`: both sides return + * `false` for every one of these keys and for the same encodings as R. + */ +describe("Ed25519 decoder boundary (non-canonical y = p + k)", () => { + const P = (1n << 255n) - 19n; + const enc = (y: bigint, sign: boolean): Uint8Array => { + const b = Uint8Array.from({ length: 32 }, (_, i) => Number((y >> BigInt(8 * i)) & 0xffn)); + if (sign) b[31] |= 0x80; + return b; + }; + const decodes = (b: Uint8Array, zip215: boolean): boolean => { + try { + noble.Point.fromBytes(b, zip215); + return true; + } catch { + return false; + } + }; + /** The 40 encodings: y = p + k for k = 0..18 with both sign bits, y = 1 and y = p − 1 with the sign bit. */ + const forty: Uint8Array[] = []; + for (let k = 0; k <= 18; k++) + for (const sign of [false, true]) forty.push(enc(P + BigInt(k), sign)); + forty.push(enc(1n, true), enc(P - 1n, true)); + const fixture = SIGNED.find((t) => t.scheme === "ed25519"); + if (fixture === undefined) throw new Error("no ed25519 fixture"); + const pub = bytes(fixture.pub); + const sig = bytes(fixture.sig); + const msg = bytes(fixture.msg); + + it("dalek's rule decodes exactly the k with a square root; canonical decoding takes none of the 40", () => { + expect( + [...ED_NONCANONICAL_DECODABLE_K, ...ED_NONCANONICAL_UNDECODABLE_K].sort((a, b) => a - b), + ).toEqual(Array.from({ length: 19 }, (_, k) => k)); + let dalekRejects = 0; + for (let k = 0; k <= 18; k++) { + for (const sign of [false, true]) { + const e = enc(P + BigInt(k), sign); + expect(decodes(e, true)).toBe(ED_NONCANONICAL_DECODABLE_K.includes(k)); + if (!decodes(e, true)) dalekRejects++; + } + } + expect(dalekRejects).toBe(14); + expect(forty.filter((e) => !decodes(e, false))).toHaveLength(40); + expect(forty.filter((e) => decodes(e, true))).toHaveLength(26); + }); + it("the four small-order re-encodings under dalek's rule", () => { + const re = (h: string): string => hex(noble.Point.fromBytes(bytes(h), true).toBytes()); + expect(re("ee" + "ff".repeat(31))).toBe("01" + "00".repeat(31)); + expect(re("01" + "00".repeat(30) + "80")).toBe("01" + "00".repeat(31)); + expect(re("ec" + "ff".repeat(31))).toBe("ec" + "ff".repeat(30) + "7f"); + expect(re("ed" + "ff".repeat(31))).toBe("00".repeat(31) + "80"); + }); + it("verify is false for every one of the 40 as A and as R, with the fixture's honest signature", () => { + expect(ed25519.verify(pub, sig, msg)).toBe(true); + for (const e of forty) { + expect(ed25519.verify(e, sig, msg)).toBe(false); + const r = new Uint8Array(64); + r.set(e, 0); + r.set(sig.subarray(32), 32); + expect(ed25519.verify(pub, r, msg)).toBe(false); + } + }); +}); describe("backend boundaries", () => { const key = new Uint8Array(32), nonce = new Uint8Array(12); diff --git a/tests/vectors/heavy.json b/tests/vectors/heavy.json new file mode 100644 index 0000000..6e8938b --- /dev/null +++ b/tests/vectors/heavy.json @@ -0,0 +1,22 @@ +{ + "count": 1, + "vectors": [ + { + "recipe": { + "k": "scrypt", + "pw": { + "text": "pw" + }, + "salt": { + "cycle": 16, + "start": 80 + }, + "len": 32, + "n": 22, + "r": 9, + "p": 1 + }, + "expect": "1fc13793fa6a921c6fa806a0856afdebfb744877768dc12a330351129593d167" + } + ] +} diff --git a/tests/vectors/recipes.ts b/tests/vectors/recipes.ts index b4ed6a7..9521a18 100644 --- a/tests/vectors/recipes.ts +++ b/tests/vectors/recipes.ts @@ -4,7 +4,8 @@ * Every argument is named, so an API refactor changes only the adapters. * Byte inputs are hex or cycling-byte descriptions; the * seeded RNG is a four-word seed. Outcomes are hex strings, booleans as - * "1"/"0", or `throw:` (message-independent). + * "1"/"0", or `throw:` (message-independent), except that an + * `x25519Shared` `CryptoError` renders as `throw:|`. * * Recipe semantics are FROZEN. */ @@ -26,7 +27,7 @@ export type Recipe = /** `n` is log2(N), the exponent, as the underlying API takes it. */ | { k: "scrypt"; pw: Bytes; salt: Bytes; len: number; n?: number; r?: number; p?: number } | { k: "argon2id"; pw: Bytes; salt: Bytes; len: number } - /** Raw ChaCha20 keystream — JS-only (report A6); `counter` is the initial block counter. */ + /** Raw ChaCha20 keystream — no `bc-crypto` function (js-only J1); `counter` is the initial block counter. */ | { k: "chacha20"; key: Bytes; nonce: Bytes; d: Bytes; counter?: number } | { k: "aeadEncrypt"; pt: Bytes; key: Bytes; nonce: Bytes; aad?: Bytes } | { k: "aeadDecrypt"; ct: Bytes; key: Bytes; nonce: Bytes; aad?: Bytes } @@ -203,6 +204,12 @@ export function materialize(api: VectorApi, r: Recipe): string { return bytesToHex(api.newPriv(r.alg, api.makeRng(seedOf(r.seed)))); } } catch (e) { + // `x25519Shared` failures carry the error value: the reference's + // `try_x25519_shared_key` returns `Err(NonContributoryKey)` with a Display + // text, and the harness compares code and message for this kind. + if (r.k === "x25519Shared" && e instanceof Error && e.name === "CryptoError" && "code" in e) { + return `throw:${String(e.code)}|${e.message}`; + } return `throw:${e instanceof Error ? e.name : "Error"}`; } } diff --git a/tests/vectors/vectors.json b/tests/vectors/vectors.json index 7cf98b5..75c3636 100644 --- a/tests/vectors/vectors.json +++ b/tests/vectors/vectors.json @@ -1,5 +1,5 @@ { - "count": 679, + "count": 807, "vectors": [ { "recipe": { @@ -9705,6 +9705,1450 @@ }, "expect": "0" }, + { + "recipe": { + "k": "ecdsaVerify", + "pub": { + "hex": "0284bf7562262bbd6940085748f3be6afa52ae317155181ece31b66351ccffa4b0" + }, + "sig": { + "hex": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ecdsaVerify", + "pub": { + "hex": "0284bf7562262bbd6940085748f3be6afa52ae317155181ece31b66351ccffa4b0" + }, + "sig": { + "hex": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd03641410000000000000000000000000000000000000000000000000000000000000001" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ecdsaVerify", + "pub": { + "hex": "0284bf7562262bbd6940085748f3be6afa52ae317155181ece31b66351ccffa4b0" + }, + "sig": { + "hex": "0000000000000000000000000000000000000000000000000000000000000001fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ecdsaVerify", + "pub": { + "hex": "0284bf7562262bbd6940085748f3be6afa52ae317155181ece31b66351ccffa4b0" + }, + "sig": { + "hex": "00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ecdsaVerify", + "pub": { + "hex": "02fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc30" + }, + "sig": { + "hex": "7817c1ef7da0216b52440f59d066ddcf82fd78754ec07b4b6fc05f4b7ad3482b7a4041c2e0885d313a6bd751aa210818a66cfeda154095a98b5f06dcf4868967" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "efffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "efffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f4ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f4ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f5ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f5ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f8ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f8ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f9ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f9ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "faffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "faffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "feffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "feffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f1ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f1ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f2ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f2ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f3ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f3ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f6ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f6ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "fbffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "fbffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "fcffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "fcffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "fdffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "fdffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "0100000000000000000000000000000000000000000000000000000000000080" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "sig": { + "hex": "6cf286acd41b5bc4c0b038d6b2479805be207b853245738ea7e9acbe4d3c56d9383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "efffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "efffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f1ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f1ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f2ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f2ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f3ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f3ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f4ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f4ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f5ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f5ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f6ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f6ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f8ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f8ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f9ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "f9ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "faffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "faffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "fbffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "fbffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "fcffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "fcffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "fdffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "fdffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "feffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "feffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "0100000000000000000000000000000000000000000000000000000000000080383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "ed25519Verify", + "pub": { + "hex": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664" + }, + "sig": { + "hex": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff383ac725021660759eca2817744ad0604b1d5e68d1b742d0abcf0e14adeb0601" + }, + "msg": { + "hex": "07" + } + }, + "expect": "0" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 1 + }, + "pub": { + "hex": "0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 1 + }, + "pub": { + "hex": "0100000000000000000000000000000000000000000000000000000000000000" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 1 + }, + "pub": { + "hex": "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 1 + }, + "pub": { + "hex": "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 1 + }, + "pub": { + "hex": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 1 + }, + "pub": { + "hex": "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 1 + }, + "pub": { + "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 1 + }, + "pub": { + "hex": "0000000000000000000000000000000000000000000000000000000000000080" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 1 + }, + "pub": { + "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 1 + }, + "pub": { + "hex": "0100000000000000000000000000000000000000000000000000000000000080" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 1 + }, + "pub": { + "hex": "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 1 + }, + "pub": { + "hex": "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 1 + }, + "pub": { + "hex": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, { "recipe": { "k": "x25519Shared", @@ -9712,37 +11156,193 @@ "cycle": 32, "start": 1 }, + "pub": { + "hex": "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 153 + }, "pub": { "hex": "0000000000000000000000000000000000000000000000000000000000000000" } }, - "expect": "throw:CryptoError" + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 153 + }, + "pub": { + "hex": "0100000000000000000000000000000000000000000000000000000000000000" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 153 + }, + "pub": { + "hex": "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 153 + }, + "pub": { + "hex": "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 153 + }, + "pub": { + "hex": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 153 + }, + "pub": { + "hex": "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 153 + }, + "pub": { + "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 153 + }, + "pub": { + "hex": "0000000000000000000000000000000000000000000000000000000000000080" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 153 + }, + "pub": { + "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 153 + }, + "pub": { + "hex": "0100000000000000000000000000000000000000000000000000000000000080" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 153 + }, + "pub": { + "hex": "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + }, + { + "recipe": { + "k": "x25519Shared", + "priv": { + "cycle": 32, + "start": 153 + }, + "pub": { + "hex": "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7" + } + }, + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" }, { "recipe": { "k": "x25519Shared", "priv": { "cycle": 32, - "start": 1 + "start": 153 }, "pub": { - "hex": "0100000000000000000000000000000000000000000000000000000000000000" + "hex": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } }, - "expect": "throw:CryptoError" + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" }, { "recipe": { "k": "x25519Shared", "priv": { "cycle": 32, - "start": 1 + "start": 153 }, "pub": { - "hex": "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800" + "hex": "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } }, - "expect": "throw:CryptoError" + "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" }, { "recipe": { @@ -9752,76 +11352,95 @@ "start": 1 }, "pub": { - "hex": "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157" + "hex": "efffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" } }, - "expect": "throw:CryptoError" + "expect": "8df9ca6b3b1ee1979975a3a79dc75ddf71aeb4b027a5f9e77aa2fdb365f5a95a" }, { "recipe": { "k": "x25519Shared", "priv": { "cycle": 32, - "start": 1 + "start": 153 }, "pub": { - "hex": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + "hex": "efffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" } }, - "expect": "throw:CryptoError" + "expect": "a68194e994abd0916af111265577eced74d3e1bf46f1bccf127931bb14ffd6ee" }, { "recipe": { - "k": "x25519Shared", - "priv": { - "cycle": 32, - "start": 1 - }, + "k": "ecdsaCompress", "pub": { - "hex": "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + "hex": "0679be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8" + } + }, + "expect": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + }, + { + "recipe": { + "k": "ecdsaCompress", + "pub": { + "hex": "0779be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8" } }, "expect": "throw:CryptoError" }, { "recipe": { - "k": "x25519Shared", - "priv": { - "cycle": 32, - "start": 1 - }, + "k": "ecdsaCompress", "pub": { - "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" + "hex": "0779be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798b7c52588d95c3b9aa25b0403f1eef75702e84bb7597aabe663b82f6f04ef2777" + } + }, + "expect": "0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + }, + { + "recipe": { + "k": "ecdsaCompress", + "pub": { + "hex": "0679be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798b7c52588d95c3b9aa25b0403f1eef75702e84bb7597aabe663b82f6f04ef2777" } }, "expect": "throw:CryptoError" }, { "recipe": { - "k": "x25519Shared", - "priv": { - "cycle": 32, - "start": 1 - }, + "k": "ecdsaCompress", "pub": { - "hex": "0000000000000000000000000000000000000000000000000000000000000080" + "hex": "0579be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8" } }, "expect": "throw:CryptoError" }, { "recipe": { - "k": "x25519Shared", - "priv": { - "cycle": 32, - "start": 1 - }, + "k": "ecdsaCompress", "pub": { - "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "hex": "06fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc30483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8" } }, "expect": "throw:CryptoError" }, + { + "recipe": { + "k": "scrypt", + "pw": { + "text": "pw" + }, + "salt": { + "cycle": 16, + "start": 80 + }, + "len": 32, + "n": 17, + "r": 64, + "p": 1 + }, + "expect": "88d8c7754d2123a4992c89a8748dc2b23c5af68cc545f85b0ea27d0ab4f686f3" + }, { "recipe": { "k": "ecdsaPub", @@ -10323,6 +11942,210 @@ "counter": 1 }, "expect": "105dd85955e2c399f053c842e5d893934bae41aac80afaab9605c57345ea1c1d75d5edc1907f4e1e536510fcbc6f701e1bf0aad5cdacea197fc03dc725df1602" + }, + { + "recipe": { + "k": "scrypt", + "pw": { + "text": "pw" + }, + "salt": { + "cycle": 16, + "start": 80 + }, + "len": 32, + "n": 256, + "r": 8, + "p": 1 + }, + "expect": "throw:CryptoError" + }, + { + "recipe": { + "k": "pbkdf2Sha256", + "pw": { + "text": "pw" + }, + "salt": { + "cycle": 16, + "start": 80 + }, + "iter": 4294967296, + "len": 32 + }, + "expect": "throw:CryptoError" + }, + { + "recipe": { + "k": "scrypt", + "pw": { + "text": "pw" + }, + "salt": { + "cycle": 16, + "start": 80 + }, + "len": 32, + "n": 4, + "r": 4294967297, + "p": 1 + }, + "expect": "throw:CryptoError" + }, + { + "recipe": { + "k": "ecdsaDecompress", + "pub": { + "hex": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + } + }, + "expect": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8" + }, + { + "recipe": { + "k": "ecdsaDecompress", + "pub": { + "hex": "0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + } + }, + "expect": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798b7c52588d95c3b9aa25b0403f1eef75702e84bb7597aabe663b82f6f04ef2777" + }, + { + "recipe": { + "k": "ecdsaDecompress", + "pub": { + "hex": "020000000000000000000000000000000000000000000000000000000000000001" + } + }, + "expect": "0400000000000000000000000000000000000000000000000000000000000000014218f20ae6c646b363db68605822fb14264ca8d2587fdd6fbc750d587e76a7ee" + }, + { + "recipe": { + "k": "ecdsaDecompress", + "pub": { + "hex": "02fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc30" + } + }, + "expect": "throw:CryptoError" + }, + { + "recipe": { + "k": "ecdsaCompress", + "pub": { + "hex": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8" + } + }, + "expect": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + }, + { + "recipe": { + "k": "ecdsaCompress", + "pub": { + "hex": "0400000000000000000000000000000000000000000000000000000000000000014218f20ae6c646b363db68605822fb14264ca8d2587fdd6fbc750d587e76a7ee" + } + }, + "expect": "020000000000000000000000000000000000000000000000000000000000000001" + }, + { + "recipe": { + "k": "ecdsaCompress", + "pub": { + "hex": "04fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc304218f20ae6c646b363db68605822fb14264ca8d2587fdd6fbc750d587e76a7ee" + } + }, + "expect": "throw:CryptoError" + }, + { + "recipe": { + "k": "aeadDecrypt", + "ct": { + "hex": "e19e646c4637d22fc5ef5b23ea7a2c99eb2a042ad2377566f86b65" + }, + "key": { + "cycle": 32, + "start": 0 + }, + "nonce": { + "cycle": 12, + "start": 0 + }, + "aad": { + "text": "ad" + } + }, + "expect": "68656c6c6f20776f726c64" + }, + { + "recipe": { + "k": "aeadDecrypt", + "ct": { + "hex": "e19e646c4637d22fc5ef5b23ea7a2c99eb2a042ad2377566f86b65" + }, + "key": { + "cycle": 32, + "start": 0 + }, + "nonce": { + "cycle": 12, + "start": 0 + } + }, + "expect": "throw:CryptoError" + }, + { + "recipe": { + "k": "aeadDecrypt", + "ct": { + "hex": "e19e646c4637d22fc5ef5b18f74a8dd13d3bbce0be3ebb508fb959" + }, + "key": { + "cycle": 32, + "start": 0 + }, + "nonce": { + "cycle": 12, + "start": 0 + } + }, + "expect": "68656c6c6f20776f726c64" + }, + { + "recipe": { + "k": "aeadDecrypt", + "ct": { + "hex": "295a498b8841a1c5f55d4d606f731159" + }, + "key": { + "cycle": 32, + "start": 0 + }, + "nonce": { + "cycle": 12, + "start": 0 + } + }, + "expect": "" + }, + { + "recipe": { + "k": "aeadDecrypt", + "ct": { + "hex": "295a498b8841a1c5f55d4d606f731159" + }, + "key": { + "cycle": 32, + "start": 0 + }, + "nonce": { + "cycle": 12, + "start": 0 + }, + "aad": { + "cycle": 0, + "start": 0 + } + }, + "expect": "" } ] } From 28eb7635def93b1ccd6345dea797f3cacd93141a Mon Sep 17 00:00:00 2001 From: Leonardo Custodio Date: Mon, 14 Sep 2026 16:51:14 -0300 Subject: [PATCH 2/8] A few docs improvements --- CHANGELOG.md | 33 +++------------------------------ MIGRATION.md | 14 -------------- eslint.config.mjs | 2 +- 3 files changed, 4 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d870dd..ebbf6ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,22 +69,9 @@ now patches in; against it there is no exception list. Requires the paged core against noble under one-block, three-block and default pages plus the RFC 7914 vectors. -### Corrections to the 1.0.0-beta.2 entry +## 1.0.0-beta.2 - 2026-09-12 -The four "behavioral differences" that entry kept were differences from the -released `bc-crypto` 0.14.0 crate, not from the reference tree, which already -returned `false` for an unparseable verify input, rejected a low-order X25519 -peer and asserted positive KDF costs. Against the reference, malformed verify -inputs match (`false` on both sides), scrypt logN 0 and PBKDF2 iterations 0 -are the usual panic → `InvalidParameter` mapping, and the low-order X25519 -peer is matched by code and message as of this release. - -## 1.0.0-beta.2 - -Fixes Ed25519 verification and adds reference parameter validation. Four -behavioral differences remained against the published Rust `bc-crypto` -0.14.0 crate (see the corrections above). Ordinary signing and derivation -outputs are unchanged. +Fixes Ed25519 verification and adds reference parameter validation. ### Fixed @@ -139,20 +126,6 @@ outputs are unchanged. reports 649 matches and 30 expected-divergence/JS-only cases, with no unexpected mismatches. -### Internal - -- `RUST_DIVERGENCES.md` now records exactly four divergences, each kept - on purpose: low-order X25519 peer keys (rejected here, a predictable key - there), `verify` on malformed encodings (`false` here, a panic there - - BIP-340 and RFC 8032 specify `false`), scrypt `logN: 0`, and PBKDF2 - `iterations: 0` (the reference's crate treats it as one). -- The Rust harness allows only exact reviewed divergence recipes and checks - the expected HKDF-of-zero output for low-order X25519 peers. Its `--strict` - mode disables behavioral exceptions for candidate reference versions. -- Expanded the golden corpus to 679 vectors, including Ed25519 torsion, - PBKDF2 zero-cost/empty-output, X25519 encodings, scrypt parameter rules, - empty HKDF salt, and non-canonical Ed25519 scalar cases. - -## 1.0.0-beta.1 +## 1.0.0-beta.1 - 2026-09-12 Initial beta implementation. diff --git a/MIGRATION.md b/MIGRATION.md index 9bccc9c..cf7d131 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -161,17 +161,3 @@ that lacks a method the draw calls, propagates unwrapped: + const key = ecdsa.generatePrivateKey({ rng }); + const sig = schnorr.sign(key, msg, { rng }); ``` - -## 6. Node and TypeScript floors - -Node **22.12** and TypeScript **5.7** (for `Uint8Array` return -types). The IIFE / global-script build is gone; use the ESM or CJS entry. - -## 7. What did not change - -- The HKDF salts (`"agreement"`, `"signing"`), - the scrypt defaults (log₂N 17, r 8, p 1) and the Argon2id defaults - (t 2, m 19456 KiB, p 1). -- ECDSA signs `doubleSha256(message)` deterministically (RFC 6979) and - returns the 64-byte compact form. -- Schnorr is BIP-340 with 32 bytes of aux-rand. diff --git a/eslint.config.mjs b/eslint.config.mjs index 66785c2..c6519b6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -149,7 +149,7 @@ export default [ // Executable entry points inside a library: these run in Node.js and are // expected to use process, console and friends. { - files: ["src/bin/**/*.ts", "src/cmd/**/*.ts", "src/cli.ts", "src/main.ts"], + files: ["src/main.ts"], languageOptions: { parser: tsParser, parserOptions: { From 68d4a5ec622eee351feebfadf13d4e56017aa4b0 Mon Sep 17 00:00:00 2001 From: Leonardo Custodio Date: Mon, 14 Sep 2026 16:59:15 -0300 Subject: [PATCH 3/8] Some comment changes --- .gitignore | 2 +- eslint.config.mjs | 4 +- scripts/annotate-isolated-declarations.ts | 6 +-- scripts/api-report.ts | 5 +- scripts/build-baseline.ts | 12 ++--- scripts/check-deps.ts | 12 ++--- scripts/check-heavy-vectors.ts | 4 +- scripts/generate-vectors.ts | 4 +- scripts/generate-verify-fixtures.ts | 2 +- tests/__snapshots__/golden.test.ts.snap | 56 +++++++++---------- tests/baseline/README.md | 8 +-- tests/corpus/corpus.ts | 13 +++-- tests/corpus/ed25519-strict-fixtures.ts | 6 ++- tests/corpus/verify-fixtures.ts | 2 +- tests/crypto.property.test.ts | 15 +++--- tests/crypto.test.ts | 53 ++++++------------ tests/differential.test.ts | 66 ++++++++++------------- tests/golden-vectors.test.ts | 4 +- tests/golden.test.ts | 19 ++++--- tests/heavy-vectors.test.ts | 4 +- tests/kdf-backend.test.ts | 2 +- tests/scrypt-core.test.ts | 5 +- tests/vectors/recipes.ts | 21 +++----- 23 files changed, 145 insertions(+), 180 deletions(-) diff --git a/.gitignore b/.gitignore index 4d59281..4660bc6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ node_modules # Build artifacts dist coverage -# Generated TypeDoc output; retain handwritten architecture notes. +# Generated TypeDoc output /docs/* *.tsbuildinfo diff --git a/eslint.config.mjs b/eslint.config.mjs index c6519b6..1d0ea44 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -132,8 +132,8 @@ export default [ "error", { considerDefaultExhaustiveForUnions: true }, ], - // A value and a type may intentionally share a name (e.g. `MajorType`, - // `Cbor`, `EdgeType`); tsc already rejects genuine illegal redeclarations. + // A value and a type may intentionally share a name; tsc already rejects + // genuine illegal redeclarations. "no-redeclare": "off", "@typescript-eslint/no-redeclare": "off", "@typescript-eslint/no-namespace": "off", diff --git a/scripts/annotate-isolated-declarations.ts b/scripts/annotate-isolated-declarations.ts index 0ff7476..09e52b5 100644 --- a/scripts/annotate-isolated-declarations.ts +++ b/scripts/annotate-isolated-declarations.ts @@ -3,9 +3,9 @@ * * bun scripts/annotate-isolated-declarations.ts [--dry-run] * - * The reference tsconfig enables `isolatedDeclarations`, which the monorepo did - * not. It requires an explicit type on any exported declaration whose type a - * single-file emit cannot infer. Most of those are one shape: + * `tsconfig.json` enables `isolatedDeclarations`, which requires an explicit + * type on any exported declaration whose type a single-file emit cannot + * infer. Most of those are one shape: * * export const FOO = new Bar(...) -> export const FOO: Bar = new Bar(...) * static readonly SIZE = OTHER_CONST -> static readonly SIZE: number = ... diff --git a/scripts/api-report.ts b/scripts/api-report.ts index 0914c41..51b63e2 100644 --- a/scripts/api-report.ts +++ b/scripts/api-report.ts @@ -7,9 +7,8 @@ * * api-extractor requires a `.d.ts` entry point; tsdown emits `.d.mts`, so a * transient copy is made inside dist/ first. The committed report - * (api/.api.md) is the reviewable record of the public surface - - * "API deliberately unstable, wire frozen" is enforced by making every - * surface change a visible diff here and in api/index.d.mts. + * (api/.api.md) is the reviewable record of the public surface: every + * surface change is a visible diff here and in api/index.d.mts. */ import { copyFileSync, existsSync, readFileSync, rmSync } from "node:fs"; diff --git a/scripts/build-baseline.ts b/scripts/build-baseline.ts index 5adf12b..1e76dbf 100644 --- a/scripts/build-baseline.ts +++ b/scripts/build-baseline.ts @@ -10,8 +10,8 @@ * Bundles src/index.ts as a single ESM file with every @blockchaincommons * sibling INLINED, resolving each sibling to ITS frozen baseline bundle * (..//tests/baseline/-baseline.mjs) when one exists, so the - * baseline keeps the pre-redesign behaviour of its dependencies even after - * they change. Writes tests/baseline/-baseline.mjs, the .d.mts API + * baseline keeps the behaviour its dependencies had at that commit even + * after they change. Writes tests/baseline/-baseline.mjs, the .d.mts API * snapshot, and README.md with the commit and sha256 pinned. */ import { build } from "tsdown"; @@ -45,8 +45,8 @@ for (const dir of readdirSync(parent)) { const depPkgPath = join(parent, dir, "package.json"); if (!existsSync(depPkgPath)) continue; const depName = JSON.parse(readFileSync(depPkgPath, "utf8")).name; - // The canonical dcbor is a published, stable dependency: never alias it to - // its own (much older) pre-redesign baseline. + // dcbor is a published, stable dependency: never alias it to its own + // (much older) baseline bundle. if (depName !== pkg.name && depName !== "@blockchaincommons/dcbor") alias[depName] = join(bl, f); } @@ -79,10 +79,10 @@ writeFileSync( `# Frozen baseline build \`${short}-baseline.mjs\` is the self-contained ESM bundle of \`${pkg.name}\` built from -commit \`${commit}\`, the pre-redesign wire-format reference. Sibling +commit \`${commit}\`, the \`@bcts/${short}\` wire-format reference. Sibling \`@blockchaincommons/*\` packages are INLINED from their own frozen baseline bundles (${Object.keys(alias).length ? Object.keys(alias).join(", ") : "none"}), so this bundle keeps the -pre-redesign behaviour of its dependencies after they change. +behaviour its dependencies had at that commit. \`${short}-baseline.d.mts\` is the public surface at that commit. \`tests/differential.test.ts\` runs every corpus recipe through this bundle and diff --git a/scripts/check-deps.ts b/scripts/check-deps.ts index 6dbd1b0..95c9ad8 100644 --- a/scripts/check-deps.ts +++ b/scripts/check-deps.ts @@ -1,12 +1,12 @@ /** * Dependency hygiene gate. * - * bun scripts/check-deps.ts # no monorepo leftovers may survive + * bun scripts/check-deps.ts # no @bcts/* or workspace: dependency * bun scripts/check-deps.ts --zero # additionally: zero runtime deps * - * The first check is universal: an extracted repository must never ship a - * `@bcts/*` dependency or a `workspace:` protocol range, both of which are - * unresolvable outside the bcts monorepo. The `--zero` form additionally + * The first check is universal: a published package must never depend on + * `@bcts/*` or use a `workspace:` protocol range, neither of which resolves + * from the npm registry. The `--zero` form additionally * enforces the zero-runtime-dependency policy for the packages that hold it. */ import { readFileSync } from "node:fs"; @@ -23,7 +23,7 @@ let failed = false; for (const group of groups) { for (const [name, range] of Object.entries(pkg[group] ?? {})) { if (name.startsWith("@bcts/")) { - console.error(`${group}: "${name}" is a monorepo package and cannot be published.`); + console.error(`${group}: "${name}" is a @bcts package and cannot be published.`); failed = true; } if (typeof range === "string" && range.startsWith("workspace:")) { @@ -42,4 +42,4 @@ if (zero) { } if (failed) process.exit(1); -console.log(zero ? "zero runtime dependencies" : "no monorepo dependencies"); +console.log(zero ? "zero runtime dependencies" : "no @bcts or workspace: dependencies"); diff --git a/scripts/check-heavy-vectors.ts b/scripts/check-heavy-vectors.ts index ab9bd5b..0aa6038 100644 --- a/scripts/check-heavy-vectors.ts +++ b/scripts/check-heavy-vectors.ts @@ -14,14 +14,14 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import * as src from "../src/index.ts"; import * as rand from "@blockchaincommons/rand"; -import { materialize, redesignedAdapterFor, type Recipe } from "../tests/vectors/recipes.ts"; +import { materialize, currentAdapterFor, type Recipe } from "../tests/vectors/recipes.ts"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const { count, vectors } = JSON.parse( readFileSync(join(root, "tests/vectors/heavy.json"), "utf8"), ) as { count: number; vectors: { recipe: Recipe; expect: string }[] }; if (vectors.length !== count) throw new Error("heavy.json count does not match its vectors"); -const api = redesignedAdapterFor(src, rand); +const api = currentAdapterFor(src, rand); let mismatch = 0; for (const v of vectors) { diff --git a/scripts/generate-vectors.ts b/scripts/generate-vectors.ts index 716092a..eb149e5 100644 --- a/scripts/generate-vectors.ts +++ b/scripts/generate-vectors.ts @@ -12,11 +12,11 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import * as src from "../src/index.ts"; import * as rand from "@blockchaincommons/rand"; -import { materialize, redesignedAdapterFor } from "../tests/vectors/recipes.ts"; +import { materialize, currentAdapterFor } from "../tests/vectors/recipes.ts"; import { allRecipes, goldenRecipes, heavyRecipes } from "../tests/corpus/corpus.ts"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const api = redesignedAdapterFor(src, rand); +const api = currentAdapterFor(src, rand); const args = process.argv.slice(2); const [recipes, out, label] = (() => { diff --git a/scripts/generate-verify-fixtures.ts b/scripts/generate-verify-fixtures.ts index b666f68..3509c89 100644 --- a/scripts/generate-verify-fixtures.ts +++ b/scripts/generate-verify-fixtures.ts @@ -49,7 +49,7 @@ writeFileSync( * GENERATED by scripts/generate-verify-fixtures.ts from the working tree; * the signing inputs (cycling-byte private keys 0x01…, 0x42…, the scalar 1; * messages cyc(1, 7) and cyc(32, 7); Schnorr aux-rand cyc(32, 0x77)) are - * golden vectors already proven against the reference. FROZEN. + * golden vectors replayed against the reference. */ export interface SignedTriple { scheme: "ecdsa" | "schnorr" | "ed25519"; diff --git a/tests/__snapshots__/golden.test.ts.snap b/tests/__snapshots__/golden.test.ts.snap index cf53aa1..45b96a6 100644 --- a/tests/__snapshots__/golden.test.ts.snap +++ b/tests/__snapshots__/golden.test.ts.snap @@ -1,32 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`golden: freeze additions (B1–B5) > B1: ed25519.verify on a small-order key / non-canonical encodings (false, as verify_strict) 1`] = ` -[ - "false", - "false", - "false", -] -`; - -exports[`golden: freeze additions (B1–B5) > B2: x25519.sharedKey with a low-order public key (the reference's NonContributoryKey) 1`] = ` -[ - "throw:CryptoError:X25519 peer key produces an all-zero shared secret", - "throw:CryptoError:X25519 peer key produces an all-zero shared secret", -] -`; - -exports[`golden: freeze additions (B1–B5) > B3: verify on malformed keys and signatures of the right length (false on both sides) 1`] = ` -[ - "false", - "false", - "false", - "false", - "false", - "false", -] -`; - -exports[`golden: freeze additions (B1–B5) > B4: domain faults are reported as CryptoError 1`] = ` +exports[`golden: edge cases > domain faults are reported as CryptoError 1`] = ` { "argon2id dkLen 3": "throw:CryptoError:argon2id dkLen must be an integer in [4, 4294967295], got 3", "argon2id salt 4": "throw:CryptoError:argon2id salt must be at least 8 bytes, got 4", @@ -47,13 +21,39 @@ exports[`golden: freeze additions (B1–B5) > B4: domain faults are reported as } `; -exports[`golden: freeze additions (B1–B5) > B5: scrypt output length below the reference's opt bound (parameterised: throw; default: accepted) 1`] = ` +exports[`golden: edge cases > ed25519.verify on a small-order key / non-canonical encodings (false, as verify_strict) 1`] = ` +[ + "false", + "false", + "false", +] +`; + +exports[`golden: edge cases > scrypt output length below the reference's opt bound (parameterised: throw; default: accepted) 1`] = ` [ "throw:CryptoError:scrypt dkLen must be an integer in [10, 64], got 8", "8685bd02e2cada6a", ] `; +exports[`golden: edge cases > verify on malformed keys and signatures of the right length (false on both sides) 1`] = ` +[ + "false", + "false", + "false", + "false", + "false", + "false", +] +`; + +exports[`golden: edge cases > x25519.sharedKey with a low-order public key (the reference's NonContributoryKey) 1`] = ` +[ + "throw:CryptoError:X25519 peer key produces an all-zero shared secret", + "throw:CryptoError:X25519 peer key produces an all-zero shared secret", +] +`; + exports[`golden: hashes > hmac (1) 1`] = ` [ "a86ce6b2ab762e74d14bf0329723c70e6a409252ae7feb1b8108da3d782d9b66", diff --git a/tests/baseline/README.md b/tests/baseline/README.md index 9ad1139..2128861 100644 --- a/tests/baseline/README.md +++ b/tests/baseline/README.md @@ -1,10 +1,10 @@ # Frozen baseline build `crypto-baseline.mjs` is the self-contained ESM bundle of `@blockchaincommons/crypto` built from -commit `d2c99548ef30dfc0b763793ed236ffc9a0db8386`, the pre-redesign wire-format reference. Sibling +commit `d2c99548ef30dfc0b763793ed236ffc9a0db8386`, the `@bcts/crypto` wire-format reference. Sibling `@blockchaincommons/*` packages are INLINED from their own frozen baseline bundles (@blockchaincommons/rand, @blockchaincommons/dcbor), so this bundle keeps the -pre-redesign behaviour of its dependencies after they change. +behaviour its dependencies had at that commit. `crypto-baseline.d.mts` is the public surface at that commit. `tests/differential.test.ts` runs every corpus recipe through this bundle and @@ -16,8 +16,8 @@ Baseline sha256: d3a5a82546fd0424232ba32ea1c1bd485e08f35f3f241edc90c8476fb155965 `rand-baseline.mjs` / `rand-baseline.d.mts` are a vendored copy of `@blockchaincommons/rand`'s own frozen baseline (see its `tests/baseline/README.md`), -needed here because the crypto baseline's pre-redesign `*Using(rng)` functions -take the OLD rand interface (`SeededRandomNumberGenerator`, `randomData(n)`), +needed here because the crypto baseline's `*Using(rng)` functions +take the baseline rand interface (`SeededRandomNumberGenerator`, `randomData(n)`), not the current `SeededRng`/`fillBytes`. Baseline commit: e59bf7d1d244c08a6686d20f60a26d5a8ba3f26b diff --git a/tests/corpus/corpus.ts b/tests/corpus/corpus.ts index 295c551..9e3972d 100644 --- a/tests/corpus/corpus.ts +++ b/tests/corpus/corpus.ts @@ -234,14 +234,13 @@ const RFC8032: [string, string, string][] = [ "af82", ], ]; -/** Report B1: the identity point (small order), canonical and non-canonical (y = p + 1). */ +/** The identity point (small order), canonical and non-canonical (y = p + 1). */ const ED_IDENTITY = "01" + "00".repeat(31); const ED_IDENTITY_NONCANONICAL = "ee" + "ff".repeat(30) + "7f"; const ED_ZERO_S = "00".repeat(32); -/** Report B3: malformed keys and signatures of the right length. */ +/** Malformed keys and signatures of the right length. */ const FF32 = "ff".repeat(32); const FF64 = "ff".repeat(64); -/** Report B2: low-order X25519 public keys. */ /** * Every encoding of a low-order point (RFC 7748 §6.1, little-endian): 0, 1, * the two order-8 points, p − 1, p, p + 1, and (bit 255 is masked on both @@ -289,7 +288,7 @@ const edEncoding = (y: bigint, sign: boolean): string => * Non-canonical `y = p + k` encodings that dalek's `CompressedEdwardsY::decompress` * still decodes (it reduces y first; the reduced point has a square root), and the * ones it rejects. Executed against noble's `Point.fromBytes(enc, true)`, which - * applies the same rule; CRYPTO-04's boundary test pins both lists. + * applies the same rule; the decoder-boundary test pins both lists. */ export const ED_NONCANONICAL_DECODABLE_K: number[] = [0, 1, 3, 4, 5, 6, 9, 10, 14, 15, 16, 18]; export const ED_NONCANONICAL_UNDECODABLE_K: number[] = [2, 7, 8, 11, 12, 13, 17]; @@ -379,7 +378,7 @@ function* verify(): Generator { yield verifyOf("ed25519", ed.pub, edEncoding(ED_P - 1n, true) + ed.sig.slice(64), ed.msg); } /** - * Success paths the corpus never pinned against the reference: point decompression and + * Success paths: point decompression and * compression with values, and AEAD decryption of literal sealed buffers (key 00…1f, * nonce 00…0b). Every input is a literal, so a regression on either side is a MISMATCH. */ @@ -427,7 +426,7 @@ function* faults(): Generator { yield { k: "ecdsaCompress", pub: hx("06" + SECP_GX + SECP_NEG_GY) }; yield { k: "ecdsaCompress", pub: hx("05" + SECP_GX + SECP_GY) }; yield { k: "ecdsaCompress", pub: hx("06" + SECP_P_PLUS_1 + SECP_GY) }; - // Above noble's former default memory ceiling (~1 GiB): the reference has none. + // 1.07 GiB of working memory, above noble's own default ceiling (~1 GiB); the reference has none. yield { k: "scrypt", pw, salt, len: 32, n: 17, r: 64, p: 1 }; // Scalar, point, and KDF domain checks. yield { k: "ecdsaPub", priv: zero }; @@ -451,7 +450,7 @@ function* faults(): Generator { yield { k: "scrypt", pw, salt, len: 65, n: 4, r: 8, p: 1 }; yield { k: "scrypt", pw, salt, len: 10, n: 4, r: 8, p: 1 }; yield { k: "scrypt", pw, salt, len: 65 }; - // Parity the corpus never pinned: an X25519 public key with bit 255 set, an empty HKDF salt. + // An X25519 public key with bit 255 set, and an empty HKDF salt. yield { k: "x25519Shared", priv: cyc(32, 1), pub: hx("ff".repeat(32)) }; yield { k: "hkdfSha256", key: cyc(32, 0x10), salt: cyc(0), len: 32 }; yield { k: "hkdfSha512", key: cyc(32, 0x10), salt: cyc(0), len: 32 }; diff --git a/tests/corpus/ed25519-strict-fixtures.ts b/tests/corpus/ed25519-strict-fixtures.ts index f323883..e9d612f 100644 --- a/tests/corpus/ed25519-strict-fixtures.ts +++ b/tests/corpus/ed25519-strict-fixtures.ts @@ -1,4 +1,8 @@ -/** Known-scalar fixtures with order-two torsion in A/R. Generated by reports/divergence-review/generate-strict-fixtures.ts. */ +/** + * Ed25519 fixtures built from known scalars, with order-two torsion in A, R, + * both or neither. `valid` is the reference's `verify_strict` outcome; the + * fixtures are golden vectors, so the Rust harness replays every row. + */ export const ED25519_STRICT_FIXTURES = [ { publicKey: "5866666666666666666666666666666666666666666666666666666666666666", diff --git a/tests/corpus/verify-fixtures.ts b/tests/corpus/verify-fixtures.ts index b19c340..cf85df3 100644 --- a/tests/corpus/verify-fixtures.ts +++ b/tests/corpus/verify-fixtures.ts @@ -3,7 +3,7 @@ * GENERATED by scripts/generate-verify-fixtures.ts from the working tree; * the signing inputs (cycling-byte private keys 0x01…, 0x42…, the scalar 1; * messages cyc(1, 7) and cyc(32, 7); Schnorr aux-rand cyc(32, 0x77)) are - * golden vectors already proven against the reference. FROZEN. + * golden vectors replayed against the reference. */ export interface SignedTriple { scheme: "ecdsa" | "schnorr" | "ed25519"; diff --git a/tests/crypto.property.test.ts b/tests/crypto.property.test.ts index 63971fb..e2df62e 100644 --- a/tests/crypto.property.test.ts +++ b/tests/crypto.property.test.ts @@ -1,5 +1,6 @@ /** - * Property tests: round-trips and tamper detection over generated inputs. + * Property tests over generated inputs: round-trips, tamper detection, fault + * types, argument validation and strict verification. */ import fc from "fast-check"; import { runInNewContext } from "node:vm"; @@ -99,7 +100,7 @@ describe("signature round-trips", () => { }); }); -describe("properties for B1, B2, B4", () => { +describe("faults and strict verification", () => { const isCryptoError = (f: () => unknown): boolean => { try { f(); @@ -108,7 +109,7 @@ describe("properties for B1, B2, B4", () => { return c.CryptoError.isCryptoError(e); } }; - it("every fault raised by the package is a CryptoError (B4)", () => { + it("every fault raised by the package is a CryptoError", () => { const zero = new Uint8Array(32); const n = Uint8Array.from( Buffer.from("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", "hex"), @@ -176,7 +177,7 @@ describe("properties for B1, B2, B4", () => { err.details.what, ).toBe("scrypt parameters"); }); - it("a small-order or non-canonical Ed25519 public key or R never verifies (B1)", () => { + it("a small-order or non-canonical Ed25519 public key or R never verifies", () => { const identity = new Uint8Array(32); identity[0] = 1; const nonCanonicalIdentity = new Uint8Array(32).fill(0xff); @@ -222,7 +223,7 @@ describe("properties for B1, B2, B4", () => { { numRuns: 50 }, ); }); - it("x25519.sharedKey rejects every low-order encoding with NonContributoryKey, the reference's message (B2)", () => { + it("x25519.sharedKey rejects every low-order encoding with NonContributoryKey, the reference's message", () => { // The nine RFC 7748 §6.1 encodings and the other five high-bit variants of the same // seven u values; anything else agrees with noble's ladder. const lowOrder = [ @@ -427,7 +428,7 @@ describe("argument types: every byte, options and boolean argument is checked fi expect(rejects("crc32 littleEndian", () => c.crc32Bytes(M, { littleEndian: O(1) }))).toBe(true); expect(c.crc32Bytes(M, { littleEndian: undefined })).toEqual(c.crc32Bytes(M)); }); - it("the executed regressions: engine TypeErrors, silent acceptance, misattribution", () => { + it("strings, arrays and missing options are InvalidParameter naming the argument", () => { const what = (f: () => unknown): string => { try { f(); @@ -533,7 +534,7 @@ describe("verify never throws for inputs of the right length", () => { }); }); -describe("KDF domains mirrored from the reference's crates (1.0.0-beta.2)", () => { +describe("KDF domains mirrored from the reference's crates", () => { const pw = new Uint8Array(2); const salt = new Uint8Array(16).fill(0x50); it("scrypt: default and parameterised output-length rules", { timeout: 30_000 }, () => { diff --git a/tests/crypto.test.ts b/tests/crypto.test.ts index 47bce42..80826a8 100644 --- a/tests/crypto.test.ts +++ b/tests/crypto.test.ts @@ -1,4 +1,4 @@ -// Tests ported from bc-crypto-rust, expressed against the redesigned API. +// The reference's unit tests and test vectors, plus this package's own contracts. import { crc32, @@ -31,7 +31,6 @@ import { testRandomBytes, } from "@blockchaincommons/rand"; -// Helper to convert hex string to Uint8Array function hexToBytes(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { @@ -40,7 +39,6 @@ function hexToBytes(hex: string): Uint8Array { return bytes; } -// Helper to convert Uint8Array to hex string function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes) .map((b) => b.toString(16).padStart(2, "0")) @@ -123,7 +121,6 @@ describe("Hash functions", () => { "13485067e21af17c0900f70d885f02593c0e61e46f86450e4a0201a54c14db76", ); - // Different salt produces different output const differentSalt = hexToBytes("0d0e0f101112131415161718"); const differentKey = hkdfSha256(keyMaterial, differentSalt, { dkLen: keyLen }); expect(bytesToHex(derivedKey)).not.toBe(bytesToHex(differentKey)); @@ -152,7 +149,6 @@ describe("Symmetric encryption", () => { expect(bytesToHex(ciphertext)).toBe(bytesToHex(expectedCiphertext)); expect(bytesToHex(authTag)).toBe(bytesToHex(expectedAuthTag)); - // Decrypt and verify const decrypted = chacha20Poly1305.decrypt(key, nonce, sealed, { aad }); expect(new TextDecoder().decode(decrypted)).toBe( "Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it.", @@ -288,7 +284,6 @@ describe("ECDSA", () => { const isValid = ecdsa.verify(publicKey, signature, message); expect(isValid).toBe(true); - // Verify with wrong message fails const wrongMessage = new TextEncoder().encode("Wrong message"); const isInvalid = ecdsa.verify(publicKey, signature, wrongMessage); expect(isInvalid).toBe(false); @@ -315,10 +310,9 @@ describe("ECDSA", () => { }); describe("Schnorr", () => { - // Mirrors Rust `schnorr_signing::tests::test_schnorr_sign` (line 64) - // exactly — uses `make_fake_random_number_generator()` for both the - // private-key derivation and the auxiliary randomness, then asserts - // the byte-identical signature. + // Mirrors the reference's `schnorr_signing::tests::test_schnorr_sign`: the + // seeded test generator supplies both the private key and the auxiliary + // randomness, and the signature is byte-identical. test("test_schnorr_sign (deterministic, matches Rust)", () => { const rng = SeededRng.forTesting(); const privateKey = ecdsa.generatePrivateKey({ rng }); @@ -451,7 +445,7 @@ describe("Schnorr", () => { expect(isValid).toBe(true); }); - // BIP-340 Test Vector 5 - public key not on the curve (should fail/throw) + // BIP-340 Test Vector 5 - public key not on the curve test("test_bip340_vector_5", () => { const publicKey = hexToBytes( "EEFDEA4CDB677750A420FEE807EACF21EB9898AE79B9768766E4FAA04A2D4A34", @@ -461,14 +455,7 @@ describe("Schnorr", () => { "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E17776969E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", ); - // This should either throw or return false - let result: boolean; - try { - result = schnorr.verify(publicKey, signature, message); - } catch { - result = false; - } - expect(result).toBe(false); + expect(schnorr.verify(publicKey, signature, message)).toBe(false); }); // BIP-340 Test Vector 6 - has_even_y(R) is false @@ -583,7 +570,7 @@ describe("Schnorr", () => { expect(isValid).toBe(false); }); - // BIP-340 Test Vector 14 - public key is not a valid X coordinate (should fail/throw) + // BIP-340 Test Vector 14 - public key is not a valid X coordinate test("test_bip340_vector_14", () => { const publicKey = hexToBytes( "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30", @@ -593,14 +580,7 @@ describe("Schnorr", () => { "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E17776969E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", ); - // This should either throw or return false - let result: boolean; - try { - result = schnorr.verify(publicKey, signature, message); - } catch { - result = false; - } - expect(result).toBe(false); + expect(schnorr.verify(publicKey, signature, message)).toBe(false); }); // BIP-340 Test Vector 15 - empty message @@ -686,9 +666,9 @@ describe("Schnorr", () => { }); describe("Ed25519", () => { - // Mirrors Rust `ed25519_signing::tests::test_ed25519_signing` (line 51) - // exactly — fake_random_data(32) for the private key, then asserts both - // the public key and the signature byte-identically. + // Mirrors the reference's `ed25519_signing::tests::test_ed25519_signing`: + // `fake_random_data(32)` for the private key, then the public key and the + // signature, byte-identical. test("test_ed25519_signing (deterministic, matches Rust)", () => { const MESSAGE = new TextEncoder().encode( "Ladies and Gentlemen of the class of '99: If I could offer you only " + @@ -727,7 +707,6 @@ describe("Ed25519", () => { expect(ed25519.verify(publicKey, signature, message)).toBe(true); - // Verify with wrong message fails const wrongMessage = new TextEncoder().encode("Wrong message"); expect(ed25519.verify(publicKey, signature, wrongMessage)).toBe(false); }); @@ -839,9 +818,8 @@ describe("Scrypt", () => { expect(bytesToHex(key1)).toBe(bytesToHex(key2)); // Deterministic }); - // Cross-platform parity vector — pins the byte output of `scrypt()` with - // the recommended params (logN=17, r=8, p=1) that match Rust - // `bc_crypto::scrypt`. Drift here means cross-impl interop is broken. + // The reference's `bc_crypto::scrypt` output with its recommended + // parameters (logN 17, r 8, p 1). test("scrypt cross-platform vector (matches Rust defaults)", { timeout: 10_000 }, () => { const password = new TextEncoder().encode("password"); const salt = new TextEncoder().encode("salt"); @@ -895,9 +873,8 @@ describe("Argon2id", () => { expect(bytesToHex(key1)).toBe(bytesToHex(key2)); // Deterministic }, 30_000); - // Cross-platform parity vector — pins the byte output of `argon2id()` with - // the `Argon2::default()` params (t=2, m=19456, p=1) that Rust - // `bc_crypto::argon2id` uses. Drift here means cross-impl interop is broken. + // The reference's `bc_crypto::argon2id` output with `Argon2::default()` + // (t 2, m 19456 KiB, p 1). test("argon2id cross-platform vector (matches Rust defaults)", () => { const password = new TextEncoder().encode("password"); const salt = new TextEncoder().encode("example salt"); diff --git a/tests/differential.test.ts b/tests/differential.test.ts index a8896fe..ff00968 100644 --- a/tests/differential.test.ts +++ b/tests/differential.test.ts @@ -1,9 +1,10 @@ import { ED25519_STRICT_FIXTURES } from "./corpus/ed25519-strict-fixtures"; /** - * Differential harness: every corpus recipe through the frozen baseline - * bundle (with its own inlined pre-redesign rand) AND the working tree; - * outcomes must be identical except for enumerated tombstones. Error NAMES - * are compared, not messages. + * Differential test: every corpus recipe through the frozen baseline bundle + * (the `@bcts/crypto` surface this package replaces, with its own inlined + * rand) and through the working tree. Outcomes must be identical except for + * the allowed differences below. Only "throws" versus a value is compared, + * not error classes or messages. */ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; @@ -13,25 +14,22 @@ import * as baselineMod from "./baseline/crypto-baseline.mjs"; import * as randBaseline from "./baseline/rand-baseline.mjs"; import * as src from "../src"; import * as rand from "@blockchaincommons/rand"; -import { - materialize, - baselineAdapterFor, - redesignedAdapterFor, - type Recipe, -} from "./vectors/recipes"; +import { materialize, baselineAdapterFor, currentAdapterFor, type Recipe } from "./vectors/recipes"; import { categories, noBaseline } from "./corpus/corpus"; const here = dirname(fileURLToPath(import.meta.url)); const BASELINE_SHA256 = "d3a5a82546fd0424232ba32ea1c1bd485e08f35f3f241edc90c8476fb1559655"; /** - * Allowed differences between the pre-redesign baseline and the tree; error - * class names are not compared (only whether a recipe throws or has a value). + * Where the tree deliberately differs from the baseline. Within a category, an + * entry that matches any recipe must see at least one of them differ, so a + * stale entry fails the test. */ -const TOMBSTONES: { id: string; landed: boolean; matches: (r: Recipe) => boolean }[] = [ +const ALLOWED_DIFFERENCES: { id: string; matches: (r: Recipe) => boolean }[] = [ { - id: "T5-uncofactored-ed25519", - landed: true, + // The uncofactored equation (`verify_strict`): torsion fixtures the + // baseline's cofactored check accepted are rejected. + id: "uncofactored-ed25519", matches: (r) => r.k === "ed25519Verify" && "hex" in r.sig && @@ -44,8 +42,7 @@ const TOMBSTONES: { id: string; landed: boolean; matches: (r: Recipe) => boolean // canonically; the outcome is the same `false`, because an undecodable // key is `false` on both sides and a decodable non-canonical key is // small-order or would need a discrete logarithm to verify. - id: "T1", - landed: true, + id: "strict-ed25519-encodings", matches: (r) => r.k === "ed25519Verify" && "hex" in r.pub && @@ -57,16 +54,14 @@ const TOMBSTONES: { id: string; landed: boolean; matches: (r: Recipe) => boolean // Seeded Ed25519 key generation draws the reference's packed // `fill_bytes` stream (`SeededRng.fillBytesPacked`); the baseline drew // one step per byte (`random_data`), which is not what the reference does. - id: "T2", - landed: true, + id: "packed-ed25519-keygen", matches: (r) => r.k === "newPriv" && r.alg === "ed25519", }, { // scrypt's parameterised path mirrors `scrypt::Params::new`: output length // in 10..=64, logN < 16·r, r·p < 2^30. The baseline computed these; the // reference panics; the tree throws. - id: "T3", - landed: true, + id: "scrypt-params-new", matches: (r) => r.k === "scrypt" && r.n !== undefined && @@ -75,8 +70,7 @@ const TOMBSTONES: { id: string; landed: boolean; matches: (r: Recipe) => boolean { // PBKDF2 with dkLen 0 is an empty key on the tree (as the reference returns); // the baseline threw. - id: "T4", - landed: true, + id: "pbkdf2-empty-output", matches: (r) => (r.k === "pbkdf2Sha256" || r.k === "pbkdf2Sha512") && r.len === 0 && r.iter >= 1, }, @@ -84,8 +78,7 @@ const TOMBSTONES: { id: string; landed: boolean; matches: (r: Recipe) => boolean // Hybrid `06`/`07` uncompressed keys compress on the tree, as libsecp256k1 // parses them for the reference; the baseline (noble) rejected every // prefix but `04`. - id: "T9-hybrid-uncompressed", - landed: true, + id: "hybrid-uncompressed-keys", matches: (r) => r.k === "ecdsaCompress" && "hex" in r.pub && @@ -94,8 +87,7 @@ const TOMBSTONES: { id: string; landed: boolean; matches: (r: Recipe) => boolean { // scrypt has no default memory ceiling on the tree (the reference has // none); the baseline kept noble's default of 128·8·(2^20 + 2) bytes. - id: "T10-scrypt-maxmem", - landed: true, + id: "scrypt-no-memory-ceiling", matches: (r) => r.k === "scrypt" && r.n !== undefined && @@ -104,8 +96,8 @@ const TOMBSTONES: { id: string; landed: boolean; matches: (r: Recipe) => boolean ]; const baseline = baselineAdapterFor(baselineMod, randBaseline); -const current = redesignedAdapterFor(src, rand); -// Error class names changed (AeadError/Error -> CryptoError); compare throw-vs-value only. +const current = currentAdapterFor(src, rand); +// The two surfaces throw different error classes; only throw versus value is compared. const norm = (s: string): string => (s.startsWith("throw:") ? "throw" : s); describe("differential: baseline vs working tree", () => { @@ -120,26 +112,26 @@ describe("differential: baseline vs working tree", () => { it(`category ${name}`, { timeout: 300_000 }, () => { let n = 0; const diffs: string[] = []; - const landedHits = new Map(); - const landedMatches = new Map(); + const differing = new Map(); + const matched = new Map(); for (const recipe of gen()) { if (noBaseline(recipe)) continue; n++; const a = norm(materialize(baseline, recipe)); const b = norm(materialize(current, recipe)); const equal = a === b; - const tomb = TOMBSTONES.find((t) => t.matches(recipe)); - if (tomb?.landed === true) { - landedMatches.set(tomb.id, (landedMatches.get(tomb.id) ?? 0) + 1); - if (!equal) landedHits.set(tomb.id, (landedHits.get(tomb.id) ?? 0) + 1); + const allowed = ALLOWED_DIFFERENCES.find((d) => d.matches(recipe)); + if (allowed !== undefined) { + matched.set(allowed.id, (matched.get(allowed.id) ?? 0) + 1); + if (!equal) differing.set(allowed.id, (differing.get(allowed.id) ?? 0) + 1); } else if (!equal) { diffs.push(`${JSON.stringify(recipe)}: ${a} !== ${b}`); } } expect(n).toBeGreaterThan(0); expect(diffs).toEqual([]); - for (const [id, matches] of landedMatches) - if (matches > 0) expect(landedHits.get(id) ?? 0).toBeGreaterThan(0); + for (const [id, count] of matched) + if (count > 0) expect(differing.get(id) ?? 0, id).toBeGreaterThan(0); }); } }); diff --git a/tests/golden-vectors.test.ts b/tests/golden-vectors.test.ts index 56efbf7..da537bd 100644 --- a/tests/golden-vectors.test.ts +++ b/tests/golden-vectors.test.ts @@ -4,14 +4,14 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import * as src from "../src"; import * as rand from "@blockchaincommons/rand"; -import { materialize, redesignedAdapterFor, type Recipe } from "./vectors/recipes"; +import { materialize, currentAdapterFor, type Recipe } from "./vectors/recipes"; const here = dirname(fileURLToPath(import.meta.url)); const { count, vectors } = JSON.parse(readFileSync(join(here, "vectors/vectors.json"), "utf8")) as { count: number; vectors: { recipe: Recipe; expect: string }[]; }; -const api = redesignedAdapterFor(src, rand); +const api = currentAdapterFor(src, rand); describe("golden vectors (frozen)", () => { it("fixture is self-consistent and non-trivial", () => { diff --git a/tests/golden.test.ts b/tests/golden.test.ts index 85ee50b..4c88a93 100644 --- a/tests/golden.test.ts +++ b/tests/golden.test.ts @@ -67,7 +67,7 @@ describe("golden: hashes", () => { describe("golden: symmetric", () => { for (const [n, d] of INPUTS) { it(`chacha20poly1305 (${n})`, () => { - // Snapshot layout is [ct, tag, ctA, tagA] from the pre-redesign tuple API. + // Snapshot layout: [ciphertext, tag, ciphertext with aad, tag with aad]. const sealed = c.chacha20Poly1305.encrypt(KEY, NONCE, d); const sealedA = c.chacha20Poly1305.encrypt(KEY, NONCE, d, { aad: AAD }); const split = (s: Uint8Array) => [ @@ -164,11 +164,10 @@ describe("golden: keys and signatures", () => { }); /** - * Freeze additions: today's behaviour on inputs known to have edge-case - * outcomes, recorded verbatim so future changes show up as snapshot diffs - * rather than silent drift. + * Edge cases: outcomes on inputs at the boundary of each primitive's domain, + * recorded verbatim so a change is a snapshot diff. */ -describe("golden: freeze additions (B1–B5)", () => { +describe("golden: edge cases", () => { const fill = (n: number, v: number): Uint8Array => new Uint8Array(n).fill(v); const one = (n: number): Uint8Array => { const u = new Uint8Array(n); @@ -194,20 +193,20 @@ describe("golden: freeze additions (B1–B5)", () => { const nonCanonicalRSignature = new Uint8Array(64); nonCanonicalRSignature.set(nonCanonicalIdentity, 0); - it("B1: ed25519.verify on a small-order key / non-canonical encodings (false, as verify_strict)", () => { + it("ed25519.verify on a small-order key / non-canonical encodings (false, as verify_strict)", () => { expect([ outcome(() => c.ed25519.verify(identity, identitySignature, msg)), outcome(() => c.ed25519.verify(nonCanonicalIdentity, identitySignature, msg)), outcome(() => c.ed25519.verify(identity, nonCanonicalRSignature, msg)), ]).toMatchSnapshot(); }); - it("B2: x25519.sharedKey with a low-order public key (the reference's NonContributoryKey)", () => { + it("x25519.sharedKey with a low-order public key (the reference's NonContributoryKey)", () => { expect([ outcome(() => c.x25519.sharedKey(PRIV, new Uint8Array(32))), outcome(() => c.x25519.sharedKey(PRIV, one(32))), ]).toMatchSnapshot(); }); - it("B3: verify on malformed keys and signatures of the right length (false on both sides)", () => { + it("verify on malformed keys and signatures of the right length (false on both sides)", () => { const ecdsaPub = c.ecdsa.publicKey(PRIV); expect([ outcome(() => c.ecdsa.verify(ecdsaPub, fill(64, 0xff), msg)), @@ -222,7 +221,7 @@ describe("golden: freeze additions (B1–B5)", () => { outcome(() => c.ed25519.verify(c.ed25519.publicKey(PRIV), fill(64, 0xff), msg)), ]).toMatchSnapshot(); }); - it("B4: domain faults are reported as CryptoError", () => { + it("domain faults are reported as CryptoError", () => { const n = Uint8Array.from( Buffer.from("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", "hex"), ); @@ -261,7 +260,7 @@ describe("golden: freeze additions (B1–B5)", () => { ), ).toMatchSnapshot(); }); - it("B5: scrypt output length below the reference's opt bound (parameterised: throw; default: accepted)", () => { + it("scrypt output length below the reference's opt bound (parameterised: throw; default: accepted)", () => { expect([ outcome(() => c.scrypt(text("pw"), SALT, { dkLen: 8, logN: 4 })), outcome(() => c.scrypt(text("pw"), SALT, { dkLen: 8 })), diff --git a/tests/heavy-vectors.test.ts b/tests/heavy-vectors.test.ts index 3e30fa0..d785c89 100644 --- a/tests/heavy-vectors.test.ts +++ b/tests/heavy-vectors.test.ts @@ -10,14 +10,14 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import * as src from "../src"; import * as rand from "@blockchaincommons/rand"; -import { materialize, redesignedAdapterFor, type Recipe } from "./vectors/recipes"; +import { materialize, currentAdapterFor, type Recipe } from "./vectors/recipes"; const here = dirname(fileURLToPath(import.meta.url)); const { count, vectors } = JSON.parse(readFileSync(join(here, "vectors/heavy.json"), "utf8")) as { count: number; vectors: { recipe: Recipe; expect: string }[]; }; -const api = redesignedAdapterFor(src, rand); +const api = currentAdapterFor(src, rand); describe.skipIf(process.env["CRYPTO_HEAVY"] !== "1")("heavy vectors (CRYPTO_HEAVY=1)", () => { it("fixture is self-consistent", () => { diff --git a/tests/kdf-backend.test.ts b/tests/kdf-backend.test.ts index f484697..0121c4a 100644 --- a/tests/kdf-backend.test.ts +++ b/tests/kdf-backend.test.ts @@ -27,7 +27,7 @@ describe("PBKDF2 dkLen up to (2^32 − 1)·hLen; iterations 0 stays InvalidParam afterEach(() => { vi.mocked(pbkdf2).mockReset(); }); - it("a dkLen above the old u32 cap reaches noble (2^32, no derivation)", () => { + it("a dkLen above 2^32 − 1 reaches noble (2^32, no derivation)", () => { vi.mocked(pbkdf2).mockImplementationOnce(() => new Uint8Array(0)); c.pbkdf2Sha256(pw, salt, { iterations: 1, dkLen: 2 ** 32 }); expect(vi.mocked(pbkdf2)).toHaveBeenCalledTimes(1); diff --git a/tests/scrypt-core.test.ts b/tests/scrypt-core.test.ts index 135ea4b..ba0b0bb 100644 --- a/tests/scrypt-core.test.ts +++ b/tests/scrypt-core.test.ts @@ -55,8 +55,9 @@ describe("scrypt core", () => { ); }); it("pages hold whole blocks and the last page may be short", () => { - // N = 5 blocks of 128·r bytes in pages of 2 blocks: the core must still - // address every block; a wrong page size would corrupt the mix. + // Blocks of 256 bytes (r = 2) in pages of 1, 2, 2 (700 bytes rounds down to + // whole blocks), 4 and 16 blocks, with a short last page for B: every block + // must be addressed through its page. const pw = utf8("pw"); const salt = utf8("salt"); const want = hex(nobleScrypt(pw, salt, { N: 8, r: 2, p: 3, dkLen: 32 })); diff --git a/tests/vectors/recipes.ts b/tests/vectors/recipes.ts index 9521a18..b91eac0 100644 --- a/tests/vectors/recipes.ts +++ b/tests/vectors/recipes.ts @@ -7,7 +7,7 @@ * "1"/"0", or `throw:` (message-independent), except that an * `x25519Shared` `CryptoError` renders as `throw:|`. * - * Recipe semantics are FROZEN. + * Recipe semantics are fixed: the committed vectors depend on them. */ export type Bytes = { hex: string } | { cycle: number; start?: number } | { text: string }; @@ -216,7 +216,7 @@ export function materialize(api: VectorApi, r: Recipe): string { /* eslint-disable @typescript-eslint/no-explicit-any */ -/** The pre-redesign surface (frozen baseline bundle; its own inlined rand). */ +/** The `@bcts/crypto` surface of the frozen baseline bundle (with its own inlined rand). */ export function baselineAdapterFor(m: any, randBaseline: any): VectorApi { return { sha256: m.sha256, @@ -238,7 +238,7 @@ export function baselineAdapterFor(m: any, randBaseline: any): VectorApi { chacha20: () => { throw new Error("no baseline analog"); }, - // The pre-redesign AEAD returns [ciphertext, tag] and decrypt takes the tag + // The baseline AEAD returns [ciphertext, tag] and decrypt takes the tag // last; the vector representation is the concatenation ciphertext || tag. aeadEncrypt: (pt, k, n, aad) => { const [ct, tag] = @@ -270,7 +270,7 @@ export function baselineAdapterFor(m: any, randBaseline: any): VectorApi { schnorrVerify: m.schnorrVerify, ed25519Pub: m.ed25519PublicKeyFromPrivateKey, ed25519Sign: m.ed25519Sign, - // Pre-redesign ed25519Verify takes (pub, msg, sig), unlike ecdsa/schnorr (pub, sig, msg). + // The baseline's ed25519Verify takes (pub, msg, sig), unlike ecdsa/schnorr (pub, sig, msg). ed25519Verify: (pub, sig, msg) => m.ed25519Verify(pub, msg, sig), newPriv: (alg, rng) => ({ @@ -278,20 +278,13 @@ export function baselineAdapterFor(m: any, randBaseline: any): VectorApi { ed25519: m.ed25519NewPrivateKeyUsing, x25519: m.x25519NewPrivateKeyUsing, })[alg](rng), - // The baseline's `*Using(rng)` call rng.randomData(n); the OLD rand interface provides it. + // The baseline's `*Using(rng)` call rng.randomData(n), which the baseline rand provides. makeRng: (seed) => new randBaseline.SeededRandomNumberGenerator(seed), }; } -/** The working tree, using the current (redesigned) names. */ -export function redesignedAdapterFor(m: any, rand: any): VectorApi { - if (typeof m.aeadChaCha20Poly1305Encrypt === "function") { - // Pre-redesign flat surface, but the redesigned rand: build a generator - // whose fillBytes drives the old `*Using` functions via randomBytes(n, { rng }). - const api = baselineAdapterFor(m, undefined); - api.makeRng = (seed) => new rand.SeededRng(seed); - return api; - } +/** This package's surface (the working tree or its build). */ +export function currentAdapterFor(m: any, rand: any): VectorApi { return { sha256: m.sha256, doubleSha256: m.doubleSha256, From 0be7051ad87b052b184f15be0890eb0cdea75314 Mon Sep 17 00:00:00 2001 From: Leonardo Custodio Date: Mon, 14 Sep 2026 17:09:18 -0300 Subject: [PATCH 4/8] Changes --- RUST_DIVERGENCES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RUST_DIVERGENCES.md b/RUST_DIVERGENCES.md index fd4cef1..f9736d2 100644 --- a/RUST_DIVERGENCES.md +++ b/RUST_DIVERGENCES.md @@ -26,7 +26,7 @@ For every input the reference accepts and a JavaScript runtime can hold, outputs ### D1. PBKDF2 output past (2^32 − 1)·hLen -`pbkdf2Sha256`, `pbkdf2Sha512` and scrypt's default path stop at (2^32 − 1)·hLen output bytes (RFC 8018 §5.2) and throw `InvalidParameter` beyond it. The reference accepts a longer `key_len`, and its `pbkdf2` 0.12.2 backend's `u32` block counter then overflows: a panic with overflow checks, a wrapped counter (the output repeats from the start) without them. `scrypt` 0.11.0 admits 31 further bytes that reach that block. The port does not reproduce this because the reference's result depends on its build profile and needs at least 137 GiB of output. An upstream report to RustCrypto is pending. +`pbkdf2Sha256`, `pbkdf2Sha512` and scrypt's default path stop at (2^32 − 1)·hLen output bytes (RFC 8018 §5.2) and throw `InvalidParameter` beyond it. The reference accepts a longer `key_len`, and its `pbkdf2` 0.12.2 backend's `u32` block counter then overflows: a panic with overflow checks, a wrapped counter (the output repeats from the start) without them. `scrypt` 0.11.0 admits 31 further bytes that reach that block. The port does not reproduce this because the reference's result depends on its build profile and needs at least 137 GiB of output. ## Maintenance From c5c21964d1ddc6885287400366ecc8ec098c8479 Mon Sep 17 00:00:00 2001 From: Leonardo Custodio Date: Mon, 14 Sep 2026 17:16:31 -0300 Subject: [PATCH 5/8] Improvements --- CHANGELOG.md | 9 +++------ src/ecdsa.ts | 8 +++++++- tests/crypto.test.ts | 10 ++++++++++ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbf6ab..bc41647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,9 @@ # Changelog -## Unreleased +## 1.0.0-beta.3 - 2026-09-14 -Closes every divergence from the reference that a TypeScript design can -remove. The reference is the `bc-rust/bc-crypto-rust` working tree (commit -`4f2b791`, tag 0.14.0, plus its input-validation edits), which the Rust harness -now patches in; against it there is no exception list. Requires -`@blockchaincommons/rand` ≥ 1.0.0-beta.3. +Closes the divergence from the reference listed below. The reference is the `bc-crypto-rust` (tag 0.14.0), +which the Rust harness now patches in; against it there is no exception list. ### Changed (breaking) diff --git a/src/ecdsa.ts b/src/ecdsa.ts index 9a615be..f06f766 100644 --- a/src/ecdsa.ts +++ b/src/ecdsa.ts @@ -119,7 +119,13 @@ export const ecdsa: Ecdsa = { plain[0] = 0x04; const point = secp256k1.Point.fromBytes(plain); if ((point.toAffine().y & 1n) !== BigInt(head & 1)) { - throw new Error("hybrid prefix does not match the parity of y"); + // A CryptoError passes through `guard`; the point is on the curve, + // so `invalidPoint`'s message would be wrong here. The reference + // does not distinguish the two: both are `InvalidPublicKey`. + throw CryptoError.invalidData( + "ECDSA uncompressed public key", + "ECDSA uncompressed public key has a hybrid prefix that does not match the parity of y", + ); } return point.toBytes(true); } diff --git a/tests/crypto.test.ts b/tests/crypto.test.ts index 80826a8..78d5dec 100644 --- a/tests/crypto.test.ts +++ b/tests/crypto.test.ts @@ -1077,6 +1077,16 @@ describe("ECDSA hybrid uncompressed keys (libsecp256k1's 06/07 prefixes)", () => expect(CryptoError.isCryptoError(err) && err.code).toBe("InvalidData"); } }); + test("a contradicting prefix is reported as such, not as an off-curve point", () => { + for (const bad of ["07" + GX + GY, "06" + GX + NEG_GY]) { + expect(() => ecdsa.compressPublicKey(hex(bad))).toThrow( + "ECDSA uncompressed public key has a hybrid prefix that does not match the parity of y", + ); + } + expect(() => ecdsa.compressPublicKey(hex("05" + GX + GY))).toThrow( + "ECDSA uncompressed public key is not a point on the curve", + ); + }); }); describe("ChaCha20 keystream", () => { From c1d89757a73f21d0b77da54dbf986c73a719cc6c Mon Sep 17 00:00:00 2001 From: Leonardo Custodio Date: Mon, 14 Sep 2026 17:48:50 -0300 Subject: [PATCH 6/8] Improvements --- .github/workflows/ci.yml | 13 +- CHANGELOG.md | 46 +- MIGRATION.md | 25 +- README.md | 4 +- RUST_DIVERGENCES.md | 17 +- api/crypto.api.md | 6 +- api/index.d.mts | 69 +- src/domain.ts | 6 + src/ecdsa.ts | 44 +- src/ed25519.ts | 30 +- src/error.ts | 32 +- src/hash.ts | 15 +- src/kdf.ts | 15 +- src/scrypt-core.ts | 2 +- src/x25519.ts | 40 +- tests/__snapshots__/golden.test.ts.snap | 26 +- tests/corpus/corpus.ts | 4 +- tests/crypto.property.test.ts | 70 +- tests/crypto.test.ts | 85 ++- tests/differential.test.ts | 77 +- tests/golden.test.ts | 22 +- tests/kdf-backend.test.ts | 29 +- tests/rust-validation/Cargo.lock | 2 + tests/rust-validation/Cargo.toml | 9 +- tests/rust-validation/README.md | 35 +- .../bc-crypto-rust-4f2b791-edits.patch | 658 ------------------ tests/rust-validation/src/main.rs | 40 +- tests/strict-boundaries.test.ts | 24 +- tests/vectors/recipes.ts | 9 +- tests/vectors/vectors.json | 126 ++-- 30 files changed, 505 insertions(+), 1075 deletions(-) delete mode 100644 tests/rust-validation/reference/bc-crypto-rust-4f2b791-edits.patch diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 396a9e7..b7cfaa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,22 +79,11 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - # The reference is bc-crypto-rust at 4f2b791 plus the input-validation - # edits kept in tests/rust-validation/reference/, materialised at the - # path the harness's [patch.crates-io] names. This step goes with the - # patch once the release that contains the edits ships. - - name: Materialise the reference (bc-crypto-rust 4f2b791 + edits) - run: | - ref="$GITHUB_WORKSPACE/../../../bc-rust/bc-crypto-rust" - git clone --quiet https://github.com/BlockchainCommons/bc-crypto-rust.git "$ref" - git -C "$ref" checkout --quiet 4f2b791320730578b04943c833c4a9e6c232fc4d - git -C "$ref" apply --verbose "$GITHUB_WORKSPACE/tests/rust-validation/reference/bc-crypto-rust-4f2b791-edits.patch" - # The whole corpus (tests/corpus/corpus.ts `allRecipes()`) - name: Materialise the full corpus run: bun scripts/generate-vectors.ts --full "${{ runner.temp }}/crypto-full-corpus.json" - # The runner image ships a stable Rust toolchain + # The runner image ships a stable Rust toolchain; - name: Validate the golden vectors against the reference working-directory: tests/rust-validation run: cargo run --release --locked -- ../vectors/vectors.json diff --git a/CHANGELOG.md b/CHANGELOG.md index bc41647..cd02847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,7 @@ ## 1.0.0-beta.3 - 2026-09-14 -Closes the divergence from the reference listed below. The reference is the `bc-crypto-rust` (tag 0.14.0), -which the Rust harness now patches in; against it there is no exception list. +Closes the below divergences from the reference, the published `bc-crypto` (tag 0.14.0). ### Changed (breaking) @@ -17,11 +16,26 @@ which the Rust harness now patches in; against it there is no exception list. another argument (`ecdsa.sign(key, "msg")` reported the private key). A `Buffer` and a `Uint8Array` from another realm are accepted. `memzero` and `memzeroAll` require numeric typed arrays. -- **A low-order X25519 peer is `NonContributoryKey`.** `x25519.sharedKey` - throws the new code with the reference's message, `"X25519 peer key - produces an all-zero shared secret"` (its `try_x25519_shared_key` returns - `Err(NonContributoryKey)`), instead of `InvalidData` "low-order point". - `CryptoErrorCode` and `CryptoErrorDetails` gain the member. +- **A low-order X25519 peer derives the reference's key.** For every + low-order encoding (RFC 7748 §6.1), `x25519.sharedKey` returns HKDF-SHA-256 + of the all-zero shared secret, `6ddeb1af…8d6e` whatever the private key, + as the reference's `x25519_shared_key` does (x25519-dalek's + `diffie_hellman`, which it does not check), instead of throwing + `InvalidData` "low-order point". Reject such peers yourself before deriving + from an untrusted key. +- **`verify` throws where the reference's parse panics.** `ecdsa.verify`, + `schnorr.verify` and `ed25519.verify` throw `InvalidData` naming the key + when it does not decode (the reference `.expect`s `PublicKey::from_slice` + and `XOnlyPublicKey::from_byte_array` and `.unwrap()`s + `VerifyingKey::from_bytes`), and `ecdsa.verify` throws it for an r or s ≥ n + (`Signature::from_compact`); all of these were `false`. An input that + parses still verifies or not. `ed25519.verify` decodes the key as dalek + does (a non-canonical y reduced), so the 26 non-canonical encodings dalek + takes are `false` and the 14 it rejects are `InvalidData`. +- **Zero KDF costs derive.** PBKDF2 `iterations: 0` computes what 1 does (the + reference's `pbkdf2` 0.12.2 runs `rounds − 1` rounds after the first block) + and scrypt `logN: 0` derives with N = 1 (`scrypt::Params::new` takes + `log_n` 0), where both were rejected. - **scrypt has no default memory ceiling.** `maxmem` is an opt-in ceiling; by default the parameters decide, as in the reference. logN 17, r 64 (1.07 GiB) now derives (`88d8c775…86f3`), where noble's ~1 GiB default @@ -39,28 +53,28 @@ which the Rust harness now patches in; against it there is no exception list. libsecp256k1's `06`/`07` prefixes when the low bit matches the parity of y, as the reference does; a mismatch is `InvalidData`. - **PBKDF2 `dkLen` up to (2^32 − 1)·hLen** (RFC 8018 §5.2; 32 or 64), where - the port stopped at 2^32 − 1. `iterations` 0 stays `InvalidParameter` at - every length, the typed form of the reference's `assert!(iterations > 0)`. + the port stopped at 2^32 − 1. - `chacha20` returns `Uint8Array`. ### Validation -- The harness is patched to the reference tree, has no exception list, and - parses every argument with the Rust width before the call: a wrong-length +- The harness builds against the published crate, unpatched, has no + exception list, and parses every argument with the Rust width before the + call: a wrong-length fixed argument, sealed data under 16 bytes, a number outside `u8`, `u32` or `usize`, and raw ChaCha20 are js-only, never matches or mismatches. The golden file grew from 679 to 807 vectors: point (de)compression and AEAD decryption success paths, 66 non-canonical Ed25519 A and R rows, 19 - undecodable-key and r/s ∈ {0, n} verify rows, 21 low-order X25519 rows - carrying the error value, 6 hybrid keys, the logN 17 r 64 row and 3 width - probes. `--full` replays the whole corpus (1195) and `heavy.json` the + undecodable-key and r/s ∈ {0, n} verify rows, 28 low-order X25519 rows, + 6 hybrid keys, the logN 17 r 64 row and 3 width probes. `--full` replays the whole corpus (1195) and `heavy.json` the logN 22, r 9 vector; CI runs all three, plus the heavy vector on Bun and Node. Results: `807 vectors - 793 match, 14 js-only, 0 MISMATCH`; `1195 - 1180, 15, 0`; `1 - 1, 0, 0`. - Tests: an argument-type property over every exported function, the Ed25519 decoder boundary (dalek decodes 26 of the 40 non-canonical - encodings, the port none; `verify` is `false` for all 40 on both sides), - a verify-never-throws property, the Ed25519 packed and fallback generator + encodings and so does the port: `false` for those as A, `InvalidData` for + the 14 it rejects, `false` for all 40 as R), a verify-outcome property (a + boolean, or `InvalidData` naming the key), the Ed25519 packed and fallback generator paths, malformed generators propagating rand's `InvalidGenerator` unwrapped, backend spies for the PBKDF2 bound and the scrypt ceiling, and the paged core against noble under one-block, three-block and default diff --git a/MIGRATION.md b/MIGRATION.md index cf7d131..00f6c5e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -24,8 +24,11 @@ - [ ] Pass `Uint8Array`s (a `Buffer` qualifies). A string, plain array or `ArrayBuffer` in any byte position is now `CryptoError` `InvalidParameter`, named after the argument; encode text explicitly. -- [ ] A low-order X25519 peer in `x25519.sharedKey` is `NonContributoryKey` - (the reference's message), not `InvalidData`. +- [ ] A low-order X25519 peer in `x25519.sharedKey` derives the reference's + fixed key instead of throwing `InvalidData`; reject such peers yourself. +- [ ] `verify` throws `InvalidData` for a public key the reference cannot + parse (and `ecdsa.verify` for r or s ≥ n); a parsed input is still + `true`/`false`. PBKDF2 `iterations: 0` and scrypt `logN: 0` derive. - [ ] scrypt has no default memory ceiling; pass `maxmem` if you want one. PBKDF2 accepts `dkLen` up to (2^32 − 1)·hLen. - [ ] Raise your Node floor to **22.12** and TypeScript to **>= 5.7**. @@ -120,7 +123,6 @@ try { case "InvalidSize": // e.details: { what, expected, actual } case "InvalidData": // a key, point or signature of the right length that is not valid case "InvalidParameter": // an argument outside its domain, including a non-Uint8Array byte argument - case "NonContributoryKey": // x25519.sharedKey: a low-order peer key (all-zero shared secret) } } } @@ -133,17 +135,18 @@ factories. Length checks that used to throw a bare `code: "InvalidSize"`; the message names the parameter and the actual length. Invalid scalars or points and rejected KDF parameters are reported as `CryptoError` (previously the noble library's own `Error`/`RangeError` -escaped); a low-order X25519 public key is `NonContributoryKey`, with the -reference's message. Every byte argument is checked to be a `Uint8Array` +escaped); a low-order X25519 public key derives the reference's fixed key +(HKDF of the all-zero secret, as x25519-dalek's unchecked `diffie_hellman` +gives it). Every byte argument is checked to be a `Uint8Array` before anything else, and every options object to be an object: a string, plain array or `ArrayBuffer` is `InvalidParameter` naming the argument (it used to leak an engine `TypeError`, be silently accepted, or blame another -argument). The three `verify` functions return `false` for a malformed -signature or public key of the right length and only throw for wrong lengths -or wrong types; `ed25519.verify` is strict (canonical encodings, no -small-order key or `R`, and an uncofactored equation). Rust uses the same -equation but a more permissive public-key decoder; the difference cannot be -observed through `verify`, which is `false` on both sides for every such key. +argument). The three `verify` functions throw `InvalidData` for a public key +the reference's parser rejects (its `.expect`, a panic) and `ecdsa.verify` +for an r or s ≥ n; any input that parses is `true` or `false`. +`ed25519.verify` is strict (`verify_strict`: no small-order key or `R`, a +canonical `R` and `s`, and an uncofactored equation) and decodes the key as +dalek does, a non-canonical y reduced. ## 5. Randomness diff --git a/README.md b/README.md index 1507a34..85284bd 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ try { } ``` -Every byte argument must be a `Uint8Array` (a `Buffer` qualifies); a string, array or `ArrayBuffer` is `CryptoError` `InvalidParameter`, named after the argument, before any other check. A low-order X25519 peer is `NonContributoryKey`, with the reference's message. +Every byte argument must be a `Uint8Array` (a `Buffer` qualifies); a string, array or `ArrayBuffer` is `CryptoError` `InvalidParameter`, named after the argument, before any other check. As in the reference, a low-order X25519 peer derives one fixed key (HKDF of the all-zero secret): reject such peers yourself before deriving from an untrusted key. `memzero(bytes)` and `memzeroAll(arrays)` overwrite a typed array with zeros as a best effort. JavaScript has no volatile writes and an engine may keep copies of a buffer, so treat them as defence in depth, not as a guarantee that a key has left memory. @@ -65,7 +65,7 @@ Runnable examples live in the [`examples/`](https://github.com/BlockchainCommons ### Version History -- **Unreleased** - Every argument is type-checked before anything else (a non-`Uint8Array` byte argument is `InvalidParameter`); a low-order X25519 peer is `NonContributoryKey` with the reference's message; scrypt has no default memory ceiling and derives oversize parameter sets through a paged core on JavaScriptCore; hybrid `06`/`07` uncompressed keys compress; PBKDF2 accepts `dkLen` up to (2^32 − 1)·hLen. The Rust harness runs against the reference tree with no exception list, on the golden file, the full corpus and a heavy vector, in CI. +- **1.0.0-beta.3 (September 14, 2026)** - Every argument is type-checked before anything else (a non-`Uint8Array` byte argument is `InvalidParameter`); a low-order X25519 peer derives the reference's key; `verify` throws `InvalidData`; PBKDF2 `iterations: 0` and scrypt `logN: 0` derive; hybrid `06`/`07` uncompressed keys compress; PBKDF2 accepts `dkLen` up to (2^32 − 1)·hLen. - **1.0.0-beta.2 (September 12, 2026)** - Ed25519 uses the Rust reference's uncofactored verification equation; ChaCha20 counter-overflow reports `InvalidParameter`; scrypt validates its backend limits. scrypt mirrors the reference's parameter rules (`logN < 16·r`, `r·p < 2^30`, the parameterised path's `10..=64` output length) and gains `maxmem`; PBKDF2 accepts `dkLen: 0` with positive iterations; argon2id keeps only the reference's fixed costs. - **1.0.0-beta.1 (September 9, 2026)** - Initial beta implementation. diff --git a/RUST_DIVERGENCES.md b/RUST_DIVERGENCES.md index f9736d2..b04f61b 100644 --- a/RUST_DIVERGENCES.md +++ b/RUST_DIVERGENCES.md @@ -1,16 +1,11 @@ # Divergences from the Rust reference implementation -The reference is the `bc-rust/bc-crypto-rust` working tree: commit +The reference is the published `bc-crypto` 0.14.0 crate, as-is. Its sources +are `bc-crypto-rust` commit [`4f2b791320730578b04943c833c4a9e6c232fc4d`](https://github.com/BlockchainCommons/bc-crypto-rust/commit/4f2b791320730578b04943c833c4a9e6c232fc4d) -(tag `0.14.0`, `Cargo.toml` version 0.14.0) plus its uncommitted edits, recorded in -[`.github/versions.yml`](./.github/versions.yml): - -- `ecdsa_verify`, `schnorr_verify` and `ed25519_verify` return `false` for an unparseable public key or signature (`src/ecdsa_signing.rs`, `schnorr_signing.rs`, `ed25519_signing.rs`); -- `try_x25519_shared_key` returns `Err(Error::NonContributoryKey)` for a low-order peer, and `x25519_shared_key` panics on it (`src/public_key_encryption.rs`, `error.rs`); -- `scrypt_opt` asserts `log_n > 0` (`src/scrypt.rs`); -- `pbkdf2_hmac_sha256` and `pbkdf2_hmac_sha512` assert `iterations > 0`, including for empty output (`src/hash.rs`). - -The released `bc-crypto` 0.14.0 differs from the reference on these four points and is not the reference. `tests/rust-validation/Cargo.toml` patches `bc-crypto` to that tree (`[patch.crates-io]`), and CI reproduces the tree from `tests/rust-validation/reference/bc-crypto-rust-4f2b791-edits.patch`; when the release that contains the edits ships, the pin moves to it and nothing else changes (see Maintenance). +(tag `0.14.0`, the head of `master`), recorded in +[`.github/versions.yml`](./.github/versions.yml). `tests/rust-validation` +depends on the crate from crates.io (`bc-crypto = "=0.14.0"`). `tests/rust-validation` replays the vectors against the reference in CI: @@ -31,4 +26,4 @@ For every input the reference accepts and a JavaScript runtime can hold, outputs ## Maintenance - CI runs the harness on `vectors.json`, the full corpus and `heavy.json`, and checks the heavy vectors in TypeScript under Bun and Node. A difference is a bug on one side. Either fix it, or record it here with an input, both outcomes, the reason no TypeScript design can match, and a vector. -- The reference is the bc-rust working tree, patched into the harness with `[patch.crates-io] bc-crypto = { path = … }`; the harness prints the resolved source on stderr. `.github/workflows/upstream.yml` opens an issue when bc-crypto-rust moves. When the release that contains the four edits ships, re-pin `tests/rust-validation/Cargo.toml`, `Cargo.lock` and `.github/versions.yml` to it, and drop the patch, the `reference/` directory and the CI step that materialises the tree. No port behaviour changes with that move; the three result lines must be unchanged. The bc-components-ts and bc-envelope-ts harnesses carry the same patch and move their locks on their own schedule. +- The reference is the crates.io crate pinned in `tests/rust-validation/Cargo.toml` and `Cargo.lock`; the harness prints the resolved version and source on stderr. `.github/workflows/upstream.yml` opens an issue when bc-crypto-rust moves. When a release ships, re-pin `Cargo.toml`, `Cargo.lock` and `.github/versions.yml` to it, regenerate the vectors and run the three replays; every new difference is a bug on one side. diff --git a/api/crypto.api.md b/api/crypto.api.md index 36346e0..056edc1 100644 --- a/api/crypto.api.md +++ b/api/crypto.api.md @@ -65,11 +65,10 @@ export class CryptoError extends Error { is(code: CryptoErrorCode): boolean; static isCryptoError(value: unknown): value is CryptoError; override readonly name = "CryptoError"; - static nonContributoryKey(cause?: unknown): CryptoError; } // @public -export type CryptoErrorCode = "InvalidSize" | "InvalidData" | "InvalidParameter" | "NonContributoryKey" | "AuthenticationFailed"; +export type CryptoErrorCode = "InvalidSize" | "InvalidData" | "InvalidParameter" | "AuthenticationFailed"; // @public export type CryptoErrorDetails = { @@ -83,9 +82,6 @@ export type CryptoErrorDetails = { } | { readonly code: "InvalidParameter"; readonly what: string; -} | { - readonly code: "NonContributoryKey"; - readonly what: string; } | { readonly code: "AuthenticationFailed"; }; diff --git a/api/index.d.mts b/api/index.d.mts index 10ec331..4b2d449 100644 --- a/api/index.d.mts +++ b/api/index.d.mts @@ -1,7 +1,7 @@ import { RandomNumberGenerator, RngOptions, RngOptions as RngOptions$1 } from "@blockchaincommons/rand"; //#region src/error.d.ts /** Machine-readable discriminant for a {@link CryptoError}. */ -type CryptoErrorCode = "InvalidSize" | "InvalidData" | "InvalidParameter" | "NonContributoryKey" | "AuthenticationFailed"; +type CryptoErrorCode = "InvalidSize" | "InvalidData" | "InvalidParameter" | "AuthenticationFailed"; /** * The structured payload of a {@link CryptoError}, discriminated by `code`: * `e.details.code === "InvalidSize"` narrows to `{ what, expected, actual }`. @@ -30,14 +30,6 @@ type CryptoErrorDetails = { readonly code: "InvalidParameter"; /** The argument, e.g. `"scrypt logN"` or `"ECDSA message"`. */ readonly what: string; -} | { - /** - * `x25519.sharedKey` was given a low-order peer key, so the shared - * secret would be all zero (the reference's `Error::NonContributoryKey`). - */ - readonly code: "NonContributoryKey"; - /** The argument: `"X25519 public key"`. */ - readonly what: string; } | { /** AEAD authentication failed: wrong key, nonce or aad, or tampered data. */ readonly code: "AuthenticationFailed"; @@ -46,8 +38,8 @@ type CryptoErrorDetails = { * Thrown for wrong-length keys, nonces, signatures and public keys * (`InvalidSize`), a key, point or signature of the right length that is not * valid (`InvalidData`), an argument outside its domain, including a value - * of the wrong type (`InvalidParameter`), a low-order X25519 peer key - * (`NonContributoryKey`), and AEAD tag mismatch (`AuthenticationFailed`). + * of the wrong type (`InvalidParameter`), and AEAD tag mismatch + * (`AuthenticationFailed`). * * Every failure of an argument or of a primitive is a `CryptoError`; when a * backend error is what was caught, it is the `cause`. Two things propagate @@ -86,12 +78,6 @@ export declare class CryptoError extends Error { static invalidData(what: string, message: string, cause?: unknown): CryptoError; /** `what` (a number, an options object or a byte argument) is outside its domain. */ static invalidParameter(what: string, message: string, cause?: unknown): CryptoError; - /** - * The X25519 peer key is a low-order point, so the shared secret would be - * all zero. The message is the reference's `Error::NonContributoryKey` - * Display text. - */ - static nonContributoryKey(cause?: unknown): CryptoError; /** AEAD authentication failed (wrong key, nonce, aad, or tampered data). */ static authenticationFailed(cause?: unknown): CryptoError; } @@ -161,8 +147,9 @@ export declare function hmacSha512(key: Uint8Array, message: Uint8Array): Uint8A /** Options for the PBKDF2 functions. */ interface Pbkdf2Options { /** - * PBKDF2 iteration count; an integer in [1, 2^32 − 1]. The reference asserts - * `iterations > 0`, including for empty output, so 0 is `InvalidParameter`. + * PBKDF2 iteration count; an integer in [0, 2^32 − 1]. 0 derives what 1 + * does: the reference's `pbkdf2` 0.12.2 computes the first block of each + * output block and then `rounds − 1` more, none for 0 or 1. */ readonly iterations: number; /** @@ -177,14 +164,14 @@ interface Pbkdf2Options { * PBKDF2-HMAC-SHA-256. * @throws {CryptoError} `InvalidParameter` unless `password` and `salt` are * `Uint8Array`s, `options` is an object, `iterations` an integer in - * [1, 2^32 − 1] and `dkLen` an integer in [0, (2^32 − 1) · 32]. + * [0, 2^32 − 1] and `dkLen` an integer in [0, (2^32 − 1) · 32]. */ export declare function pbkdf2Sha256(password: Uint8Array, salt: Uint8Array, options: Pbkdf2Options): Uint8Array; /** * PBKDF2-HMAC-SHA-512. * @throws {CryptoError} `InvalidParameter` unless `password` and `salt` are * `Uint8Array`s, `options` is an object, `iterations` an integer in - * [1, 2^32 − 1] and `dkLen` an integer in [0, (2^32 − 1) · 64]. + * [0, 2^32 − 1] and `dkLen` an integer in [0, (2^32 − 1) · 64]. */ export declare function pbkdf2Sha512(password: Uint8Array, salt: Uint8Array, options: Pbkdf2Options): Uint8Array; /** Options for the HKDF functions. */ @@ -216,8 +203,8 @@ interface ScryptOptions { readonly dkLen: number; /** * log₂ of the CPU/memory cost `N`. Default 17 (N = 131072). An integer in - * [1, 32] and below `16·r`; the reference asserts `log_n > 0`, and its - * panic is `InvalidParameter` here. Values above 32 need at least 3.3 TiB. + * [0, 32] and below `16·r` (`scrypt::Params::new`); 0 is N = 1, which the + * reference derives with. Values above 32 need at least 3.3 TiB. */ readonly logN?: number | undefined; /** Block size. Default 8. */ @@ -236,7 +223,7 @@ interface ScryptOptions { /** * @throws {CryptoError} `InvalidParameter` unless `password` and `salt` are * `Uint8Array`s and `options` an object; when `dkLen` is outside its domain - * (see {@link ScryptOptions.dkLen}), `logN` not in [1, 32] or not below `16·r` + * (see {@link ScryptOptions.dkLen}), `logN` not in [0, 32] or not below `16·r` * (scrypt requires `N < 2^(128·r/8)`), `r` or `p` not ≥ 1, `r·p` not below * 2^30, or the parameters exceed `maxmem`. */ @@ -319,12 +306,12 @@ interface X25519 { publicKey(privateKey: Uint8Array): Uint8Array; /** * X25519 Diffie-Hellman, then HKDF-SHA-256 with salt "agreement" → 32 bytes - * (the reference's `try_x25519_shared_key`). - * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; - * `InvalidSize` on a wrong length; `NonContributoryKey` when `publicKey` - * is a low-order point, i.e. the shared secret would be all zero, as the - * reference's `try_x25519_shared_key` returns `Err(Error::NonContributoryKey)` - * (its `x25519_shared_key` wrapper panics on the same input). + * (the reference's `x25519_shared_key`). A low-order `publicKey` (RFC 7748 + * §6.1) gives the all-zero shared secret and so one fixed key, whatever the + * private key: the reference calls x25519-dalek's `diffie_hellman` without + * checking `was_contributory`, and neither does this. Reject such peers + * yourself before deriving from an untrusted key. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong length. */ sharedKey(privateKey: Uint8Array, publicKey: Uint8Array): Uint8Array; } @@ -370,9 +357,9 @@ interface Ecdsa { */ sign(privateKey: Uint8Array, message: Uint8Array): Uint8Array; /** - * `false` on an invalid signature, and for an unparseable key or an r or s - * ≥ n of the right length (the reference's `let Ok(..) = … else { return false; }`). - * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature. + * `false` on a signature that does not verify, r or s = 0 and a high s + * included (libsecp256k1's `secp256k1_ecdsa_verify`). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature; `InvalidData` when the key is not a point on the curve or r or s ≥ n, the reference's `.expect`ed parses (`PublicKey::from_slice`, `Signature::from_compact`). */ verify(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array): boolean; } @@ -404,9 +391,8 @@ interface Schnorr { */ sign(privateKey: Uint8Array, message: Uint8Array, options?: SchnorrSignOptions): Uint8Array; /** - * `false` on an invalid signature or an unparseable key (BIP-340 vectors - * 5–14; the reference's `let Ok(pk) = … else { return false; }`). - * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature. + * `false` on a signature that does not verify (BIP-340 vectors 6–13). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature; `InvalidData` when the key is not the x of a point on the curve (BIP-340 vectors 5 and 14), the reference's `.expect`ed `XOnlyPublicKey::from_byte_array`. */ verify(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array): boolean; } @@ -445,11 +431,12 @@ interface Ed25519 { /** * `(publicKey, signature, message)`, the same order as `ecdsa.verify` and `schnorr.verify`. * - * Uses the reference's uncofactored verification equation (`verify_strict`). - * Only canonical point encodings are accepted, and a small-order public - * key or `R` never verifies. `false` on any invalid or malformed input of - * the right length, including a key the reference cannot decode. - * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature. + * Uses the reference's uncofactored verification equation (`verify_strict`): + * `false` for a small-order key or `R`, a non-canonical `R`, an `s` ≥ L, + * or a signature that does not verify. The key is decoded as the + * reference's `VerifyingKey::from_bytes` decodes it (a non-canonical y is + * reduced), and its `.unwrap()` on a key with no point is a panic. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature; `InvalidData` when the key is not a point on the curve. */ verify(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array): boolean; } diff --git a/src/domain.ts b/src/domain.ts index b4df7d7..d44fa16 100644 --- a/src/domain.ts +++ b/src/domain.ts @@ -55,4 +55,10 @@ export const backendRejected = cause, ); +/** The wrap for a point decoder: bytes of the right length that are not a point on the curve. */ +export const invalidPoint = + (what: string) => + (cause: unknown): CryptoError => + CryptoError.invalidData(what, `${what} is not a point on the curve`, cause); + export const U32_MAX = 0xffffffff; diff --git a/src/ecdsa.ts b/src/ecdsa.ts index f06f766..0ab127b 100644 --- a/src/ecdsa.ts +++ b/src/ecdsa.ts @@ -4,6 +4,7 @@ * @module ecdsa */ import { secp256k1, schnorr as nobleSchnorr } from "@noble/curves/secp256k1.js"; +import { bytesToNumberBE } from "@noble/curves/utils.js"; import { type RandomNumberGenerator, type RngOptions, @@ -12,7 +13,7 @@ import { } from "@blockchaincommons/rand"; import { doubleSha256 } from "./hash.js"; import { CryptoError, requireBytes, requireLength, requireOptions } from "./error.js"; -import { guard } from "./domain.js"; +import { guard, invalidPoint } from "./domain.js"; const ECDSA_PRIVATE_KEY_SIZE = 32; const ECDSA_PUBLIC_KEY_SIZE = 33; @@ -25,10 +26,21 @@ const invalidScalar = (what: string) => (cause: unknown): CryptoError => CryptoError.invalidData(what, `${what} is not a valid scalar (must be in [1, n - 1])`, cause); -const invalidPoint = - (what: string) => - (cause: unknown): CryptoError => - CryptoError.invalidData(what, `${what} is not a point on the curve`, cause); + +const CURVE_ORDER = secp256k1.Point.Fn.ORDER; + +/** + * The reference's `Signature::from_compact` is `.expect`ed, and libsecp256k1's + * `secp256k1_ecdsa_signature_parse_compact` fails only when r or s overflows + * n: that is `InvalidData` here. r or s = 0 parses, and then does not verify. + */ +function requireCompactScalars(what: string, signature: Uint8Array): void { + const r = bytesToNumberBE(signature.subarray(0, 32)); + const s = bytesToNumberBE(signature.subarray(32)); + if (r >= CURVE_ORDER || s >= CURVE_ORDER) { + throw CryptoError.invalidData(what, `${what} has r or s outside [0, n - 1]`); + } +} /** The shape of the {@link ecdsa} family. */ export interface Ecdsa { @@ -68,9 +80,9 @@ export interface Ecdsa { */ sign(privateKey: Uint8Array, message: Uint8Array): Uint8Array; /** - * `false` on an invalid signature, and for an unparseable key or an r or s - * ≥ n of the right length (the reference's `let Ok(..) = … else { return false; }`). - * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature. + * `false` on a signature that does not verify, r or s = 0 and a high s + * included (libsecp256k1's `secp256k1_ecdsa_verify`). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature; `InvalidData` when the key is not a point on the curve or r or s ≥ n, the reference's `.expect`ed parses (`PublicKey::from_slice`, `Signature::from_compact`). */ verify(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array): boolean; } @@ -146,6 +158,10 @@ export const ecdsa: Ecdsa = { requireLength("ECDSA public key", publicKey, ECDSA_PUBLIC_KEY_SIZE); requireLength("ECDSA signature", signature, ECDSA_SIGNATURE_SIZE); requireBytes("ECDSA message", message); + // The reference parses with `.expect`, the key first: an undecodable key + // or an r or s ≥ n is a panic. A parsed pair that does not verify is `false`. + guard(() => secp256k1.Point.fromBytes(publicKey), invalidPoint("ECDSA public key")); + requireCompactScalars("ECDSA signature", signature); try { return secp256k1.verify(signature, doubleSha256(message), publicKey, { prehash: false, @@ -188,9 +204,8 @@ export interface Schnorr { options?: SchnorrSignOptions, ): Uint8Array; /** - * `false` on an invalid signature or an unparseable key (BIP-340 vectors - * 5–14; the reference's `let Ok(pk) = … else { return false; }`). - * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature. + * `false` on a signature that does not verify (BIP-340 vectors 6–13). + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature; `InvalidData` when the key is not the x of a point on the curve (BIP-340 vectors 5 and 14), the reference's `.expect`ed `XOnlyPublicKey::from_byte_array`. */ verify(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array): boolean; } @@ -224,6 +239,13 @@ export const schnorr: Schnorr = { requireLength("Schnorr public key", publicKey, SCHNORR_PUBLIC_KEY_SIZE); requireLength("Schnorr signature", signature, SCHNORR_SIGNATURE_SIZE); requireBytes("Schnorr message", message); + // The reference `.expect`s the key (`secp256k1_xonly_pubkey_parse`: x < p + // and on the curve, which is what `02 ‖ x` decodes under); any 64 bytes + // are a signature, which then verifies or not. + guard( + () => secp256k1.Point.fromBytes(Uint8Array.of(0x02, ...publicKey)), + invalidPoint("Schnorr public key"), + ); try { return nobleSchnorr.verify(signature, message, publicKey); } catch { diff --git a/src/ed25519.ts b/src/ed25519.ts index 88e77a8..c455b1b 100644 --- a/src/ed25519.ts +++ b/src/ed25519.ts @@ -14,6 +14,7 @@ import { secureRng, } from "@blockchaincommons/rand"; import { requireBytes, requireLength, requireOptions } from "./error.js"; +import { guard, invalidPoint } from "./domain.js"; const ED25519_PUBLIC_KEY_SIZE = 32; const ED25519_PRIVATE_KEY_SIZE = 32; @@ -50,11 +51,12 @@ export interface Ed25519 { /** * `(publicKey, signature, message)`, the same order as `ecdsa.verify` and `schnorr.verify`. * - * Uses the reference's uncofactored verification equation (`verify_strict`). - * Only canonical point encodings are accepted, and a small-order public - * key or `R` never verifies. `false` on any invalid or malformed input of - * the right length, including a key the reference cannot decode. - * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature. + * Uses the reference's uncofactored verification equation (`verify_strict`): + * `false` for a small-order key or `R`, a non-canonical `R`, an `s` ≥ L, + * or a signature that does not verify. The key is decoded as the + * reference's `VerifyingKey::from_bytes` decodes it (a non-canonical y is + * reduced), and its `.unwrap()` on a key with no point is a panic. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong-length key or signature; `InvalidData` when the key is not a point on the curve. */ verify(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array): boolean; } @@ -103,14 +105,18 @@ export const ed25519: Ed25519 = { requireLength("Ed25519 public key", publicKey, ED25519_PUBLIC_KEY_SIZE); requireLength("Ed25519 signature", signature, ED25519_SIGNATURE_SIZE); requireBytes("Ed25519 message", message); + // dalek's `CompressedEdwardsY::decompress` reduces a non-canonical y and + // fails only when the reduced y has no x: noble's `zip215 = true` rule. + // The reference `.unwrap()`s it. + const a = guard( + () => noble.Point.fromBytes(publicKey, true), + invalidPoint("Ed25519 public key"), + ); try { - // Canonical decoding (zip215 = false) of the key and of R, then the - // small-order checks `verify_strict` makes, then the equation. The - // reference's decoder reduces a non-canonical y instead; the outcome is - // the same `false`, because an undecodable key is `false` on both sides - // and a decodable non-canonical key is small-order (rejected below and - // by `verify_strict`) or a point whose discrete logarithm nobody knows. - const a = noble.Point.fromBytes(publicKey, false); + // Canonical decoding (zip215 = false) of R, then the small-order checks + // `verify_strict` makes, then the equation. A decodable non-canonical + // key is small-order (rejected below and by `verify_strict`) or a point + // whose discrete logarithm nobody knows, so it is `false` on both sides. const r = noble.Point.fromBytes(signature.subarray(0, 32), false); if (a.isSmallOrder() || r.isSmallOrder()) return false; const order = noble.Point.Fn.ORDER; diff --git a/src/error.ts b/src/error.ts index 723825d..7c60f07 100644 --- a/src/error.ts +++ b/src/error.ts @@ -7,11 +7,7 @@ import { isBytes } from "@noble/hashes/utils.js"; /** Machine-readable discriminant for a {@link CryptoError}. */ export type CryptoErrorCode = - | "InvalidSize" - | "InvalidData" - | "InvalidParameter" - | "NonContributoryKey" - | "AuthenticationFailed"; + "InvalidSize" | "InvalidData" | "InvalidParameter" | "AuthenticationFailed"; /** * The structured payload of a {@link CryptoError}, discriminated by `code`: @@ -45,15 +41,6 @@ export type CryptoErrorDetails = /** The argument, e.g. `"scrypt logN"` or `"ECDSA message"`. */ readonly what: string; } - | { - /** - * `x25519.sharedKey` was given a low-order peer key, so the shared - * secret would be all zero (the reference's `Error::NonContributoryKey`). - */ - readonly code: "NonContributoryKey"; - /** The argument: `"X25519 public key"`. */ - readonly what: string; - } | { /** AEAD authentication failed: wrong key, nonce or aad, or tampered data. */ readonly code: "AuthenticationFailed"; @@ -63,8 +50,8 @@ export type CryptoErrorDetails = * Thrown for wrong-length keys, nonces, signatures and public keys * (`InvalidSize`), a key, point or signature of the right length that is not * valid (`InvalidData`), an argument outside its domain, including a value - * of the wrong type (`InvalidParameter`), a low-order X25519 peer key - * (`NonContributoryKey`), and AEAD tag mismatch (`AuthenticationFailed`). + * of the wrong type (`InvalidParameter`), and AEAD tag mismatch + * (`AuthenticationFailed`). * * Every failure of an argument or of a primitive is a `CryptoError`; when a * backend error is what was caught, it is the `cause`. Two things propagate @@ -129,19 +116,6 @@ export class CryptoError extends Error { return new CryptoError(message, { code: "InvalidParameter", what }, cause); } - /** - * The X25519 peer key is a low-order point, so the shared secret would be - * all zero. The message is the reference's `Error::NonContributoryKey` - * Display text. - */ - static nonContributoryKey(cause?: unknown): CryptoError { - return new CryptoError( - "X25519 peer key produces an all-zero shared secret", - { code: "NonContributoryKey", what: "X25519 public key" }, - cause, - ); - } - /** AEAD authentication failed (wrong key, nonce, aad, or tampered data). */ static authenticationFailed(cause?: unknown): CryptoError { // The reference's `Error::Aead` displays as "AEAD error". diff --git a/src/hash.ts b/src/hash.ts index 26cf6be..8762697 100644 --- a/src/hash.ts +++ b/src/hash.ts @@ -110,8 +110,9 @@ export function hmacSha512(key: Uint8Array, message: Uint8Array): Uint8Array 0`, including for empty output, so 0 is `InvalidParameter`. + * PBKDF2 iteration count; an integer in [0, 2^32 − 1]. 0 derives what 1 + * does: the reference's `pbkdf2` 0.12.2 computes the first block of each + * output block and then `rounds − 1` more, none for 0 or 1. */ readonly iterations: number; /** @@ -124,9 +125,9 @@ export interface Pbkdf2Options { } const pbkdf2Domain = (options: Pbkdf2Options, hLen: number): { c: number; dkLen: number } => ({ - // Checked before the empty-output return: the reference's `assert!(iterations > 0)` - // comes before its allocation, so it fires for empty output too. - c: expectInt("pbkdf2 iterations", options.iterations, 1, U32_MAX), + // 0 iterations is 1 in the reference's crate (`for _ in 1..rounds` after the + // first block); noble requires c ≥ 1, so the same call is made for both. + c: Math.max(1, expectInt("pbkdf2 iterations", options.iterations, 0, U32_MAX)), // `dkLen: 0` is an empty key on both sides (the reference fills a zero-length Vec). dkLen: expectInt("pbkdf2 dkLen", options.dkLen, 0, U32_MAX * hLen), }); @@ -135,7 +136,7 @@ const pbkdf2Domain = (options: Pbkdf2Options, hLen: number): { c: number; dkLen: * PBKDF2-HMAC-SHA-256. * @throws {CryptoError} `InvalidParameter` unless `password` and `salt` are * `Uint8Array`s, `options` is an object, `iterations` an integer in - * [1, 2^32 − 1] and `dkLen` an integer in [0, (2^32 − 1) · 32]. + * [0, 2^32 − 1] and `dkLen` an integer in [0, (2^32 − 1) · 32]. */ export function pbkdf2Sha256( password: Uint8Array, @@ -157,7 +158,7 @@ export function pbkdf2Sha256( * PBKDF2-HMAC-SHA-512. * @throws {CryptoError} `InvalidParameter` unless `password` and `salt` are * `Uint8Array`s, `options` is an object, `iterations` an integer in - * [1, 2^32 − 1] and `dkLen` an integer in [0, (2^32 − 1) · 64]. + * [0, 2^32 − 1] and `dkLen` an integer in [0, (2^32 − 1) · 64]. */ export function pbkdf2Sha512( password: Uint8Array, diff --git a/src/kdf.ts b/src/kdf.ts index 33ba597..2c06113 100644 --- a/src/kdf.ts +++ b/src/kdf.ts @@ -25,8 +25,8 @@ export interface ScryptOptions { readonly dkLen: number; /** * log₂ of the CPU/memory cost `N`. Default 17 (N = 131072). An integer in - * [1, 32] and below `16·r`; the reference asserts `log_n > 0`, and its - * panic is `InvalidParameter` here. Values above 32 need at least 3.3 TiB. + * [0, 32] and below `16·r` (`scrypt::Params::new`); 0 is N = 1, which the + * reference derives with. Values above 32 need at least 3.3 TiB. */ readonly logN?: number | undefined; /** Block size. Default 8. */ @@ -49,7 +49,7 @@ const SCRYPT_MAX_DKLEN = 0xffffffff * 32; /** * @throws {CryptoError} `InvalidParameter` unless `password` and `salt` are * `Uint8Array`s and `options` an object; when `dkLen` is outside its domain - * (see {@link ScryptOptions.dkLen}), `logN` not in [1, 32] or not below `16·r` + * (see {@link ScryptOptions.dkLen}), `logN` not in [0, 32] or not below `16·r` * (scrypt requires `N < 2^(128·r/8)`), `r` or `p` not ≥ 1, `r·p` not below * 2^30, or the parameters exceed `maxmem`. */ @@ -66,7 +66,7 @@ export function scrypt( const dkLen = parameterised ? expectInt("scrypt dkLen", options.dkLen, 10, 64) : expectInt("scrypt dkLen", options.dkLen, 1, SCRYPT_MAX_DKLEN); - const logN = expectInt("scrypt logN", options.logN ?? 17, 1, 32); + const logN = expectInt("scrypt logN", options.logN ?? 17, 0, 32); const r = expectInt("scrypt r", options.r ?? 8, 1, U32_MAX); const p = expectInt("scrypt p", options.p ?? 1, 1, U32_MAX); // The two shape rules of RFC 7914 §2 that the reference's `scrypt::Params::new` enforces. @@ -87,9 +87,10 @@ export function scrypt( : expectInt("scrypt maxmem", options.maxmem, 1, Number.MAX_SAFE_INTEGER); const N = 2 ** logN; const blockBytes = 128 * r; - if (blockBytes * N > SINGLE_BUFFER_LIMIT || blockBytes * p > SINGLE_BUFFER_LIMIT) { - // Too large for one typed array on JavaScriptCore: the paged core, with - // the same `maxmem` rule and error shape as noble's. + if (N === 1 || blockBytes * N > SINGLE_BUFFER_LIMIT || blockBytes * p > SINGLE_BUFFER_LIMIT) { + // The paged core: for a working buffer too large for one typed array on + // JavaScriptCore, and for N = 1, which noble refuses (`2^1 <= N`) and the + // reference's crate runs. Same `maxmem` rule and error shape as noble's. const memUsed = blockBytes * (N + p + 1); if (memUsed > maxmem) { throw backendRejected("scrypt parameters")( diff --git a/src/scrypt-core.ts b/src/scrypt-core.ts index 221d21f..c140db5 100644 --- a/src/scrypt-core.ts +++ b/src/scrypt-core.ts @@ -19,7 +19,7 @@ import { rotl, swap32IfBE } from "@noble/hashes/utils.js"; /** Parameters for {@link scryptCore}; already validated by the caller. */ export interface ScryptCoreParams { - /** CPU/memory cost, a power of two in [2, 2^32]. */ + /** CPU/memory cost, a power of two in [1, 2^32] (N = 1: one block of `V`, mixed once). */ readonly N: number; /** Block size factor ≥ 1. */ readonly r: number; diff --git a/src/x25519.ts b/src/x25519.ts index b2611c4..f4d1649 100644 --- a/src/x25519.ts +++ b/src/x25519.ts @@ -6,12 +6,15 @@ import { x25519 as noble } from "@noble/curves/ed25519.js"; import { type RngOptions, randomBytes, secureRng } from "@blockchaincommons/rand"; import { hkdfSha256 } from "./hash.js"; -import { CryptoError, requireBytes, requireLength, requireOptions } from "./error.js"; -import { guard } from "./domain.js"; +import { requireBytes, requireLength, requireOptions } from "./error.js"; +import { backendRejected } from "./domain.js"; const X25519_PRIVATE_KEY_SIZE = 32; const X25519_PUBLIC_KEY_SIZE = 32; +/** noble's message for a low-order peer, the one throw `getSharedSecret` has for 32-byte arguments. */ +const NOBLE_LOW_ORDER_PEER = "invalid private or public key received"; + // The HKDF salts are wire: every derived key in the stack depends on them. const textEncoder = new TextEncoder(); const AGREEMENT_SALT = textEncoder.encode("agreement"); @@ -58,12 +61,12 @@ export interface X25519 { publicKey(privateKey: Uint8Array): Uint8Array; /** * X25519 Diffie-Hellman, then HKDF-SHA-256 with salt "agreement" → 32 bytes - * (the reference's `try_x25519_shared_key`). - * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; - * `InvalidSize` on a wrong length; `NonContributoryKey` when `publicKey` - * is a low-order point, i.e. the shared secret would be all zero, as the - * reference's `try_x25519_shared_key` returns `Err(Error::NonContributoryKey)` - * (its `x25519_shared_key` wrapper panics on the same input). + * (the reference's `x25519_shared_key`). A low-order `publicKey` (RFC 7748 + * §6.1) gives the all-zero shared secret and so one fixed key, whatever the + * private key: the reference calls x25519-dalek's `diffie_hellman` without + * checking `was_contributory`, and neither does this. Reject such peers + * yourself before deriving from an untrusted key. + * @throws {CryptoError} `InvalidParameter` on a non-`Uint8Array`; `InvalidSize` on a wrong length. */ sharedKey(privateKey: Uint8Array, publicKey: Uint8Array): Uint8Array; } @@ -86,13 +89,20 @@ export const x25519: X25519 = { sharedKey(privateKey, publicKey) { requireLength("X25519 private key", privateKey, X25519_PRIVATE_KEY_SIZE); requireLength("X25519 public key", publicKey, X25519_PUBLIC_KEY_SIZE); - // Both arguments are 32 bytes here and every 32-byte private key is - // clamped and valid, so noble's only failure is its low-order set, which - // equals dalek's `!was_contributory()`: the reference's `NonContributoryKey`. - const secret = guard( - () => noble.getSharedSecret(privateKey, publicKey), - (cause) => CryptoError.nonContributoryKey(cause), - ); + // noble rejects the low-order peers before its ladder; the set is exactly + // the u values the ladder sends to zero, which x25519-dalek's unchecked + // `diffie_hellman` (the reference) returns as the all-zero secret. Both + // arguments are 32 bytes and every 32-byte private key is clamped, so + // that rejection is noble's only throw here; anything else is a fault. + let secret: Uint8Array; + try { + secret = noble.getSharedSecret(privateKey, publicKey); + } catch (e) { + if (!(e instanceof Error && e.message === NOBLE_LOW_ORDER_PEER)) { + throw backendRejected("X25519 public key")(e); + } + secret = new Uint8Array(X25519_PUBLIC_KEY_SIZE); + } return hkdfSha256(secret, AGREEMENT_SALT, { dkLen: 32 }); }, }; diff --git a/tests/__snapshots__/golden.test.ts.snap b/tests/__snapshots__/golden.test.ts.snap index 45b96a6..9a96bb3 100644 --- a/tests/__snapshots__/golden.test.ts.snap +++ b/tests/__snapshots__/golden.test.ts.snap @@ -13,10 +13,10 @@ exports[`golden: edge cases > domain faults are reported as CryptoError 1`] = ` "hkdfSha256 length 1.5": "throw:CryptoError:hkdf dkLen must be an integer in [0, 8160], got 1.5", "hkdfSha256 length 8161": "throw:CryptoError:hkdf dkLen must be an integer in [0, 8160], got 8161", "pbkdf2Sha256 dkLen 0": "", - "pbkdf2Sha256 iterations 0": "throw:CryptoError:pbkdf2 iterations must be an integer in [1, 4294967295], got 0", + "pbkdf2Sha256 iterations -1": "throw:CryptoError:pbkdf2 iterations must be an integer in [0, 4294967295], got -1", "schnorr.publicKey(0)": "throw:CryptoError:Schnorr private key is not a valid scalar (must be in [1, n - 1])", "scrypt dkLen 0": "throw:CryptoError:scrypt dkLen must be an integer in [1, 137438953440], got 0", - "scrypt logN 0": "throw:CryptoError:scrypt logN must be an integer in [1, 32], got 0", + "scrypt logN -1": "throw:CryptoError:scrypt logN must be an integer in [0, 32], got -1", "scrypt r 1.5": "throw:CryptoError:scrypt r must be an integer in [1, 4294967295], got 1.5", } `; @@ -36,24 +36,32 @@ exports[`golden: edge cases > scrypt output length below the reference's opt bou ] `; -exports[`golden: edge cases > verify on malformed keys and signatures of the right length (false on both sides) 1`] = ` +exports[`golden: edge cases > verify on malformed keys and signatures of the right length (false, or InvalidData where the reference's parse panics) 1`] = ` [ - "false", - "false", - "false", + "throw:CryptoError:ECDSA signature has r or s outside [0, n - 1]", + "throw:CryptoError:ECDSA public key is not a point on the curve", + "throw:CryptoError:Schnorr public key is not a point on the curve", "false", "false", "false", ] `; -exports[`golden: edge cases > x25519.sharedKey with a low-order public key (the reference's NonContributoryKey) 1`] = ` +exports[`golden: edge cases > x25519.sharedKey with a low-order public key (the reference's all-zero secret: one key) 1`] = ` [ - "throw:CryptoError:X25519 peer key produces an all-zero shared secret", - "throw:CryptoError:X25519 peer key produces an all-zero shared secret", + "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e", + "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e", ] `; +exports[`golden: edge cases > zero KDF costs derive, as the reference's crates do (scrypt N = 1; PBKDF2 0 rounds as 1) 1`] = ` +{ + "pbkdf2Sha256 iterations 0": "09d8b84188254ca4b82c68202c11d368569d655b6aa0c1909344534394a05a33", + "pbkdf2Sha256 iterations 1": "09d8b84188254ca4b82c68202c11d368569d655b6aa0c1909344534394a05a33", + "scrypt logN 0": "5a81ca47d8c099b1c6e0d5c191ea5d81d1775626fe9ca10acf2fae5f3c28975e", +} +`; + exports[`golden: hashes > hmac (1) 1`] = ` [ "a86ce6b2ab762e74d14bf0329723c70e6a409252ae7feb1b8108da3d782d9b66", diff --git a/tests/corpus/corpus.ts b/tests/corpus/corpus.ts index 9e3972d..a0b3853 100644 --- a/tests/corpus/corpus.ts +++ b/tests/corpus/corpus.ts @@ -245,8 +245,8 @@ const FF64 = "ff".repeat(64); * Every encoding of a low-order point (RFC 7748 §6.1, little-endian): 0, 1, * the two order-8 points, p − 1, p, p + 1, and (bit 255 is masked on both * sides) two of them with the high bit set. The reference's - * `try_x25519_shared_key` returns `Err(NonContributoryKey)` for every one; - * the port throws `CryptoError` `NonContributoryKey` with the same message. + * `x25519_shared_key` (x25519-dalek's unchecked `diffie_hellman`) gets the + * all-zero secret for every one and derives the same key; so does the port. */ export const X25519_LOW_ORDER: string[] = [ "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/tests/crypto.property.test.ts b/tests/crypto.property.test.ts index e2df62e..7e76866 100644 --- a/tests/crypto.property.test.ts +++ b/tests/crypto.property.test.ts @@ -125,11 +125,12 @@ describe("faults and strict verification", () => { () => c.schnorr.sign(zero, pw, { auxRand: k32 }), () => c.ecdsa.decompressPublicKey(Uint8Array.from([2, ...new Uint8Array(32).fill(0xff)])), () => c.ecdsa.compressPublicKey(Uint8Array.from([4, ...new Uint8Array(64).fill(1)])), - () => c.x25519.sharedKey(k32, zero), + () => c.ecdsa.verify(Uint8Array.from([2, ...new Uint8Array(32).fill(0xff)]), salt, pw), () => c.scrypt(pw, salt, { dkLen: 0 }), - () => c.scrypt(pw, salt, { dkLen: 32, logN: 0 }), + () => c.scrypt(pw, salt, { dkLen: 32, logN: -1 }), () => c.scrypt(pw, salt, { dkLen: 32, logN: 64 }), () => c.scrypt(pw, salt, { dkLen: 32, r: 1.5 }), + () => c.scrypt(pw, salt, { dkLen: 32, r: 0 }), () => c.scrypt(pw, salt, { dkLen: 32, p: 0 }), () => c.scrypt(pw, salt, { dkLen: 32, logN: 4, maxmem: 1 }), // in domain; the backend's memory limit () => c.scrypt(pw, salt, { dkLen: 32, logN: 17, r: 1 }), // RFC 7914: logN must be below 16·r @@ -142,16 +143,17 @@ describe("faults and strict verification", () => { () => c.hkdfSha256(salt, salt, { dkLen: 8161 }), () => c.hkdfSha256(salt, salt, { dkLen: 1.5 }), () => c.hkdfSha512(salt, salt, { dkLen: -1 }), - () => c.pbkdf2Sha256(pw, salt, { iterations: 0, dkLen: 32 }), + () => c.pbkdf2Sha256(pw, salt, { iterations: -1, dkLen: 32 }), () => c.chacha20(k32, salt.subarray(0, 12), pw, { counter: -1 }), () => c.chacha20(k32, salt.subarray(0, 12), pw, { counter: 2 ** 32 }), ]; // Every entry must throw, and what it throws must be a CryptoError. expect(faults.map(isCryptoError)).toEqual(faults.map(() => true)); - // Generated: integers outside the KDF domains and non-integers. + // Generated: integers outside the KDF domains (0 iterations and logN 0 are + // inside them, as in the reference's crates) and non-integers. fc.assert( fc.property( - fc.oneof(fc.integer({ max: 0 }), fc.double({ noInteger: true, noNaN: true })), + fc.oneof(fc.integer({ max: -1 }), fc.double({ noInteger: true, noNaN: true })), (bad) => isCryptoError(() => c.pbkdf2Sha256(pw, salt, { iterations: bad, dkLen: 32 })) && isCryptoError(() => c.scrypt(pw, salt, { dkLen: 32, logN: 4, r: bad })) && @@ -223,9 +225,11 @@ describe("faults and strict verification", () => { { numRuns: 50 }, ); }); - it("x25519.sharedKey rejects every low-order encoding with NonContributoryKey, the reference's message", () => { + it("x25519.sharedKey derives one fixed key from every low-order encoding: the reference's all-zero secret", () => { // The nine RFC 7748 §6.1 encodings and the other five high-bit variants of the same - // seven u values; anything else agrees with noble's ladder. + // seven u values; anything else agrees with noble's ladder. The reference's + // `x25519_shared_key` never checks the peer: the ladder gives the all-zero + // secret, and HKDF-SHA-256 of it with salt "agreement" is this key. const lowOrder = [ "0000000000000000000000000000000000000000000000000000000000000000", "0100000000000000000000000000000000000000000000000000000000000000", @@ -242,23 +246,15 @@ describe("faults and strict verification", () => { "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", ].map((h) => Uint8Array.from(Buffer.from(h, "hex"))); + const hex = (b: Uint8Array): string => Buffer.from(b).toString("hex"); + // The reference's value (`tests/rust-validation`, bc-crypto 0.14.0). + const ZERO_SECRET_KEY = "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e"; + expect( + hex(c.hkdfSha256(new Uint8Array(32), new TextEncoder().encode("agreement"), { dkLen: 32 })), + ).toBe(ZERO_SECRET_KEY); fc.assert( fc.property(key, (priv) => - lowOrder.every((pub) => { - try { - c.x25519.sharedKey(priv, pub); - return false; - } catch (e) { - return ( - c.CryptoError.isCryptoError(e) && - e.code === "NonContributoryKey" && - e.details.code === "NonContributoryKey" && - e.details.what === "X25519 public key" && - e.message === "X25519 peer key produces an all-zero shared secret" && - e.cause instanceof Error - ); - } - }), + lowOrder.every((pub) => hex(c.x25519.sharedKey(priv, pub)) === ZERO_SECRET_KEY), ), { numRuns: 50 }, ); @@ -513,11 +509,27 @@ describe("argument types: every byte, options and boolean argument is checked fi }); }); -describe("verify never throws for inputs of the right length", () => { - // The reference returns `false` for an unparseable key or signature - // (`let Ok(..) = … else { return false; }`), so does the port. - const isBool = (f: () => unknown): boolean => typeof f() === "boolean"; - it("ecdsa, schnorr and ed25519 return a boolean for random keys and signatures", () => { +describe("verify returns a boolean, or InvalidData where the reference's parse panics", () => { + // The reference `.expect`s its public-key parses and ECDSA's compact + // signature parse (a panic); an input that parses then verifies or not. + const PARSED = [ + "ECDSA public key", + "ECDSA signature", + "Schnorr public key", + "Ed25519 public key", + ]; + const outcome = (f: () => unknown): boolean => { + try { + return typeof f() === "boolean"; + } catch (e) { + return ( + c.CryptoError.isCryptoError(e) && + e.details.code === "InvalidData" && + PARSED.includes(e.details.what) + ); + } + }; + it("ecdsa, schnorr and ed25519, for random keys and signatures of the right length", () => { const k33 = fc.uint8Array({ minLength: 33, maxLength: 33 }); const k32 = fc.uint8Array({ minLength: 32, maxLength: 32 }); const sig = fc.uint8Array({ minLength: 64, maxLength: 64 }); @@ -527,7 +539,7 @@ describe("verify never throws for inputs of the right length", () => { () => c.ecdsa.verify(ecPub, s, m), () => c.schnorr.verify(pub32, s, m), () => c.ed25519.verify(pub32, s, m), - ].every(isBool), + ].every(outcome), ), { numRuns: 200 }, ); @@ -566,7 +578,7 @@ describe("KDF domains mirrored from the reference's crates", () => { it("pbkdf2: dkLen 0 is an empty key, as the reference returns", () => { expect(c.pbkdf2Sha256(pw, salt, { iterations: 1, dkLen: 0 })).toEqual(new Uint8Array(0)); expect(c.pbkdf2Sha512(pw, salt, { iterations: 7, dkLen: 0 })).toEqual(new Uint8Array(0)); - expect(() => c.pbkdf2Sha256(pw, salt, { iterations: 0, dkLen: 0 })).toThrow("iterations"); + expect(c.pbkdf2Sha256(pw, salt, { iterations: 0, dkLen: 0 })).toEqual(new Uint8Array(0)); }); it("argon2id: the reference's fixed costs, no knobs", () => { // Argon2::default() — the cross-platform vector in crypto.test.ts pins the bytes. diff --git a/tests/crypto.test.ts b/tests/crypto.test.ts index 78d5dec..d706921 100644 --- a/tests/crypto.test.ts +++ b/tests/crypto.test.ts @@ -445,7 +445,8 @@ describe("Schnorr", () => { expect(isValid).toBe(true); }); - // BIP-340 Test Vector 5 - public key not on the curve + // BIP-340 Test Vector 5 - public key not on the curve. The reference + // `.expect`s `XOnlyPublicKey::from_byte_array` (a panic): InvalidData. test("test_bip340_vector_5", () => { const publicKey = hexToBytes( "EEFDEA4CDB677750A420FEE807EACF21EB9898AE79B9768766E4FAA04A2D4A34", @@ -455,7 +456,9 @@ describe("Schnorr", () => { "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E17776969E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", ); - expect(schnorr.verify(publicKey, signature, message)).toBe(false); + expect(() => schnorr.verify(publicKey, signature, message)).toThrow( + "Schnorr public key is not a point on the curve", + ); }); // BIP-340 Test Vector 6 - has_even_y(R) is false @@ -570,7 +573,8 @@ describe("Schnorr", () => { expect(isValid).toBe(false); }); - // BIP-340 Test Vector 14 - public key is not a valid X coordinate + // BIP-340 Test Vector 14 - public key is not a valid X coordinate (x ≥ p). + // The reference `.expect`s `XOnlyPublicKey::from_byte_array` (a panic): InvalidData. test("test_bip340_vector_14", () => { const publicKey = hexToBytes( "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30", @@ -580,7 +584,9 @@ describe("Schnorr", () => { "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E17776969E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", ); - expect(schnorr.verify(publicKey, signature, message)).toBe(false); + expect(() => schnorr.verify(publicKey, signature, message)).toThrow( + "Schnorr public key is not a point on the curve", + ); }); // BIP-340 Test Vector 15 - empty message @@ -939,45 +945,64 @@ describe("CryptoError", () => { expect((e as CryptoError).code).toBe("AuthenticationFailed"); } }); - test("verify returns false and never throws for malformed keys and signatures of the right length", () => { - // The reference's `let Ok(..) = … else { return false; }` on every parse failure. + test("verify: a parsed input is a boolean; a key the reference's parser rejects is InvalidData", () => { + // The reference `.expect`s its public-key parses and ECDSA's compact + // signature parse, so those inputs are panics; everything else is a `bool`. const priv = new Uint8Array(32).fill(7); const msg = new Uint8Array(0); const bad = new Uint8Array(64).fill(0xff); const ff32 = new Uint8Array(32).fill(0xff); const hex = (h: string): Uint8Array => Uint8Array.from(Buffer.from(h, "hex")); - // signatures - expect(ecdsa.verify(ecdsa.publicKey(priv), bad, msg)).toBe(false); + const invalidData = (f: () => unknown, what: string): void => { + let err: unknown; + try { + f(); + } catch (e) { + err = e; + } + expect( + CryptoError.isCryptoError(err) && err.details.code === "InvalidData" && err.details.what, + ).toBe(what); + }; + // Any 64 bytes are a Schnorr or Ed25519 signature, which then fails; ECDSA's + // compact parse overflows for r or s ≥ n. expect(schnorr.verify(schnorr.publicKey(priv), bad, msg)).toBe(false); expect(ed25519.verify(ed25519.publicKey(priv), bad, msg)).toBe(false); + invalidData(() => ecdsa.verify(ecdsa.publicKey(priv), bad, msg), "ECDSA signature"); // keys the parsers reject: x ≥ p (ECDSA, Schnorr) and an undecodable Ed25519 y const ecSig = ecdsa.sign(priv, msg); - expect(ecdsa.verify(Uint8Array.from([2, ...ff32]), ecSig, msg)).toBe(false); - expect( - ecdsa.verify( - hex("02fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc30"), - ecSig, - msg, - ), - ).toBe(false); - expect( - schnorr.verify(ff32, schnorr.sign(priv, msg, { auxRand: new Uint8Array(32) }), msg), - ).toBe(false); + invalidData(() => ecdsa.verify(Uint8Array.from([2, ...ff32]), ecSig, msg), "ECDSA public key"); + invalidData( + () => + ecdsa.verify( + hex("02fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc30"), + ecSig, + msg, + ), + "ECDSA public key", + ); + invalidData( + () => schnorr.verify(ff32, schnorr.sign(priv, msg, { auxRand: new Uint8Array(32) }), msg), + "Schnorr public key", + ); // y = p + 2 with the sign bit clear: no square root, so dalek and noble both fail to decode. - expect( - ed25519.verify( - hex("efffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"), - ed25519.sign(priv, msg), - msg, - ), - ).toBe(false); - // r or s in {0, n} + invalidData( + () => + ed25519.verify( + hex("efffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"), + ed25519.sign(priv, msg), + msg, + ), + "Ed25519 public key", + ); + // r or s = 0 parses and fails to verify; r or s = n overflows the parse const n = "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"; const one = "00".repeat(31) + "01"; const zero = "00".repeat(32); - for (const sig of [zero + one, n + one, one + n, one + zero]) { - expect(ecdsa.verify(ecdsa.publicKey(priv), hex(sig), msg)).toBe(false); - } + expect(ecdsa.verify(ecdsa.publicKey(priv), hex(zero + one), msg)).toBe(false); + expect(ecdsa.verify(ecdsa.publicKey(priv), hex(one + zero), msg)).toBe(false); + invalidData(() => ecdsa.verify(ecdsa.publicKey(priv), hex(n + one), msg), "ECDSA signature"); + invalidData(() => ecdsa.verify(ecdsa.publicKey(priv), hex(one + n), msg), "ECDSA signature"); }); }); diff --git a/tests/differential.test.ts b/tests/differential.test.ts index ff00968..84c6738 100644 --- a/tests/differential.test.ts +++ b/tests/differential.test.ts @@ -14,12 +14,57 @@ import * as baselineMod from "./baseline/crypto-baseline.mjs"; import * as randBaseline from "./baseline/rand-baseline.mjs"; import * as src from "../src"; import * as rand from "@blockchaincommons/rand"; -import { materialize, baselineAdapterFor, currentAdapterFor, type Recipe } from "./vectors/recipes"; -import { categories, noBaseline } from "./corpus/corpus"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { ed25519 as nobleEd25519 } from "@noble/curves/ed25519.js"; +import { bytesToNumberBE } from "@noble/curves/utils.js"; +import { + materialize, + materializeBytes, + baselineAdapterFor, + currentAdapterFor, + type Bytes, + type Recipe, +} from "./vectors/recipes"; +import { + categories, + noBaseline, + X25519_LOW_ORDER, + X25519_LOW_ORDER_HIGH_BIT, +} from "./corpus/corpus"; const here = dirname(fileURLToPath(import.meta.url)); const BASELINE_SHA256 = "d3a5a82546fd0424232ba32ea1c1bd485e08f35f3f241edc90c8476fb1559655"; +/** The reference's key parses, `.expect`ed there (a panic): what the tree reports as `InvalidData`. */ +const parses = (f: () => unknown): boolean => { + try { + f(); + return true; + } catch { + return false; + } +}; +const secpKeyParses = (b: Bytes): boolean => { + const k = materializeBytes(b); + return k.length !== 33 || parses(() => secp256k1.Point.fromBytes(k)); +}; +const xOnlyKeyParses = (b: Bytes): boolean => { + const k = materializeBytes(b); + return k.length !== 32 || parses(() => secp256k1.Point.fromBytes(Uint8Array.of(2, ...k))); +}; +const ed25519KeyParses = (b: Bytes): boolean => { + const k = materializeBytes(b); + return k.length !== 32 || parses(() => nobleEd25519.Point.fromBytes(k, true)); +}; +const compactSigParses = (b: Bytes): boolean => { + const s = materializeBytes(b); + const n = secp256k1.Point.Fn.ORDER; + return ( + s.length !== 64 || + (bytesToNumberBE(s.subarray(0, 32)) < n && bytesToNumberBE(s.subarray(32)) < n) + ); +}; + /** * Where the tree deliberately differs from the baseline. Within a category, an * entry that matches any recipe must see at least one of them differ, so a @@ -50,6 +95,34 @@ const ALLOWED_DIFFERENCES: { id: string; matches: (r: Recipe) => boolean }[] = [ (r.pub.hex === "01" + "00".repeat(31) || r.pub.hex === "ee" + "ff".repeat(30) + "7f") && r.sig.hex.endsWith("00".repeat(32)), }, + { + // The reference `.expect`s its public-key parses and ECDSA's compact + // signature parse: an undecodable key, or an ECDSA r or s ≥ n, is + // `InvalidData` on the tree where the baseline returned `false`. + id: "verify-parse-panics", + matches: (r) => + (r.k === "ecdsaVerify" && (!secpKeyParses(r.pub) || !compactSigParses(r.sig))) || + (r.k === "schnorrVerify" && !xOnlyKeyParses(r.pub)) || + (r.k === "ed25519Verify" && !ed25519KeyParses(r.pub)), + }, + { + // A low-order X25519 peer derives the reference's one key (HKDF of the + // all-zero secret x25519-dalek's unchecked `diffie_hellman` gives); the + // baseline threw. + id: "x25519-low-order-peer", + matches: (r) => + r.k === "x25519Shared" && + "hex" in r.pub && + [...X25519_LOW_ORDER, ...X25519_LOW_ORDER_HIGH_BIT].includes(r.pub.hex), + }, + { + // PBKDF2 `iterations` 0 and scrypt `logN` 0 derive (the reference's + // crates run 0 rounds as 1, and N = 1); the baseline's backends refused both. + id: "zero-cost-kdf", + matches: (r) => + ((r.k === "pbkdf2Sha256" || r.k === "pbkdf2Sha512") && r.iter === 0) || + (r.k === "scrypt" && r.n === 0), + }, { // Seeded Ed25519 key generation draws the reference's packed // `fill_bytes` stream (`SeededRng.fillBytesPacked`); the baseline drew diff --git a/tests/golden.test.ts b/tests/golden.test.ts index 4c88a93..d14bf90 100644 --- a/tests/golden.test.ts +++ b/tests/golden.test.ts @@ -200,13 +200,13 @@ describe("golden: edge cases", () => { outcome(() => c.ed25519.verify(identity, nonCanonicalRSignature, msg)), ]).toMatchSnapshot(); }); - it("x25519.sharedKey with a low-order public key (the reference's NonContributoryKey)", () => { + it("x25519.sharedKey with a low-order public key (the reference's all-zero secret: one key)", () => { expect([ outcome(() => c.x25519.sharedKey(PRIV, new Uint8Array(32))), outcome(() => c.x25519.sharedKey(PRIV, one(32))), ]).toMatchSnapshot(); }); - it("verify on malformed keys and signatures of the right length (false on both sides)", () => { + it("verify on malformed keys and signatures of the right length (false, or InvalidData where the reference's parse panics)", () => { const ecdsaPub = c.ecdsa.publicKey(PRIV); expect([ outcome(() => c.ecdsa.verify(ecdsaPub, fill(64, 0xff), msg)), @@ -243,15 +243,15 @@ describe("golden: edge cases", () => { () => c.ecdsa.compressPublicKey(Uint8Array.from([4, ...fill(64, 1)])), ], ["scrypt dkLen 0", () => c.scrypt(pw, SALT, { dkLen: 0 })], - ["scrypt logN 0", () => c.scrypt(pw, SALT, { dkLen: 32, logN: 0 })], + ["scrypt logN -1", () => c.scrypt(pw, SALT, { dkLen: 32, logN: -1 })], ["scrypt r 1.5", () => c.scrypt(pw, SALT, { dkLen: 32, r: 1.5 })], ["argon2id dkLen 3", () => c.argon2id(pw, SALT, { dkLen: 3 })], ["argon2id salt 4", () => c.argon2id(pw, bytes(4), { dkLen: 32 })], ["hkdfSha256 length 8161", () => c.hkdfSha256(KEY, SALT, { dkLen: 8161 })], ["hkdfSha256 length 1.5", () => c.hkdfSha256(KEY, SALT, { dkLen: 1.5 })], [ - "pbkdf2Sha256 iterations 0", - () => c.pbkdf2Sha256(pw, SALT, { iterations: 0, dkLen: 32 }), + "pbkdf2Sha256 iterations -1", + () => c.pbkdf2Sha256(pw, SALT, { iterations: -1, dkLen: 32 }), ], ["pbkdf2Sha256 dkLen 0", () => c.pbkdf2Sha256(pw, SALT, { iterations: 1, dkLen: 0 })], ["chacha20 counter -1", () => c.chacha20(KEY, NONCE, bytes(8), { counter: -1 })], @@ -260,6 +260,18 @@ describe("golden: edge cases", () => { ), ).toMatchSnapshot(); }); + it("zero KDF costs derive, as the reference's crates do (scrypt N = 1; PBKDF2 0 rounds as 1)", () => { + const pw = text("pw"); + expect({ + "scrypt logN 0": outcome(() => c.scrypt(pw, SALT, { dkLen: 32, logN: 0 })), + "pbkdf2Sha256 iterations 0": outcome(() => + c.pbkdf2Sha256(pw, SALT, { iterations: 0, dkLen: 32 }), + ), + "pbkdf2Sha256 iterations 1": outcome(() => + c.pbkdf2Sha256(pw, SALT, { iterations: 1, dkLen: 32 }), + ), + }).toMatchSnapshot(); + }); it("scrypt output length below the reference's opt bound (parameterised: throw; default: accepted)", () => { expect([ outcome(() => c.scrypt(text("pw"), SALT, { dkLen: 8, logN: 4 })), diff --git a/tests/kdf-backend.test.ts b/tests/kdf-backend.test.ts index 0121c4a..8110d26 100644 --- a/tests/kdf-backend.test.ts +++ b/tests/kdf-backend.test.ts @@ -23,7 +23,7 @@ const pw = new TextEncoder().encode("pw"); const salt = new Uint8Array(16).fill(0x50); const U32_MAX = 2 ** 32 - 1; -describe("PBKDF2 dkLen up to (2^32 − 1)·hLen; iterations 0 stays InvalidParameter", () => { +describe("PBKDF2 dkLen up to (2^32 − 1)·hLen; iterations 0 runs as 1", () => { afterEach(() => { vi.mocked(pbkdf2).mockReset(); }); @@ -44,22 +44,17 @@ describe("PBKDF2 dkLen up to (2^32 − 1)·hLen; iterations 0 stays InvalidParam `pbkdf2 dkLen must be an integer in [0, ${U32_MAX * 64}], got ${U32_MAX * 64 + 1}`, ); }); - it("iterations 0 is InvalidParameter at every length, including empty output (the reference asserts)", () => { - for (const dkLen of [0, 32, 65]) { - for (const f of [c.pbkdf2Sha256, c.pbkdf2Sha512]) { - let err: unknown; - try { - f(pw, salt, { iterations: 0, dkLen }); - } catch (e) { - err = e; - } - expect(c.CryptoError.isCryptoError(err) && err.code).toBe("InvalidParameter"); - expect((err as Error).message).toBe( - `pbkdf2 iterations must be an integer in [1, ${U32_MAX}], got 0`, - ); - } - } - expect(vi.mocked(pbkdf2)).not.toHaveBeenCalled(); + it("iterations 0 reaches noble as c: 1, what the reference's crate computes for 0 (and 1)", () => { + vi.mocked(pbkdf2).mockImplementationOnce(() => new Uint8Array(0)); + c.pbkdf2Sha256(pw, salt, { iterations: 0, dkLen: 32 }); + expect(vi.mocked(pbkdf2).mock.calls[0]?.[3]).toEqual({ c: 1, dkLen: 32 }); + expect(c.pbkdf2Sha512(pw, salt, { iterations: 0, dkLen: 64 })).toEqual( + c.pbkdf2Sha512(pw, salt, { iterations: 1, dkLen: 64 }), + ); + expect(c.pbkdf2Sha256(pw, salt, { iterations: 0, dkLen: 0 })).toHaveLength(0); + expect(() => c.pbkdf2Sha256(pw, salt, { iterations: -1, dkLen: 32 })).toThrow( + `pbkdf2 iterations must be an integer in [0, ${U32_MAX}], got -1`, + ); }); }); diff --git a/tests/rust-validation/Cargo.lock b/tests/rust-validation/Cargo.lock index 464f690..75425d3 100644 --- a/tests/rust-validation/Cargo.lock +++ b/tests/rust-validation/Cargo.lock @@ -45,6 +45,8 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bc-crypto" version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e74e8d1d886ad04ed17badc8856d7303168cabe9201bba100a621171aa41bd7" dependencies = [ "argon2", "bc-rand", diff --git a/tests/rust-validation/Cargo.toml b/tests/rust-validation/Cargo.toml index 45f956a..0f9bd97 100644 --- a/tests/rust-validation/Cargo.toml +++ b/tests/rust-validation/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" publish = false [dependencies] +# The reference: the published crate, whose sources are commit 4f2b791 (tag 0.14.0). bc-crypto = "=0.14.0" bc-rand = "=0.5.0" serde = { version = "1", features = ["derive"] } @@ -12,11 +13,3 @@ serde_json = "1" hex = "0.4" rand_core = "0.6" rand = "0.9" - -# The reference is the bc-rust/bc-crypto-rust working tree (commit 4f2b791, tag -# 0.14.0, plus its input-validation edits; CI reproduces it from -# reference/bc-crypto-rust-4f2b791-edits.patch). Its Cargo.toml still says -# 0.14.0, so this patch replaces the registry crate of the same version. Drop -# it, and re-pin above, when the release that contains the edits ships. -[patch.crates-io] -bc-crypto = { path = "../../../../../bc-rust/bc-crypto-rust" } diff --git a/tests/rust-validation/README.md b/tests/rust-validation/README.md index 5fc9a4c..57b6797 100644 --- a/tests/rust-validation/README.md +++ b/tests/rust-validation/README.md @@ -1,16 +1,8 @@ # Rust reference cross-validation -Replays vector files against the `bc-crypto` reference. The reference is the -`bc-rust/bc-crypto-rust` working tree: commit `4f2b791` (tag 0.14.0) plus its -input-validation edits, which `Cargo.toml` patches over the registry crate of -the same version with `[patch.crates-io]`. The released `bc-crypto` 0.14.0 -differs from it on four points (`ecdsa_verify`, `schnorr_verify` and -`ed25519_verify` return `false` for an unparseable input; `try_x25519_shared_key` -returns `Err(NonContributoryKey)`; `scrypt_opt` asserts `log_n > 0`; the PBKDF2 -functions assert `iterations > 0`) and is not the reference. The edits are kept -byte for byte in `reference/bc-crypto-rust-4f2b791-edits.patch`, which CI applies -to a fresh checkout. When the release that contains them ships, the pin moves to -it, the patch and the CI step go, and no result line changes. +Replays vector files against the `bc-crypto` reference: the published +`bc-crypto` 0.14.0 crate from crates.io, whose sources are `bc-crypto-rust` +commit `4f2b791` (tag 0.14.0, the head of `master`). Nothing is patched. ```sh cd tests/rust-validation @@ -39,10 +31,6 @@ Result lines on 2026-09-14 (stderr names the resolved reference and the js-only - The reference call alone runs under `catch_unwind`: a panic is a throw. Every failure on both sides normalises to `throw`, so error codes and messages are not compared here; the golden and property suites pin the TypeScript codes. -- `x25519Shared` is the one kind compared verbatim: the reference's - `try_x25519_shared_key` returns `Err(NonContributoryKey)`, rendered - `throw:NonContributoryKey|`, and the TypeScript adapter renders its - `CryptoError` as `throw:|`. - There is no exception list. Any other difference is a MISMATCH and the exit code is 1. @@ -56,17 +44,16 @@ on Node. Regenerate it with `bun scripts/generate-vectors.ts --heavy`. ## CI -The `rust-validation` job in `.github/workflows/ci.yml` clones bc-crypto-rust -at `4f2b791` into the path the patch names, applies -`reference/bc-crypto-rust-4f2b791-edits.patch`, then runs the golden file, the +The `rust-validation` job in `.github/workflows/ci.yml` builds the harness +against the crate (`cargo run --locked`), then runs the golden file, the generated full corpus and the heavy file, followed by the Bun and Node heavy checks. A MISMATCH anywhere fails the job. ## Maintenance -When the reference moves: update the pin in `Cargo.toml` (and drop the patch -once the release contains the edits), run `cargo update -p bc-crypto`, update -`.github/versions.yml`, regenerate the vectors, run the three replays and copy -the result lines into `RUST_DIVERGENCES.md`. A new difference is a bug on one -side: fix it, or record it in `RUST_DIVERGENCES.md` with an input, both -outcomes, the reason no TypeScript design can match, and a vector. +When the reference moves: update the pin in `Cargo.toml`, run +`cargo update -p bc-crypto`, update `.github/versions.yml`, regenerate the +vectors, run the three replays and copy the result lines into +`RUST_DIVERGENCES.md`. A new difference is a bug on one side: fix it, or +record it in `RUST_DIVERGENCES.md` with an input, both outcomes, the reason +no TypeScript design can match, and a vector. diff --git a/tests/rust-validation/reference/bc-crypto-rust-4f2b791-edits.patch b/tests/rust-validation/reference/bc-crypto-rust-4f2b791-edits.patch deleted file mode 100644 index f43aff9..0000000 --- a/tests/rust-validation/reference/bc-crypto-rust-4f2b791-edits.patch +++ /dev/null @@ -1,658 +0,0 @@ -diff --git a/CHANGELOG.md b/CHANGELOG.md -new file mode 100644 -index 0000000..462153b ---- /dev/null -+++ b/CHANGELOG.md -@@ -0,0 +1,14 @@ -+# Changelog -+ -+## Unreleased -+ -+- ECDSA, Schnorr, and Ed25519 verification return false when public-key or -+ applicable signature parsing fails, instead of panicking. -+- Added `try_x25519_shared_key`, returning `Error::NonContributoryKey` when -+ the peer produces an all-zero shared secret. `x25519_shared_key` delegates -+ to it and panics on rejection; successful agreements retain their bytes. -+- PBKDF2 SHA-256/SHA-512 reject zero iterations, including with empty output. -+- `scrypt_opt` rejects log_n = 0 instead of deriving with N = 1. -+ -+These changes are not published as part of 0.14.0. See MIGRATION.md for -+caller changes and the compatibility impact of invalid historical inputs. -diff --git a/MIGRATION.md b/MIGRATION.md -new file mode 100644 -index 0000000..7e0ce79 ---- /dev/null -+++ b/MIGRATION.md -@@ -0,0 +1,22 @@ -+# Migrating to the unreleased input-validation changes -+ -+Valid keys, signatures, and KDF parameters retain their cryptographic outputs. -+ -+Use `try_x25519_shared_key` for peer keys supplied by another party and handle -+`Error::NonContributoryKey`. The original array-returning `x25519_shared_key` -+remains available but now panics on the same invalid peers. Code exhaustively -+matching the public Error enum must handle the new variant. Do not encrypt -+new data with a key obtained from a non-contributory peer. -+ -+Verification functions now return false instead of panicking on malformed -+public keys or ECDSA compact signatures. Remove panic-based handling for -+these validation failures. Fixed-size argument requirements are unchanged. -+ -+PBKDF2 iterations and scrypt_opt log_n must be positive. This also applies -+to PBKDF2 with empty output. Historical records with zero costs no longer -+derive through these entry points. If recovery is needed, use a separately -+reviewed legacy path; silently substituting parameters can produce different -+keys (in particular, scrypt N = 1 and N = 2 do not derive the same result). -+ -+These are local changes pending publication. Consumers pinned to the -+published bc-crypto 0.14.0 still observe the previous behavior. -diff --git a/README.md b/README.md -index a3b4ab0..dc80d21 100644 ---- a/README.md -+++ b/README.md -@@ -120,3 +120,11 @@ The following keys may be used to communicate sensitive information to developer - | Christopher Allen | FDFE 14A5 4ECB 30FC 5D22 74EF F8D3 6C91 3574 05ED | - - You can import a key by running the following command with that individual’s fingerprint: `gpg --recv-keys ""` Ensure that you put quotes around fingerprints that contain spaces. -+ -+### Unreleased input-validation changes -+ -+The working tree adds a recoverable `try_x25519_shared_key` API and rejects -+non-contributory X25519 peers. Verification returns false on malformed -+inputs rather than panicking; PBKDF2 iterations and `scrypt_opt` log_n must -+be positive. These changes are not in the published 0.14.0 crate. See -+[CHANGELOG.md](./CHANGELOG.md) and [MIGRATION.md](./MIGRATION.md). -diff --git a/src/ecdsa_signing.rs b/src/ecdsa_signing.rs -index 57c41b1..8425245 100644 ---- a/src/ecdsa_signing.rs -+++ b/src/ecdsa_signing.rs -@@ -1,8 +1,7 @@ - use secp256k1::{Message, PublicKey, Secp256k1, SecretKey, ecdsa::Signature}; - - use crate::{ -- ECDSA_PRIVATE_KEY_SIZE, ECDSA_PUBLIC_KEY_SIZE, ECDSA_SIGNATURE_SIZE, -- hash::double_sha256, -+ ECDSA_PRIVATE_KEY_SIZE, ECDSA_PUBLIC_KEY_SIZE, ECDSA_SIGNATURE_SIZE, hash::double_sha256, - }; - - /// ECDSA signs the given message using the given private key. -@@ -11,8 +10,7 @@ pub fn ecdsa_sign( - message: impl AsRef<[u8]>, - ) -> [u8; ECDSA_SIGNATURE_SIZE] { - let secp = Secp256k1::new(); -- let sk = SecretKey::from_byte_array(*private_key) -- .expect("32 bytes, within curve order"); -+ let sk = SecretKey::from_byte_array(*private_key).expect("32 bytes, within curve order"); - let hash = double_sha256(message.as_ref()); - let msg = Message::from_digest(hash); - let sig = secp.sign_ecdsa(msg, &sk); -@@ -28,12 +26,14 @@ pub fn ecdsa_verify( - message: impl AsRef<[u8]>, - ) -> bool { - let secp = Secp256k1::new(); -- let pk = PublicKey::from_slice(public_key) -- .expect("33 or 65 bytes, serialized according to the spec"); -+ let Ok(pk) = PublicKey::from_slice(public_key) else { -+ return false; -+ }; - let hash = double_sha256(message.as_ref()); - let msg = Message::from_digest(hash); -- let sig = Signature::from_compact(signature) -- .expect("64 bytes, signature according to the spec"); -+ let Ok(sig) = Signature::from_compact(signature) else { -+ return false; -+ }; - secp.verify_ecdsa(msg, &sig, &pk).is_ok() - } - -@@ -43,8 +43,7 @@ mod tests { - use hex_literal::hex; - - use crate::{ -- ecdsa_new_private_key_using, ecdsa_public_key_from_private_key, -- ecdsa_sign, ecdsa_verify, -+ ecdsa_new_private_key_using, ecdsa_public_key_from_private_key, ecdsa_sign, ecdsa_verify, - }; - - const MESSAGE: &[u8] = b"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."; -diff --git a/src/ed25519_signing.rs b/src/ed25519_signing.rs -index d99ec98..59c282e 100644 ---- a/src/ed25519_signing.rs -+++ b/src/ed25519_signing.rs -@@ -31,8 +31,9 @@ pub fn ed25519_verify( - message: &[u8], - signature: &[u8; ED25519_SIGNATURE_SIZE], - ) -> bool { -- let verifying_key = -- ed25519_dalek::VerifyingKey::from_bytes(public_key).unwrap(); -+ let Ok(verifying_key) = ed25519_dalek::VerifyingKey::from_bytes(public_key) else { -+ return false; -+ }; - let signature = Signature::from_bytes(signature); - verifying_key.verify_strict(message, &signature).is_ok() - } -@@ -42,9 +43,8 @@ mod tests { - use hex_literal::hex; - - use crate::{ -- ED25519_PRIVATE_KEY_SIZE, ED25519_PUBLIC_KEY_SIZE, -- ED25519_SIGNATURE_SIZE, ed25519_public_key_from_private_key, -- ed25519_sign, ed25519_verify, -+ ED25519_PRIVATE_KEY_SIZE, ED25519_PUBLIC_KEY_SIZE, ED25519_SIGNATURE_SIZE, -+ ed25519_public_key_from_private_key, ed25519_sign, ed25519_verify, - }; - - #[test] -@@ -60,16 +60,12 @@ mod tests { - .unwrap(); - assert_eq!( - private_key, -- hex!( -- "7eb559bbbf6cce2632cf9f194aeb50943de7e1cbad54dcfab27a42759f5e2fed" -- ) -+ hex!("7eb559bbbf6cce2632cf9f194aeb50943de7e1cbad54dcfab27a42759f5e2fed") - ); - let public_key = ed25519_public_key_from_private_key(&private_key); - assert_eq!( - public_key, -- hex!( -- "76f863e1024d8ff6cd8ad56c434e01dbbf2999cfc2f132fc7f41ca19fed7a97c" -- ) -+ hex!("76f863e1024d8ff6cd8ad56c434e01dbbf2999cfc2f132fc7f41ca19fed7a97c") - ); - let signature = ed25519_sign(&private_key, MESSAGE); - assert_eq!( -diff --git a/src/error.rs b/src/error.rs -index 15e951a..46485e8 100644 ---- a/src/error.rs -+++ b/src/error.rs -@@ -3,12 +3,16 @@ use thiserror::Error; - - #[derive(Debug, Error)] - pub enum Error { -+ #[error("X25519 peer key produces an all-zero shared secret")] -+ NonContributoryKey, - #[error("AEAD error")] - Aead(AeadError), - } - - impl From for Error { -- fn from(error: AeadError) -> Self { Error::Aead(error) } -+ fn from(error: AeadError) -> Self { -+ Error::Aead(error) -+ } - } - - pub type Result = std::result::Result; -diff --git a/src/hash.rs b/src/hash.rs -index c37a6d3..b2a6dcf 100644 ---- a/src/hash.rs -+++ b/src/hash.rs -@@ -8,15 +8,14 @@ pub const SHA256_SIZE: usize = 32; - pub const SHA512_SIZE: usize = 64; - - /// Computes the CRC-32 checksum of the given data. --pub fn crc32(data: impl AsRef<[u8]>) -> u32 { crc32fast::hash(data.as_ref()) } -+pub fn crc32(data: impl AsRef<[u8]>) -> u32 { -+ crc32fast::hash(data.as_ref()) -+} - - /// Computes the SHA-256 hash of the given data, returning the hash as a - /// 4-byte vector that can be returned in either big-endian or little-endian - /// format. --pub fn crc32_data_opt( -- data: impl AsRef<[u8]>, -- little_endian: bool, --) -> [u8; CRC32_SIZE] { -+pub fn crc32_data_opt(data: impl AsRef<[u8]>, little_endian: bool) -> [u8; CRC32_SIZE] { - let checksum: u32 = crc32(data); - let mut result = [0u8; 4]; - if little_endian { -@@ -58,10 +57,7 @@ pub fn sha512(data: impl AsRef<[u8]>) -> [u8; SHA512_SIZE] { - } - - /// Computes the HMAC-SHA-256 for the given key and message. --pub fn hmac_sha256( -- key: impl AsRef<[u8]>, -- message: impl AsRef<[u8]>, --) -> [u8; SHA256_SIZE] { -+pub fn hmac_sha256(key: impl AsRef<[u8]>, message: impl AsRef<[u8]>) -> [u8; SHA256_SIZE] { - let mut mac = Hmac::::new_from_slice(key.as_ref()).unwrap(); - mac.update(message.as_ref()); - let result = mac.finalize(); -@@ -69,10 +65,7 @@ pub fn hmac_sha256( - } - - /// Computes the HMAC-SHA-512 for the given key and message. --pub fn hmac_sha512( -- key: impl AsRef<[u8]>, -- message: impl AsRef<[u8]>, --) -> [u8; SHA512_SIZE] { -+pub fn hmac_sha512(key: impl AsRef<[u8]>, message: impl AsRef<[u8]>) -> [u8; SHA512_SIZE] { - let mut mac = Hmac::::new_from_slice(key.as_ref()).unwrap(); - mac.update(message.as_ref()); - let result = mac.finalize(); -@@ -80,24 +73,30 @@ pub fn hmac_sha512( - } - - /// Computes the PBKDF2-HMAC-SHA-256 for the given password. -+/// -+/// Panics if iterations is zero, including for empty output. - pub fn pbkdf2_hmac_sha256( - pass: impl AsRef<[u8]>, - salt: impl AsRef<[u8]>, - iterations: u32, - key_len: usize, - ) -> Vec { -+ assert!(iterations > 0, "PBKDF2 iterations must be positive"); - let mut key = vec![0u8; key_len]; - pbkdf2_hmac::(pass.as_ref(), salt.as_ref(), iterations, &mut key); - key - } - - /// Computes the PBKDF2-HMAC-SHA-512 for the given password. -+/// -+/// Panics if iterations is zero, including for empty output. - pub fn pbkdf2_hmac_sha512( - pass: impl AsRef<[u8]>, - salt: impl AsRef<[u8]>, - iterations: u32, - key_len: usize, - ) -> Vec { -+ assert!(iterations > 0, "PBKDF2 iterations must be positive"); - let mut key = vec![0u8; key_len]; - pbkdf2_hmac::(pass.as_ref(), salt.as_ref(), iterations, &mut key); - key -@@ -132,8 +131,8 @@ mod tests { - use hex_literal::hex; - - use crate::hash::{ -- crc32, crc32_data, crc32_data_opt, hmac_sha256, hmac_sha512, -- pbkdf2_hmac_sha256, sha256, sha512, -+ crc32, crc32_data, crc32_data_opt, hmac_sha256, hmac_sha512, pbkdf2_hmac_sha256, sha256, -+ sha512, - }; - - #[test] -@@ -147,9 +146,7 @@ mod tests { - #[test] - fn test_sha256() { - let input = "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"; -- let expected = hex!( -- "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" -- ); -+ let expected = hex!("248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"); - let result = sha256(input.as_bytes()); - assert_eq!(result, expected); - } -@@ -170,9 +167,7 @@ mod tests { - let message = b"Hi There"; - assert_eq!( - hmac_sha256(key, message), -- hex!( -- "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" -- ) -+ hex!("b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7") - ); - assert_eq!( - hmac_sha512(key, message), -@@ -186,9 +181,7 @@ mod tests { - fn test_pbkdf2_hmac_sha256() { - assert_eq!( - pbkdf2_hmac_sha256("password", "salt", 1, 32), -- hex!( -- "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b" -- ) -+ hex!("120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b") - ); - } - -@@ -198,9 +191,7 @@ mod tests { - let salt = hex!("8e94ef805b93e683ff18"); - assert_eq!( - super::hkdf_hmac_sha256(key_material, salt, 32), -- hex!( -- "13485067e21af17c0900f70d885f02593c0e61e46f86450e4a0201a54c14db76" -- ) -+ hex!("13485067e21af17c0900f70d885f02593c0e61e46f86450e4a0201a54c14db76") - ); - } - } -diff --git a/src/lib.rs b/src/lib.rs -index 8e17c91..34796f3 100644 ---- a/src/lib.rs -+++ b/src/lib.rs -@@ -33,8 +33,8 @@ pub use error::{Error, Result}; - /// The `hash` module contains functions for hashing data. - pub mod hash; - pub use hash::{ -- CRC32_SIZE, SHA256_SIZE, SHA512_SIZE, double_sha256, hkdf_hmac_sha256, -- hmac_sha256, hmac_sha512, pbkdf2_hmac_sha256, sha256, sha512, -+ CRC32_SIZE, SHA256_SIZE, SHA512_SIZE, double_sha256, hkdf_hmac_sha256, hmac_sha256, -+ hmac_sha512, pbkdf2_hmac_sha256, sha256, sha512, - }; - - mod memzero; -@@ -42,29 +42,26 @@ pub use memzero::{memzero, memzero_vec_vec_u8}; - - mod symmetric_encryption; - pub use symmetric_encryption::{ -- SYMMETRIC_AUTH_SIZE, SYMMETRIC_KEY_SIZE, SYMMETRIC_NONCE_SIZE, -- aead_chacha20_poly1305_decrypt, aead_chacha20_poly1305_decrypt_with_aad, -- aead_chacha20_poly1305_encrypt, aead_chacha20_poly1305_encrypt_with_aad, -+ SYMMETRIC_AUTH_SIZE, SYMMETRIC_KEY_SIZE, SYMMETRIC_NONCE_SIZE, aead_chacha20_poly1305_decrypt, -+ aead_chacha20_poly1305_decrypt_with_aad, aead_chacha20_poly1305_encrypt, -+ aead_chacha20_poly1305_encrypt_with_aad, - }; - - mod public_key_encryption; - pub use public_key_encryption::{ -- X25519_PRIVATE_KEY_SIZE, X25519_PUBLIC_KEY_SIZE, -- derive_agreement_private_key, derive_signing_private_key, -- x25519_new_private_key_using, x25519_public_key_from_private_key, -- x25519_shared_key, -+ X25519_PRIVATE_KEY_SIZE, X25519_PUBLIC_KEY_SIZE, derive_agreement_private_key, -+ derive_signing_private_key, try_x25519_shared_key, x25519_new_private_key_using, -+ x25519_public_key_from_private_key, x25519_shared_key, - }; - - #[cfg(feature = "secp256k1")] - mod ecdsa_keys; - #[cfg(feature = "secp256k1")] - pub use ecdsa_keys::{ -- ECDSA_MESSAGE_HASH_SIZE, ECDSA_PRIVATE_KEY_SIZE, ECDSA_PUBLIC_KEY_SIZE, -- ECDSA_SIGNATURE_SIZE, ECDSA_UNCOMPRESSED_PUBLIC_KEY_SIZE, -- SCHNORR_PUBLIC_KEY_SIZE, ecdsa_compress_public_key, -- ecdsa_decompress_public_key, ecdsa_derive_private_key, -- ecdsa_new_private_key_using, ecdsa_public_key_from_private_key, -- schnorr_public_key_from_private_key, -+ ECDSA_MESSAGE_HASH_SIZE, ECDSA_PRIVATE_KEY_SIZE, ECDSA_PUBLIC_KEY_SIZE, ECDSA_SIGNATURE_SIZE, -+ ECDSA_UNCOMPRESSED_PUBLIC_KEY_SIZE, SCHNORR_PUBLIC_KEY_SIZE, ecdsa_compress_public_key, -+ ecdsa_decompress_public_key, ecdsa_derive_private_key, ecdsa_new_private_key_using, -+ ecdsa_public_key_from_private_key, schnorr_public_key_from_private_key, - }; - - #[cfg(feature = "secp256k1")] -@@ -76,8 +73,8 @@ pub use ecdsa_signing::{ecdsa_sign, ecdsa_verify}; - mod schnorr_signing; - #[cfg(feature = "secp256k1")] - pub use schnorr_signing::{ -- SCHNORR_SIGNATURE_SIZE, schnorr_sign, schnorr_sign_using, -- schnorr_sign_with_aux_rand, schnorr_verify, -+ SCHNORR_SIGNATURE_SIZE, schnorr_sign, schnorr_sign_using, schnorr_sign_with_aux_rand, -+ schnorr_verify, - }; - - #[cfg(feature = "ed25519")] -@@ -85,8 +82,8 @@ mod ed25519_signing; - #[cfg(feature = "ed25519")] - pub use ed25519_signing::{ - ED25519_PRIVATE_KEY_SIZE, ED25519_PUBLIC_KEY_SIZE, ED25519_SIGNATURE_SIZE, -- ed25519_new_private_key_using, ed25519_public_key_from_private_key, -- ed25519_sign, ed25519_verify, -+ ed25519_new_private_key_using, ed25519_public_key_from_private_key, ed25519_sign, -+ ed25519_verify, - }; - - mod scrypt; -diff --git a/src/public_key_encryption.rs b/src/public_key_encryption.rs -index 9296ba0..f3f363b 100644 ---- a/src/public_key_encryption.rs -+++ b/src/public_key_encryption.rs -@@ -28,16 +28,10 @@ pub fn derive_agreement_private_key( - /// Derive a 32-byte signing private key from the given key material. - /// - /// Enforces domain separation from agreement keys by using the "signing" salt. --pub fn derive_signing_private_key( -- key_material: impl AsRef<[u8]>, --) -> [u8; GENERIC_PUBLIC_KEY_SIZE] { -- hkdf_hmac_sha256( -- key_material, -- "signing".as_bytes(), -- GENERIC_PUBLIC_KEY_SIZE, -- ) -- .try_into() -- .unwrap() -+pub fn derive_signing_private_key(key_material: impl AsRef<[u8]>) -> [u8; GENERIC_PUBLIC_KEY_SIZE] { -+ hkdf_hmac_sha256(key_material, "signing".as_bytes(), GENERIC_PUBLIC_KEY_SIZE) -+ .try_into() -+ .unwrap() - } - - /// Create a new X25519 private key using the given random number generator. -@@ -58,16 +52,34 @@ pub fn x25519_public_key_from_private_key( - - /// Compute the shared symmetric key from the given X25519 private and public - /// keys. -+/// -+/// Panics if the peer produces an all-zero shared secret. For a recoverable -+/// error, use [`try_x25519_shared_key`]. - pub fn x25519_shared_key( - x25519_private_key: &[u8; X25519_PRIVATE_KEY_SIZE], - x25519_public_key: &[u8; X25519_PUBLIC_KEY_SIZE], - ) -> [u8; SYMMETRIC_KEY_SIZE] { -+ try_x25519_shared_key(x25519_private_key, x25519_public_key) -+ .expect("X25519 peer key must be contributory") -+} -+ -+/// Computes an agreed key, rejecting peers that produce an all-zero secret. -+/// Use this checked API when the public key comes from untrusted input. -+pub fn try_x25519_shared_key( -+ x25519_private_key: &[u8; X25519_PRIVATE_KEY_SIZE], -+ x25519_public_key: &[u8; X25519_PUBLIC_KEY_SIZE], -+) -> crate::Result<[u8; SYMMETRIC_KEY_SIZE]> { - let sk = StaticSecret::from(*x25519_private_key); - let pk = PublicKey::from(*x25519_public_key); - let shared_secret = sk.diffie_hellman(&pk); -- hkdf_hmac_sha256(shared_secret.as_bytes(), "agreement".as_bytes(), 32) -- .try_into() -- .unwrap() -+ if !shared_secret.was_contributory() { -+ return Err(crate::Error::NonContributoryKey); -+ } -+ Ok( -+ hkdf_hmac_sha256(shared_secret.as_bytes(), "agreement".as_bytes(), 32) -+ .try_into() -+ .unwrap(), -+ ) - } - - #[cfg(test)] -@@ -76,9 +88,8 @@ mod tests { - use hex_literal::hex; - - use crate::{ -- derive_agreement_private_key, derive_signing_private_key, -- x25519_new_private_key_using, x25519_public_key_from_private_key, -- x25519_shared_key, -+ derive_agreement_private_key, derive_signing_private_key, x25519_new_private_key_using, -+ x25519_public_key_from_private_key, x25519_shared_key, - }; - - #[test] -@@ -87,34 +98,24 @@ mod tests { - let private_key = x25519_new_private_key_using(&mut rng); - assert_eq!( - private_key, -- hex!( -- "7eb559bbbf6cce2632cf9f194aeb50943de7e1cbad54dcfab27a42759f5e2fed" -- ) -+ hex!("7eb559bbbf6cce2632cf9f194aeb50943de7e1cbad54dcfab27a42759f5e2fed") - ); - let public_key = x25519_public_key_from_private_key(&private_key); - assert_eq!( - public_key, -- hex!( -- "f1bd7a7e118ea461eba95126a3efef543ebb78439d1574bedcbe7d89174cf025" -- ) -+ hex!("f1bd7a7e118ea461eba95126a3efef543ebb78439d1574bedcbe7d89174cf025") - ); - -- let derived_x25519_private_key = -- derive_agreement_private_key(b"password"); -+ let derived_x25519_private_key = derive_agreement_private_key(b"password"); - assert_eq!( - derived_x25519_private_key, -- hex!( -- "7b19769132648ff43ae60cbaa696d5be3f6d53e6645db72e2d37516f0729619f" -- ) -+ hex!("7b19769132648ff43ae60cbaa696d5be3f6d53e6645db72e2d37516f0729619f") - ); - -- let derived_signing_private_key = -- derive_signing_private_key(b"password"); -+ let derived_signing_private_key = derive_signing_private_key(b"password"); - assert_eq!( - derived_signing_private_key, -- hex!( -- "05cc550daa75058e613e606d9898fedf029e395911c43273a208b7e0e88e271b" -- ) -+ hex!("05cc550daa75058e613e606d9898fedf029e395911c43273a208b7e0e88e271b") - ); - } - -@@ -122,21 +123,15 @@ mod tests { - fn test_key_agreement() { - let mut rng = make_fake_random_number_generator(); - let alice_private_key = x25519_new_private_key_using(&mut rng); -- let alice_public_key = -- x25519_public_key_from_private_key(&alice_private_key); -+ let alice_public_key = x25519_public_key_from_private_key(&alice_private_key); - let bob_private_key = x25519_new_private_key_using(&mut rng); -- let bob_public_key = -- x25519_public_key_from_private_key(&bob_private_key); -- let alice_shared_key = -- x25519_shared_key(&alice_private_key, &bob_public_key); -- let bob_shared_key = -- x25519_shared_key(&bob_private_key, &alice_public_key); -+ let bob_public_key = x25519_public_key_from_private_key(&bob_private_key); -+ let alice_shared_key = x25519_shared_key(&alice_private_key, &bob_public_key); -+ let bob_shared_key = x25519_shared_key(&bob_private_key, &alice_public_key); - assert_eq!(alice_shared_key, bob_shared_key); - assert_eq!( - alice_shared_key, -- hex!( -- "1e9040d1ff45df4bfca7ef2b4dd2b11101b40d91bf5bf83f8c83d53f0fbb6c23" -- ) -+ hex!("1e9040d1ff45df4bfca7ef2b4dd2b11101b40d91bf5bf83f8c83d53f0fbb6c23") - ); - } - } -diff --git a/src/schnorr_signing.rs b/src/schnorr_signing.rs -index 0dd44b4..bb7aa55 100644 ---- a/src/schnorr_signing.rs -+++ b/src/schnorr_signing.rs -@@ -45,8 +45,9 @@ pub fn schnorr_verify( - ) -> bool { - let secp = Secp256k1::new(); - let sig = Signature::from_byte_array(*schnorr_signature); -- let pk = XOnlyPublicKey::from_byte_array(*schnorr_public_key) -- .expect("32 bytes, serialized according to the spec"); -+ let Ok(pk) = XOnlyPublicKey::from_byte_array(*schnorr_public_key) else { -+ return false; -+ }; - secp.verify_schnorr(&sig, message.as_ref(), &pk).is_ok() - } - -@@ -177,7 +178,6 @@ mod tests { - } - - #[test] -- #[should_panic] // public key not on the curve - fn test_5() { - run_test_vector(TestVector { - secret_key: None, -@@ -286,7 +286,6 @@ mod tests { - } - - #[test] -- #[should_panic] // public key is not a valid X coordinate because it exceeds the field size - fn test_14() { - run_test_vector(TestVector { - secret_key: None, -diff --git a/src/scrypt.rs b/src/scrypt.rs -index 982a00d..cf78b7f 100644 ---- a/src/scrypt.rs -+++ b/src/scrypt.rs -@@ -32,11 +32,11 @@ pub fn scrypt( - ) -> Vec { - let params = scrypt::Params::recommended(); - let mut output = vec![0u8; output_len]; -- scrypt_hash(pass.as_ref(), salt.as_ref(), ¶ms, &mut output) -- .expect("scrypt failed"); -+ scrypt_hash(pass.as_ref(), salt.as_ref(), ¶ms, &mut output).expect("scrypt failed"); - output - } - -+/// Derives with explicit costs; panics when log_n is zero or parameters are invalid. - pub fn scrypt_opt( - pass: impl AsRef<[u8]>, - salt: impl AsRef<[u8]>, -@@ -46,11 +46,10 @@ pub fn scrypt_opt( - r: u32, // Must be greater than 0 and less than or equal to `4294967295` - p: u32, // Must be greater than 0 and less than `4294967295` - ) -> Vec { -- let params = scrypt::Params::new(log_n, r, p, output_len) -- .expect("Invalid Scrypt parameters"); -+ assert!(log_n > 0, "scrypt log_n must be positive"); -+ let params = scrypt::Params::new(log_n, r, p, output_len).expect("Invalid Scrypt parameters"); - let mut output = vec![0u8; output_len]; -- scrypt_hash(pass.as_ref(), salt.as_ref(), ¶ms, &mut output) -- .expect("scrypt failed"); -+ scrypt_hash(pass.as_ref(), salt.as_ref(), ¶ms, &mut output).expect("scrypt failed"); - output - } - -diff --git a/tests/invalid_inputs.rs b/tests/invalid_inputs.rs -new file mode 100644 -index 0000000..fb96cdc ---- /dev/null -+++ b/tests/invalid_inputs.rs -@@ -0,0 +1,47 @@ -+use bc_crypto::*; -+ -+#[test] -+fn low_order_x25519_is_rejected() { -+ let private = [7; 32]; -+ assert!(matches!( -+ try_x25519_shared_key(&private, &[0; 32]), -+ Err(Error::NonContributoryKey) -+ )); -+ assert!(std::panic::catch_unwind(|| x25519_shared_key(&private, &[0; 32])).is_err()); -+ let public = x25519_public_key_from_private_key(&[9; 32]); -+ assert_eq!( -+ try_x25519_shared_key(&private, &public).unwrap(), -+ x25519_shared_key(&private, &public) -+ ); -+} -+ -+#[test] -+fn zero_kdf_costs_are_rejected_even_for_empty_output() { -+ for len in [0, 32] { -+ assert!( -+ std::panic::catch_unwind(|| hash::pbkdf2_hmac_sha256(b"pw", b"salt", 0, len)).is_err() -+ ); -+ assert!( -+ std::panic::catch_unwind(|| hash::pbkdf2_hmac_sha512(b"pw", b"salt", 0, len)).is_err() -+ ); -+ } -+ assert!(std::panic::catch_unwind(|| scrypt_opt(b"pw", b"salt", 32, 0, 8, 1)).is_err()); -+} -+ -+#[cfg(feature = "secp256k1")] -+#[test] -+fn malformed_secp256k1_inputs_return_false() { -+ assert!(!ecdsa_verify(&[255; 33], &[255; 64], b"msg")); -+ let public = ecdsa_public_key_from_private_key(&[1; 32]); -+ assert!(!ecdsa_verify(&public, &[255; 64], b"msg")); -+ assert!(!schnorr_verify(&[255; 32], &[255; 64], b"msg")); -+} -+ -+#[cfg(feature = "ed25519")] -+#[test] -+fn malformed_ed25519_key_returns_false() { -+ // Compressed y=2 does not decompress on edwards25519. -+ let mut public = [0; 32]; -+ public[0] = 2; -+ assert!(!ed25519_verify(&public, b"msg", &[255; 64])); -+} diff --git a/tests/rust-validation/src/main.rs b/tests/rust-validation/src/main.rs index 04ecfae..0145a06 100644 --- a/tests/rust-validation/src/main.rs +++ b/tests/rust-validation/src/main.rs @@ -11,10 +11,8 @@ //! of the Rust width): never a match, never a mismatch. The reference call runs //! under `catch_unwind`, so a panic is a throw. Outcomes are compared after //! normalising every failure (TS `throw:`, Rust panic or `Err`) to -//! "throw", except `x25519Shared`, whose `Err(NonContributoryKey)` is rendered -//! `throw:NonContributoryKey|` and compared verbatim with the TS -//! adapter's `throw:|`. There is no exception list: any other -//! difference is a MISMATCH, and the process exits 1. +//! "throw". There is no exception list: any other difference is a MISMATCH, +//! and the process exits 1. use bc_crypto::hash::{crc32, crc32_data_opt, hkdf_hmac_sha512, pbkdf2_hmac_sha512}; use bc_crypto::*; use bc_rand::SeededRandomNumberGenerator; @@ -138,13 +136,9 @@ fn run(r: &Value) -> std::result::Result { }) } "x25519Pub" => { let priv_ = fixed::<32>(b("priv"))?; attempt(move || Some(hex::encode(x25519_public_key_from_private_key(&priv_)))) } - // The reference's one error value on this path, rendered `throw:|` and compared verbatim. "x25519Shared" => { let (priv_, pub_) = (fixed::<32>(b("priv"))?, fixed::<32>(b("pub"))?); - attempt(move || Some(match try_x25519_shared_key(&priv_, &pub_) { - Ok(key) => hex::encode(key), - Err(e) => format!("throw:NonContributoryKey|{e}"), - })) + attempt(move || Some(hex::encode(x25519_shared_key(&priv_, &pub_)))) } "deriveAgreement" => { let km = b("km"); attempt(move || Some(hex::encode(derive_agreement_private_key(km)))) } "deriveSigning" => { let km = b("km"); attempt(move || Some(hex::encode(derive_signing_private_key(km)))) } @@ -192,15 +186,11 @@ fn run(r: &Value) -> std::result::Result { }) } -/// The resolved `bc-crypto` source, from this harness's Cargo.lock: the -/// registry version, or the `[patch.crates-io]` path with its git HEAD and a -/// dirty flag (the reference is a working tree until the release that -/// contains its edits ships). +/// The resolved `bc-crypto` version and source, from this harness's Cargo.lock. fn provenance() -> String { let dir = env!("CARGO_MANIFEST_DIR"); let lock = std::fs::read_to_string(format!("{dir}/Cargo.lock")).unwrap_or_default(); - let mut version = String::from("?"); - let mut source: Option = None; + let (mut version, mut source) = (String::from("?"), String::from("?")); let mut in_pkg = false; for line in lock.lines() { let line = line.trim(); @@ -208,27 +198,11 @@ fn provenance() -> String { if line == "name = \"bc-crypto\"" { in_pkg = true; continue; } if in_pkg { if let Some(v) = line.strip_prefix("version = ") { version = v.trim_matches('"').to_string(); } - if let Some(s) = line.strip_prefix("source = ") { source = Some(s.trim_matches('"').to_string()); } + if let Some(s) = line.strip_prefix("source = ") { source = s.trim_matches('"').to_string(); } if line.starts_with("dependencies") || line.starts_with("checksum") { in_pkg = false; } } } - if let Some(s) = source { return format!("bc-crypto {version} ({s})"); } - let manifest = std::fs::read_to_string(format!("{dir}/Cargo.toml")).unwrap_or_default(); - let path = manifest - .lines() - .find(|l| l.trim_start().starts_with("bc-crypto = { path = ")) - .and_then(|l| l.split('"').nth(1)) - .map(|p| format!("{dir}/{p}")) - .unwrap_or_else(|| "?".into()); - let git = |args: &[&str]| { - std::process::Command::new("git").arg("-C").arg(&path).args(args).output().ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - }; - let head = git(&["rev-parse", "--short", "HEAD"]).unwrap_or_else(|| "?".into()); - let dirty = git(&["status", "--porcelain", "--untracked-files=all"]).map(|s| !s.is_empty()).unwrap_or(false); - let canonical = std::fs::canonicalize(&path).map(|p| p.display().to_string()).unwrap_or(path); - format!("bc-crypto {version} patched from {canonical} (HEAD {head}{})", if dirty { ", dirty" } else { "" }) + format!("bc-crypto {version} ({source})") } fn main() { diff --git a/tests/strict-boundaries.test.ts b/tests/strict-boundaries.test.ts index cbc08a2..4ac99e7 100644 --- a/tests/strict-boundaries.test.ts +++ b/tests/strict-boundaries.test.ts @@ -15,11 +15,12 @@ describe("Ed25519 uncofactored equation", () => { /** * The decoder boundary: dalek's `CompressedEdwardsY::decompress` reduces a * non-canonical y (`y = p + k` reads as `y = k`) and decodes it when the - * reduced point has a square root; the tree decodes canonically and rejects - * every such encoding. noble's `zip215 = true` applies dalek's rule, so the - * two lists in the corpus pin exactly which encodings each decoder takes. - * The difference is unobservable through `verify`: both sides return - * `false` for every one of these keys and for the same encodings as R. + * reduced point has a square root. noble's `zip215 = true` applies the same + * rule, and the tree decodes A with it: the reference `.unwrap()`s that + * decode, so a key dalek rejects is `InvalidData`, and a key it takes is a + * small-order point or one whose discrete logarithm nobody knows, `false`. + * R is decoded canonically; a non-canonical R is `false` on both sides. The + * two lists in the corpus pin exactly which encodings dalek takes. */ describe("Ed25519 decoder boundary (non-canonical y = p + k)", () => { const P = (1n << 255n) - 19n; @@ -70,15 +71,24 @@ describe("Ed25519 decoder boundary (non-canonical y = p + k)", () => { expect(re("ec" + "ff".repeat(31))).toBe("ec" + "ff".repeat(30) + "7f"); expect(re("ed" + "ff".repeat(31))).toBe("00".repeat(31) + "80"); }); - it("verify is false for every one of the 40 as A and as R, with the fixture's honest signature", () => { + it("as A: the 26 dalek decodes are false, the 14 it rejects are InvalidData; as R: all 40 are false", () => { expect(ed25519.verify(pub, sig, msg)).toBe(true); + let rejected = 0; for (const e of forty) { - expect(ed25519.verify(e, sig, msg)).toBe(false); + if (decodes(e, true)) { + expect(ed25519.verify(e, sig, msg)).toBe(false); + } else { + expect(() => ed25519.verify(e, sig, msg)).toThrow( + "Ed25519 public key is not a point on the curve", + ); + rejected++; + } const r = new Uint8Array(64); r.set(e, 0); r.set(sig.subarray(32), 32); expect(ed25519.verify(pub, r, msg)).toBe(false); } + expect(rejected).toBe(14); }); }); describe("backend boundaries", () => { diff --git a/tests/vectors/recipes.ts b/tests/vectors/recipes.ts index b91eac0..3594140 100644 --- a/tests/vectors/recipes.ts +++ b/tests/vectors/recipes.ts @@ -4,8 +4,7 @@ * Every argument is named, so an API refactor changes only the adapters. * Byte inputs are hex or cycling-byte descriptions; the * seeded RNG is a four-word seed. Outcomes are hex strings, booleans as - * "1"/"0", or `throw:` (message-independent), except that an - * `x25519Shared` `CryptoError` renders as `throw:|`. + * "1"/"0", or `throw:` (message-independent). * * Recipe semantics are fixed: the committed vectors depend on them. */ @@ -204,12 +203,6 @@ export function materialize(api: VectorApi, r: Recipe): string { return bytesToHex(api.newPriv(r.alg, api.makeRng(seedOf(r.seed)))); } } catch (e) { - // `x25519Shared` failures carry the error value: the reference's - // `try_x25519_shared_key` returns `Err(NonContributoryKey)` with a Display - // text, and the harness compares code and message for this kind. - if (r.k === "x25519Shared" && e instanceof Error && e.name === "CryptoError" && "code" in e) { - return `throw:${String(e.code)}|${e.message}`; - } return `throw:${e instanceof Error ? e.name : "Error"}`; } } diff --git a/tests/vectors/vectors.json b/tests/vectors/vectors.json index 75c3636..6835df0 100644 --- a/tests/vectors/vectors.json +++ b/tests/vectors/vectors.json @@ -8203,7 +8203,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -8263,7 +8263,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -8398,7 +8398,7 @@ "hex": "0708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223242526" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -8458,7 +8458,7 @@ "hex": "0708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223242526" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -8653,7 +8653,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -8848,7 +8848,7 @@ "hex": "0708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223242526" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -8983,7 +8983,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9178,7 +9178,7 @@ "hex": "0708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223242526" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9328,7 +9328,7 @@ "hex": "243f6a8885a308d313198a2e03707344a4093822299f31d0082efa98ec4e6c89" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9463,7 +9463,7 @@ "hex": "243f6a8885a308d313198a2e03707344a4093822299f31d0082efa98ec4e6c89" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9628,7 +9628,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9643,7 +9643,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9658,7 +9658,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9733,7 +9733,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9748,7 +9748,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9778,7 +9778,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9793,7 +9793,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9808,7 +9808,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9823,7 +9823,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9838,7 +9838,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9853,7 +9853,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9868,7 +9868,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9883,7 +9883,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9898,7 +9898,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9913,7 +9913,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9928,7 +9928,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9943,7 +9943,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9958,7 +9958,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9973,7 +9973,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -9988,7 +9988,7 @@ "hex": "07" } }, - "expect": "0" + "expect": "throw:CryptoError" }, { "recipe": { @@ -10991,7 +10991,7 @@ "hex": "0000000000000000000000000000000000000000000000000000000000000000" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11004,7 +11004,7 @@ "hex": "0100000000000000000000000000000000000000000000000000000000000000" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11017,7 +11017,7 @@ "hex": "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11030,7 +11030,7 @@ "hex": "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11043,7 +11043,7 @@ "hex": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11056,7 +11056,7 @@ "hex": "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11069,7 +11069,7 @@ "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11082,7 +11082,7 @@ "hex": "0000000000000000000000000000000000000000000000000000000000000080" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11095,7 +11095,7 @@ "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11108,7 +11108,7 @@ "hex": "0100000000000000000000000000000000000000000000000000000000000080" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11121,7 +11121,7 @@ "hex": "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11134,7 +11134,7 @@ "hex": "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11147,7 +11147,7 @@ "hex": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11160,7 +11160,7 @@ "hex": "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11173,7 +11173,7 @@ "hex": "0000000000000000000000000000000000000000000000000000000000000000" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11186,7 +11186,7 @@ "hex": "0100000000000000000000000000000000000000000000000000000000000000" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11199,7 +11199,7 @@ "hex": "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11212,7 +11212,7 @@ "hex": "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11225,7 +11225,7 @@ "hex": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11238,7 +11238,7 @@ "hex": "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11251,7 +11251,7 @@ "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11264,7 +11264,7 @@ "hex": "0000000000000000000000000000000000000000000000000000000000000080" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11277,7 +11277,7 @@ "hex": "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11290,7 +11290,7 @@ "hex": "0100000000000000000000000000000000000000000000000000000000000080" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11303,7 +11303,7 @@ "hex": "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11316,7 +11316,7 @@ "hex": "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11329,7 +11329,7 @@ "hex": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11342,7 +11342,7 @@ "hex": "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } }, - "expect": "throw:NonContributoryKey|X25519 peer key produces an all-zero shared secret" + "expect": "6ddeb1af8383e3855336d43a5993990101fb76eb68e0ec3141fe03d3dc2f8d6e" }, { "recipe": { @@ -11528,7 +11528,7 @@ "r": 8, "p": 1 }, - "expect": "throw:CryptoError" + "expect": "5a81ca47d8c099b1c6e0d5c191ea5d81d1775626fe9ca10acf2fae5f3c28975e" }, { "recipe": { @@ -11794,7 +11794,7 @@ "iter": 0, "len": 0 }, - "expect": "throw:CryptoError" + "expect": "" }, { "recipe": { @@ -11809,7 +11809,7 @@ "iter": 0, "len": 32 }, - "expect": "throw:CryptoError" + "expect": "09d8b84188254ca4b82c68202c11d368569d655b6aa0c1909344534394a05a33" }, { "recipe": { @@ -11824,7 +11824,7 @@ "iter": 0, "len": 0 }, - "expect": "throw:CryptoError" + "expect": "" }, { "recipe": { @@ -11839,7 +11839,7 @@ "iter": 0, "len": 32 }, - "expect": "throw:CryptoError" + "expect": "4683e283eefbf2e5eea9aa7d7376e6362109c9af0a629fe12352968d77d39172" }, { "recipe": { From 1f724288b2b79c792c27f89a59029f899bdab2ce Mon Sep 17 00:00:00 2001 From: Leonardo Custodio Date: Mon, 14 Sep 2026 17:55:52 -0300 Subject: [PATCH 7/8] Fixes CI --- bun.lock | 15 +++++---------- package.json | 1 + scripts/api-report.ts | 5 +++++ 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index dd0b288..1973d02 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "@typescript-eslint/eslint-plugin": "^8.70.0", "@typescript-eslint/parser": "^8.70.0", "@vitest/coverage-v8": "^5.0.0", + "ajv": "^8.20.0", "eslint": "^10.10.0", "fast-check": "^4.9.0", "prettier": "3.9.6", @@ -373,7 +374,7 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], @@ -547,7 +548,7 @@ "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], @@ -791,13 +792,11 @@ "@microsoft/tsdoc-config/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - "@rushstack/node-core-library/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "@rushstack/terminal/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -819,10 +818,6 @@ "vitest/tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], - "@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "@rushstack/node-core-library/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], } } diff --git a/package.json b/package.json index a65de64..f5bc56a 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "@typescript-eslint/eslint-plugin": "^8.70.0", "@typescript-eslint/parser": "^8.70.0", "@vitest/coverage-v8": "^5.0.0", + "ajv": "^8.20.0", "eslint": "^10.10.0", "fast-check": "^4.9.0", "prettier": "3.9.6", diff --git a/scripts/api-report.ts b/scripts/api-report.ts index 51b63e2..e7fddac 100644 --- a/scripts/api-report.ts +++ b/scripts/api-report.ts @@ -9,6 +9,11 @@ * transient copy is made inside dist/ first. The committed report * (api/.api.md) is the reviewable record of the public surface: every * surface change is a visible diff here and in api/index.d.mts. + * + * `ajv` is a direct devDependency for this script's sake: api-extractor's + * `ajv-draft-04` wants ajv 8 as an optional peer, which bun does not nest, so + * without the direct dependency eslint's ajv 6 is what gets hoisted and the + * report fails with "Cannot find module 'ajv/dist/core'". */ import { copyFileSync, existsSync, readFileSync, rmSync } from "node:fs"; From 0ad26f80883545e80db83dcdec9ab21cdcd26e8f Mon Sep 17 00:00:00 2001 From: Leonardo Custodio Date: Mon, 14 Sep 2026 18:00:22 -0300 Subject: [PATCH 8/8] Increase timeout --- tests/golden-vectors.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/golden-vectors.test.ts b/tests/golden-vectors.test.ts index da537bd..6d6c0fb 100644 --- a/tests/golden-vectors.test.ts +++ b/tests/golden-vectors.test.ts @@ -19,7 +19,10 @@ describe("golden vectors (frozen)", () => { expect(vectors.length).toBeGreaterThanOrEqual(300); }); vectors.forEach((v, i) => { - it(`#${i} ${v.recipe.k}`, () => { + // scrypt rows derive for real: the logN 17, r 64 row (1.07 GiB) takes + // over a second here and several on a CI runner, past vitest's 5 s default. + const options = v.recipe.k === "scrypt" ? { timeout: 120_000 } : {}; + it(`#${i} ${v.recipe.k}`, options, () => { expect(materialize(api, v.recipe)).toBe(v.expect); }); });