Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,48 @@ 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 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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ node_modules
# Build artifacts
dist
coverage
# Generated TypeDoc output; retain handwritten architecture notes.
# Generated TypeDoc output
/docs/*
*.tsbuildinfo

Expand Down
4 changes: 2 additions & 2 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand All @@ -13,6 +13,6 @@
"@noble/hashes",
"@blockchaincommons/rand"
],
"limit": "3 kB"
"limit": "6 kB"
}
]
103 changes: 83 additions & 20 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,88 @@
# Changelog

## 1.0.0-beta.2
## 1.0.0-beta.3 - 2026-09-14

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.
Closes the below divergences from the reference, the published `bc-crypto` (tag 0.14.0).

### 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 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
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.
- `chacha20` returns `Uint8Array<ArrayBuffer>`.

### Validation

- 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, 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 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
pages plus the RFC 7914 vectors.

## 1.0.0-beta.2 - 2026-09-12

Fixes Ed25519 verification and adds reference parameter validation.

### Fixed

Expand Down Expand Up @@ -60,20 +137,6 @@ Ordinary signing and derivation 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.
51 changes: 28 additions & 23 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@
`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` 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**.

## 1. Package name and imports
Expand Down Expand Up @@ -112,7 +122,7 @@ 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
}
}
}
Expand All @@ -123,39 +133,34 @@ 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 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 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

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);
- const sig = schnorrSignUsing(key, msg, rng);
+ 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<ArrayBuffer>` 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.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.

Runnable examples live in the [`examples/`](https://github.com/BlockchainCommons/bc-crypto-ts/tree/master/examples) directory.
Expand All @@ -63,7 +65,7 @@ Runnable examples live in the [`examples/`](https://github.com/BlockchainCommons

### Version History


- **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.

Expand Down
Loading