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
91 changes: 91 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,94 @@ 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 stored outcomes must first equal the working tree's.
- name: Check the golden vectors against the working tree
run: bun run test:golden

# The whole corpus (tests/corpus/corpus.ts `allRecipes()`)
- name: Materialise the full corpus
run: bun scripts/generate-vectors.ts --full "${{ runner.temp }}/components-full-corpus.json"

# The runner image ships a stable Rust toolchain
- name: Fetch the pinned reference crates
working-directory: tests/rust-validation
run: cargo fetch --locked

- name: Validate the golden vectors against bc-components
working-directory: tests/rust-validation
run: cargo run --release --locked --offline -- ../vectors/vectors.json

- name: Validate the full corpus against bc-components
working-directory: tests/rust-validation
run: cargo run --release --locked --offline -- "${{ runner.temp }}/components-full-corpus.json"

# The same two files against the ssh-agent build, which compares the
# SSH-agent key-derivation rows the default build reports as js-only.
- name: Validate the golden vectors against bc-components with ssh-agent
working-directory: tests/rust-validation
run: cargo run --release --locked --offline --features agent -- ../vectors/vectors.json

- name: Validate the full corpus against bc-components with ssh-agent
working-directory: tests/rust-validation
run: cargo run --release --locked --offline --features agent -- "${{ runner.temp }}/components-full-corpus.json"

# The comparison must bite: the committed one-vector fixture with a
# flipped digest digit fails with exactly one MISMATCH.
- name: Reject the mismatch fixture
working-directory: tests/rust-validation
run: |
set +e
output=$(cargo run --release --locked --offline --quiet -- mismatch.json 2>&1)
status=$?
set -e
printf '%s\n' "$output"
test "$status" -eq 1
printf '%s\n' "$output" | grep -q ' 1 MISMATCH$'

# The harness's own contract: one row per outcome class, a malformed
# recipe reported rather than run, a noreg row out of order, and a
# mapped panic whose TypeScript code differs.
- name: Check the harness fixtures
working-directory: tests/rust-validation
run: |
set +e
classes=$(cargo run --release --locked --offline --quiet -- fixtures/classes.json 2>&1)
classes_status=$?
malformed=$(cargo run --release --locked --offline --quiet -- fixtures/malformed.json 2>&1)
malformed_status=$?
noreg=$(cargo run --release --locked --offline --quiet -- fixtures/noreg-order.json 2>&1)
noreg_status=$?
panic=$(cargo run --release --locked --offline --quiet -- fixtures/panic-code.json 2>&1)
panic_status=$?
set -e
printf '%s\n%s\n%s\n%s\n' "$classes" "$malformed" "$noreg" "$panic"
test "$classes_status" -eq 0
printf '%s\n' "$classes" | grep -q '^8 vectors - 1 match, 3 panic-mapped (hang 1, none 1, panic 1), 4 js-only (J1 1, J2 1, J3 1, J4 1), 0 MISMATCH$'
test "$malformed_status" -eq 1
printf '%s\n' "$malformed" | grep -q ', 1 unparsable$'
test "$noreg_status" -eq 1
printf '%s\n' "$noreg" | grep -q ' 1 MISMATCH$'
test "$panic_status" -eq 1
printf '%s\n' "$panic" | grep -q ' 1 MISMATCH$'
25 changes: 17 additions & 8 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
{
"name": "ESM root entry (import *), minified + gzipped",
"path": "dist/index.mjs",
"limit": "89 kB"
"limit": "93 kB"
},
{
"name": "root entry, Digest + SymmetricKey + EncryptedMessage only (tree-shaken, siblings external)",
Expand All @@ -16,7 +16,7 @@
"@blockchaincommons/tags",
"@blockchaincommons/uniform-resources"
],
"limit": "9 kB"
"limit": "6 kB"
},
{
"name": "root entry, package's own code (siblings external)",
Expand All @@ -29,32 +29,41 @@
"@blockchaincommons/tags",
"@blockchaincommons/uniform-resources"
],
"limit": "69 kB"
"limit": "71 kB"
},
{
"name": "ssh subpath, minified + gzipped",
"path": "dist/ssh.mjs",
"limit": "31 kB"
"limit": "33 kB"
},
{
"name": "pq subpath, minified + gzipped",
"path": "dist/pq.mjs",
"limit": "31 kB"
"limit": "51 kB"
},
{
"name": "kdf subpath, minified + gzipped",
"path": "dist/kdf.mjs",
"limit": "32 kB"
"limit": "53 kB"
},
{
"name": "sskr subpath, minified + gzipped",
"path": "dist/sskr.mjs",
"limit": "19 kB"
"limit": "39 kB"
},
{
"name": "ssh-agent-node subpath, minified + gzipped (node: modules external)",
"path": "dist/ssh-agent-node.mjs",
"ignore": [
"node:net",
"node:process"
],
"limit": "30 kB"
},
{
"name": "tags subpath, package's own code (the package itself and siblings external)",
"path": "dist/tags.mjs",
"limit": "4 kB",
"limit": "2 kB",
"ignore": [
"@blockchaincommons/components",
"@blockchaincommons/crypto",
Expand Down
114 changes: 114 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,119 @@
# Changelog

## 1.0.0-beta.3 - 2026-09-15

### Added

- **SSH-agent key derivation.** `SSHAgentParams.lock(contentKey, secret, { agent, nonce? })`
and `unlock(encryptedMessage, secret, { agent })` implement the reference's
algorithm over an injectable `SshAgent` (`listIdentities`, `sign`);
`EncryptedKey.lockWithAgent` / `unlockWithAgent` route through it;
`MemorySshAgent` is an in-memory agent for tests and embedded use; the
Node-only subpath `@blockchaincommons/components/ssh-agent-node` exports
`connectToSshAgent()` over `SSH_AUTH_SOCK`. Errors carry the reference's
texts under `SshAgent` (`SSH Agent secret must be a valid UTF-8 string`,
`No Ed25519 identities available in SSH agent`, `Multiple identities
available in SSH agent, but no ID provided`, `No matching identity found`,
`SSH agent refused to sign`, `SSH_AUTH_SOCK env var not set`, `no
ssh-agent reachable`) and `Crypto` (`Failed to decrypt the encrypted key: …`,
`Failed to convert decrypted key to SymmetricKey: …`).
- SSH keys of every algorithm the reference generates: `sshSigningPrivateKey`
accepts `{ kind: "dsa" }`, `{ kind: "rsa" }` (2048 bits) and
`{ kind: "ecdsa", curve: "nistp521" }`, and OpenSSH private keys, public
keys and `sshsig` texts of those algorithms parse and re-encode byte for
byte with the reference (`ssh-key` 0.6.7 and `pem-rfc7468`).
- `HKDFRng` accepts `pageLength: 0` (a non-empty draw throws `InvalidData`
instead of looping, which is where the reference hangs).
- `ComponentsError` codes `Env` and `SshAgentClient`, completing the
reference's error enum (no operation produces them: the reference maps
every agent failure to `SshAgent`); `ComponentsError.cborDecode`.
- `Seed.creationCborDate` (the stored `CborDate`, sub-second precision kept).
- The DEFLATE implementation is a port of `miniz_oxide` 0.8.9 (`src/internal`),
so `Compressed` bytes equal the reference's;

### Changed (breaking)

- **Decode errors.** Every `fromCbor` / `codec.decode` failure is a
`ComponentsError` with code `Cbor` and the reference's message: dcbor's
own text for a tag or type mismatch, the reference's text for a shape
error (`Invalid signature format`, `invalid signing public key`,
`SealedMessage must be an array`, `Invalid HKDFParams`, …), and the leaf
error's text (`invalid nonce size: expected 12, got 11`) for a size error
inside a tag. `HashType`, `AuthenticationTag`, `MLKEMLevel`, `MLDSALevel`
and `KeyDerivationMethod` keep their own codes, with dcbor failures under
`Cbor` prefixed `CBOR error: `. Unsigned fields decode with dcbor's
negative wrap (`u8` / `u32` / `usize` widths).
- **Tags follow the process-wide store.** `X.codec.tags` and `X.cborTags()`
resolve names through the dcbor tags store at call time; `toUR()` throws
`URError` `TagUnnamed` until `registerTags()`.
- **Removed surfaces that have no reference counterpart:** `toUR()` on
`EncapsulationPrivateKey`, `EncapsulationPublicKey` and
`EncapsulationCiphertext`; `fromCbor` / `codec` on `ECPrivateKey`,
`ECPublicKey`, `ECUncompressedPublicKey` and `SchnorrPublicKey` (they
encode only); `MLKEMPrivateKey.publicKey()`, `MLDSAPrivateKey.publicKey()`
and `mlkemExtractPublicKey`; `HKDFRng.randomData` / `tryFillBytes` /
`fillRandomData`; `ComponentsError.invalidSizeForType` (use
`invalidSize(dataType, expected, actual)`); the second argument of
`bytesFromHex`.
- **Post-quantum key derivation and seeding.** `EncapsulationPrivateKey.publicKey()`
throws `Crypto` for ML-KEM and `SigningPrivateKey.publicKey()` throws
`General` for ML-DSA (`Deriving ML-KEM/ML-DSA public key not supported`);
`PrivateKeys.publicKeys()` fails the same way for such keys. The
reference-mapped keypair factories refuse a seeded generator for the
post-quantum schemes (`Deterministic keypair generation not supported for
this signature/encapsulation scheme`) before drawing anything;
`MLKEMPrivateKey.keypair` / `MLDSAPrivateKey.keypair` keep seeding.
- **EC keys construct by size only** (`ECPrivateKey`, `ECPublicKey`,
`ECUncompressedPublicKey`, `SchnorrPublicKey`); a zero or out-of-range
scalar and an off-curve or wrong-parity point throw `InvalidData` at first
use (`publicKey()`, `sign`, `uncompressedPublicKey()`, `compressedData()`),
where the reference panics. A hybrid `06` prefix with the right parity is
accepted.
- **Verification throws where the reference panics.** `verify` with an
undecodable Ed25519, Schnorr or ECDSA public key, or an ECDSA signature
with `r` or `s` outside `[0, n - 1]`, throws `InvalidData` (the released
bc-crypto 0.14.0 panics there); a decodable key with a wrong signature
returns `false`.
- `X25519PrivateKey.sharedKeyWith` a low-order point derives the reference's
fixed key instead of throwing; PBKDF2 with 0 iterations and scrypt with
`logN` 0 derive.
- **Compressed.** `checksum` is a `u32` and `decompressedSize` a `usize`
(a `bigint` above 2⁵³), both exact on the wire; `fromParts` and decode
reject compressed data larger than the decompressed size with
`Compression` (`compressed data is larger than decompressed size`);
`decompress` reports `corrupt compressed data` and `compressed data
checksum mismatch`; `equals` compares by digest, as the reference.
- **Seed.** `creationDate` is stored as the decoded `CborDate`; a value of
the wrong type at the date, name or note key is dropped (the reference's
`Map::get`), a missing or empty data entry is `Cbor`.
- **SskrShare** stores raw bytes: `from(bytes)` never throws; the header
accessors throw `InvalidData` naming the field when the share is shorter
than five bytes; `combine` passes the bytes to sskr and its `SskrError`
propagates unwrapped. An empty `PrivateKeyBase` is accepted.
- **Strict text parsers.** `X.fromHex` rejects whitespace, odd lengths and
non-hex characters with `Hex` (`hex decoding error: Odd number of digits`,
`Invalid character 'z' at position 0`); `UUID.fromString` trims, removes
`-` and requires 16 bytes (`InvalidSize`); `URI.from` rejects with
`InvalidData` (`invalid URI: invalid URI format`); `CborJson.asStr` keeps a
leading BOM and throws `Utf8` on invalid UTF-8.
- **Texts and `toString()`.** Size errors name the reference's data types
(`digest`, `nonce`, `symmetric key`, `authentication tag`, `Schnorr
signature`, `EC private key`, `ECDSA public key`, `X25519 public key`, …);
`AuthenticationTag` prints `AuthenticationTag("<hex>")`, `CborJson`
`JSON(<text>)`, `SSHAgentParams` `SSHAgent("<id>")`; the `Signature`
summariser prints `Signature(Unknown)` for an SSH algorithm the scheme
enum has no name for; the SSH certificate summariser no longer validates
its content.
- SSH: `SSHSignature.scheme` throws `Ssh` for ECDSA P-521 and RSA
(`Unsupported SSH ECDSA curve` / `Unsupported SSH signature algorithm`);
RSA `sshsig` signing throws `Ssh` (`cryptographic error`), as the
reference's; SSH-DSA signing seeds RFC 6979 with the minimal big-endian
private key, as the `dsa` crate; the OpenSSH and `sshsig` parsers report
the reference's grammar errors (`PEM Base64 error: invalid Base64
encoding`, `unexpected PEM type label: expecting "OPENSSH PRIVATE KEY"`,
`unknown algorithm`, …) and `sshSigningPrivateKey` validates its algorithm
argument.

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

Review against `bc-components-rust` 0.31.1 found one
Expand Down
67 changes: 60 additions & 7 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Migrating from `@bcts/components` to `@blockchaincommons/components`

`@blockchaincommons/components` is the redesigned successor to `@bcts/components`.
`@blockchaincommons/components` is the successor to `@bcts/components`.

## TL;DR checklist

Expand All @@ -26,8 +26,8 @@

## 2. Version numbering restarts

`@bcts/components` versions moved in lockstep with every other package in the
monorepo, which is why it reached `1.0.0-beta.6`. Each extracted package now
`@bcts/components` versions moved in lockstep with every other `@bcts`
package, which is why it reached `1.0.0-beta.6`. Each extracted package now
versions independently and starts again at `1.0.0-beta.1`. A lower version
number here does **not** mean older code.

Expand Down Expand Up @@ -61,15 +61,68 @@ shared type is resolved:

- The public API: every exported name, signature and type is identical.
- The wire format. Encodings produced by `@bcts/components` decode here, and the reverse.
- Parity with the Rust reference implementation. See [`RUST_DIVERGENCES.md`](./RUST_DIVERGENCES.md).
- Parity with the Rust reference implementation, replayed against the published crate by [`tests/rust-validation`](./tests/rust-validation/README.md).

---

# Migrating to the redesigned API
# Migrating to 1.0.0-beta.3

`1.0.0-beta.3` aligns the package with `bc-components-rust` 0.31.1 point by
point (see the CHANGELOG for the full list). What a caller has to change:

- **Register the tags before `toUR()`.** Call `registerTags()` from
`@blockchaincommons/components/tags` once at start-up, as the reference
calls `register_tags()`; `toUR()` throws `URError` `TagUnnamed` until then,
and `codec.tags` / `cborTags()` names follow the process-wide store.
- **Catch `Cbor` for every decode failure.** `fromCbor`, `codec.decode` and
`decodeWith` throw `ComponentsError` with code `Cbor` and the reference's
message; the leaf codes (`InvalidSize`, `InvalidData`, `PostQuantum`, …)
no longer surface from a decoder (the `cause` chain keeps the leaf error).
`HashType`, `AuthenticationTag`, the ML-KEM/ML-DSA levels and
`KeyDerivationMethod` keep their own codes.
- **Removed:** `toUR()` on the three encapsulation enums; `fromCbor`/`codec`
on `ECPrivateKey`, `ECPublicKey`, `ECUncompressedPublicKey`,
`SchnorrPublicKey` (encode only, as the reference); `MLKEMPrivateKey.publicKey()`,
`MLDSAPrivateKey.publicKey()`, `mlkemExtractPublicKey`; `HKDFRng.randomData`,
`tryFillBytes`, `fillRandomData` (use `fillBytes`); `ComponentsError.invalidSizeForType`
(use `invalidSize(dataType, expected, actual)`); `bytesFromHex(hex, what)`
is `bytesFromHex(hex)`.
- **Post-quantum keys:** `EncapsulationPrivateKey.publicKey()` throws for
ML-KEM and `SigningPrivateKey.publicKey()` for ML-DSA; keep the public key
the keypair factory hands out. `generateKeypair` / `createKeypair` with a
post-quantum scheme and an `rng` throw `General`; use
`MLKEMPrivateKey.keypair(level, { rng })` / `MLDSAPrivateKey.keypair(level, { rng })`
for seeded post-quantum keys.
- **EC keys are validated at first use**, not at construction: `ECPrivateKey.from`
accepts any 32 bytes and `publicKey()` / `sign` throw `InvalidData` for a
zero or out-of-range scalar; the same for the public-key classes.
- **`verify` can throw.** An undecodable Ed25519, Schnorr or ECDSA public key,
or an ECDSA signature with `r` or `s` ≥ n, throws `InvalidData` (the
reference panics there); wrap `verify` where untrusted keys reach it.
- `X25519PrivateKey.sharedKeyWith` a low-order point returns the reference's
fixed key instead of throwing; PBKDF2 `iterations: 0` and scrypt `logN: 0`
derive.
- `Compressed.decompressedSize` is a `number | bigint` (exact `usize`);
`Compressed.equals` compares digests; `SskrShare.from` never throws and the
header accessors throw `InvalidData` on a short share; `Seed.creationDate`
round-trips through `CborDate` (`creationCborDate` keeps the precision).
- `X.fromHex` is strict (no whitespace); `UUID.fromString` trims and drops
`-` only; `URI.from` rejects with `InvalidData`; `CborJson.asStr` keeps a
leading BOM.
- DEFLATE is a port of `miniz_oxide`, so `Compressed` bytes are the reference's.
- **New:** SSH-agent lock/unlock (`SSHAgentParams.lock`/`unlock`,
`EncryptedKey.lockWithAgent`/`unlockWithAgent`, `MemorySshAgent`, and the
Node transport `@blockchaincommons/components/ssh-agent-node`); SSH DSA,
RSA and ECDSA P-521 keys; `HKDFRng` page length 0; error codes `Env` and
`SshAgentClient`.

`1.0.0-beta.1` also redesigns the TypeScript surface; **every wire byte is
---

# Migrating to the 1.0.0-beta.1 API

`1.0.0-beta.1` also reshapes the TypeScript surface; **every wire byte is
unchanged** (tagged CBOR, URs, derivations, signatures, encryption are
verified against a frozen pre-redesign baseline and against
verified against a frozen `@bcts/components` baseline and against
`bc-components-rust` 0.31.1 by `tests/rust-validation`). What changed is
how you spell things.

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,18 @@ bundle:

### Version History

- **1.0.0-beta.3 (September 15, 2026)** - Alignment with `bc-components-rust` 0.31.1: decode errors, texts, EC/PQ key semantics, `miniz_oxide` DEFLATE bytes, SSH DSA/RSA/P-521 keys, SSH-agent lock/unlock with an injectable agent.
- **1.0.0-beta.2 (September 12, 2026)** - ECDSA SSH signatures follow the reference and OpenSSH: signing no longer low-s normalises (byte-identical to the reference) and verification accepts either form (a high-s `sshsig` from the reference or OpenSSH was rejected).
- **1.0.0-beta.1 (September 9, 2026)** - Initial beta implementation.

### Roadmap

- Continued testing and auditing on the path from beta to a stable **1.0.0** release.
- Continued parity with the Rust reference implementation as it evolves (see [`RUST_DIVERGENCES.md`](./RUST_DIVERGENCES.md)).
- Continued parity with the Rust reference implementation as it evolves; every release replays the vector corpus against the published crate (see [`tests/rust-validation/README.md`](./tests/rust-validation/README.md)).

### Dependencies

`@blockchaincommons/components` depends on `@blockchaincommons/crypto`, `@blockchaincommons/dcbor`, `@blockchaincommons/rand`, `@blockchaincommons/sskr`, `@blockchaincommons/tags`, `@blockchaincommons/uniform-resources`, `@noble/curves`, `@noble/hashes`, `@noble/post-quantum`, `@scure/base`, `pako` at runtime.
`@blockchaincommons/components` depends on `@blockchaincommons/crypto`, `@blockchaincommons/dcbor`, `@blockchaincommons/rand`, `@blockchaincommons/sskr`, `@blockchaincommons/tags`, `@blockchaincommons/uniform-resources`, `@noble/curves`, `@noble/hashes`, `@noble/post-quantum`, `@scure/base` at runtime.

To build and work on this library, you'll need the following tools:

Expand Down
Loading