Skip to content

feat(crypto)!: BLS12-381 v3 keysets#661

Merged
robwoodgate merged 54 commits into
mainfrom
bls12-381
May 22, 2026
Merged

feat(crypto)!: BLS12-381 v3 keysets#661
robwoodgate merged 54 commits into
mainfrom
bls12-381

Conversation

@robwoodgate

@robwoodgate robwoodgate commented May 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds support for v3 keysets (keyset id prefix 02), which use BLS12-381 instead of secp256k1 for BDHKE. Wire-compatible with Nutshell PR #999 — a v3 cashu-ts wallet redeems against a v3 Nutshell mint without ambiguity.

What changes for v3 proofs:

  • Multiplicative blinding on BLS12-381 G1 (B_ = Y · r, C_ = B_ · a, C = C_ · r⁻¹).
  • DLEQ is gone. A single pairing equality e(C, G2_gen) == e(Y, K2) replaces it (K2 is the mint pubkey in G2). One pairing call instead of ~4 scalar mults + hash + wire bytes per proof.
  • Batch verification via a single multi-pairing wired into the receive path (verifyProofsForReceive); typical mixed-denomination tokens get one pairing call total.

v0 (legacy base64), v1 (00…), v2 (01…) keysets are untouched and continue to work. The DLEQ code paths remain in service of those keysets.

Breaking change

Wallet.checkProofsStates now requires both id and secret on every input (previously only secret). The id selects the hash-to-curve variant for the NUT-07 lookup point Y — secp256k1 for v0/v1/v2, BLS12-381 G1 for v3. Without it, v3 proofs silently miscompute Y and the state check returns nothing. Callers passing full Proof objects are unaffected. See migration-5.0.0.md.

Highlights

  • New src/crypto/bls.ts — BLS12-381 primitives (blind, unblind, sign, single + batch verify, hash-to-curve, deterministic batch weights via Fiat-Shamir).
  • CurvePoint tagged union — point types widen across OutputData, BlindedMessage, BlindedSignature, mint-pubkey parsing to cover both curves cleanly.
  • NUT-13 HMAC derivation reduces mod the BLS scalar field for v3; secp256k1 path unchanged.
  • Mint-side v3 keyset generation matches Nutshell derive_keys_v3 (hardened path + sha256-of-bip32 before Fr reduction).
  • Wallet receive path batches v3 verification; mixed v1/v2/v3 tokens split correctly between DLEQ and pairing.
  • BAT (NUT-22 auth) verification accepts v3 auth keysets.
  • Integration tests wired against a v3 Nutshell mint via examples/auth_mint/docker-compose-nutshell.yml.

Test plan

  • Full unit suite (4345 tests) passes in node + chromium + firefox + webkit
  • BLS deterministic vector (secret="test_message", r=3, a=2) reproduces B_, C_, C byte-for-byte vs Nutshell
  • Pairing equality test against locked Nutshell vectors
  • Batch verify forgery defence: (C₁, secret₁), (C − C₁, secret₂) aggregation attack rejected
  • Mixed v1/v2/v3 receive token: each subset verified by its appropriate path; tampered proof in either subset surfaces the offender
  • NUT-13 v3 vector cross-checked against Nutshell _derive_secret_hmac_sha256 at counter where the raw HMAC exceeds BLS_FR_ORDER (so the mod-Fr branch is exercised)
  • Round-trip: v3 token getEncodedTokengetDecodedToken preserves the 02… id
  • checkProofsStates migration: v3 proofs now match against the mint's G1 Y (previously silently empty)
  • Integration suite against a live v3 Nutshell mint (make integration-nutshell-bls) — requires running mint, smoke-tested locally
  • Cross-check v3 mint key seed → keys parity with Nutshell derive_keys_v3: byte-for-byte locked vector in test/crypto/NUT01.test.ts (mnemonic='TEST_PRIVATE_KEY', path m/0'/0'/0', amounts [1,2,4,8], unit 'sat')

References

@codecov

codecov Bot commented May 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.67241% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.34%. Comparing base (be81267) to head (31371a6).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/utils/core.ts 89.79% 3 Missing and 2 partials ⚠️
src/crypto/bls.ts 95.50% 1 Missing and 3 partials ⚠️
src/crypto/NUT13.ts 85.71% 0 Missing and 2 partials ⚠️
src/model/OutputData.ts 95.12% 0 Missing and 2 partials ⚠️
src/crypto/NUT01.ts 91.66% 1 Missing ⚠️
src/crypto/core.ts 88.88% 0 Missing and 1 partial ⚠️
src/wallet/Wallet.ts 87.50% 0 Missing and 1 partial ⚠️
src/wallet/WalletEvents.ts 83.33% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #661      +/-   ##
==========================================
+ Coverage   92.32%   92.34%   +0.02%     
==========================================
  Files          50       51       +1     
  Lines        4779     4952     +173     
  Branches     1172     1217      +45     
==========================================
+ Hits         4412     4573     +161     
- Misses        157      160       +3     
- Partials      210      219       +9     
Flag Coverage Δ
integration 38.57% <20.77%> (-1.26%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@robwoodgate robwoodgate changed the title feat(crypto)!: BLS12-381 v3 keysets (Nutshell PR #999) feat(crypto)!: BLS12-381 v3 keysets May 15, 2026
Phase 1 of the v3 keyset port (see PLAN.md). Adds src/crypto/bls.ts
wrapping @noble/curves/bls12-381 with the multiplicative-blinding and
pairing-verification primitives that Nutshell PR #999 specifies for
v3 (`02`-prefixed) keysets:

- hashToCurveBls (RFC 9380 G1, DST `CASHU_BLS12_381_G1_XMD:SHA-256_SSWU_RO_`)
- blindMessageBls / unblindSignatureBls (multiplicative)
- createBlindSignatureBls (mint side, for tests + future mint helpers)
- verifyUnblindedSignatureBls (single pairing e(C,G2)==e(Y,K2))
- batchVerifyUnblindedSignatureBls (single multi-pairing, grouped by K2)

Verified byte-for-byte against Nutshell's deterministic test vector
(secret="test_message", r=3, a=2). Existing 1392 tests unchanged.
Add `CurvePoint = {kind:'secp'|'blsG1', pt}` and hex auto-decode in core.ts
so the wallet output/proof path can carry either curve. BlindedMessage.B_
and BlindedSignature.C_ now hold a CurvePoint; serialization still emits
compressed hex (66 or 96 chars). OutputData wraps its secp points with
asSecpPoint at construction, and toProof's DLEQ verify is gated on
B_.kind === 'secp' — v3 (BLS) proofs skip DLEQ entirely.
…ase 3)

- deriveKeysetId: versionByte=2 reuses the v1 preimage format with a `02`
  prefix (matches Nutshell derive_keyset_id_v3 — preimage is
  `a:hex(K2),…|unit:…` with optional `|input_fee_ppk:` / `|final_expiry:`).
- hasValidDleq short-circuits true for `02…` proofs — v3 carries no DLEQ;
  pairing verification is the equivalent guarantee at swap/melt time.
- NUT13 getDerivationKind: `02` -> HMAC_SHA256. Blinding-factor reduction
  branches on prefix: `x % BLS_FR_ORDER` for v3 (Fr is ~2^255 so the
  256-bit HMAC can exceed it more than once); secp keeps the single-
  subtract optimization.
- NUT01.verifyUnblindedSignature now dispatches by `proof.id` prefix and
  handles both UnblindedSignature and UnblindedSignatureBls. New
  `parseMintPubKey` helper returns a tagged MintPubKey union (secp/blsG2)
  so callers can decode the mint pubkey hex column without sniffing length.
- Tests: two v3 keyset-id fixtures cross-checked against a Python eval of
  Nutshell's derive_keyset_id_v3; NUT-13 v3 derivation asserts in-range
  scalar across counters and divergence from v2 for the same seed/counter.
Dispatch OutputData factories (createSingleRandomData / createSingleP2PKData /
createSingleDeterministicData) on keysetId prefix: v3 ("02…") uses
blindMessageBls + asBlsG1Point, everything else stays on secp256k1 additive
blinding. OutputData.toProof short-circuits to constructUnblindedSignatureBls
for v3 signatures (no mint pubkey, no DLEQ).

Adds test/model/OutputData.bls.test.ts: full mint→swap round-trip with a
synthetic v3 keyset and the locked Nutshell PR #999 deterministic vector
exercised through the OutputData.deserialize → toProof path.
…gths (Phase 5)

- Add doc comments on BLS_HASH_TO_CURVE_DST and BLS_FR_ORDER citing
  Nutshell PR #999 and RFC 9380; expose BLS_G2_GENERATOR as a named
  constant and use it in pairing verifications.
- Annotate Keys, B_, C_, and Proof.C with per-version hex-length notes
  (66 vs 96 vs 192) so consumers see the v3 wire shape inline.
Six wallet call sites still assumed secp after Phases 1-5; v3 tokens
either failed silently or were accepted with no crypto check. This
closes them:

- mapShortKeysetIds accepts 0x02 alongside 0x01 (same prefix-match)
- checkProofsStates + WalletEvents.proofStateUpdates dispatch hashToCurve
  vs hashToCurveBls by p.id?.startsWith('02') (id widened to optional)
- validateReturnedSignatures skips the requireSigDleq check per-index
  when the v3 signature carries no DLEQ
- createNewMintKeys with versionByte=2 generates real G2 pubkeys via
  the new getG2PubKeyFromPrivKey (K2 = a * G2_gen)
- hasValidDleq / verifyDleqIfPresent now perform pairing verification
  on v3 proofs via verifyUnblindedSignatureBls — receive path was
  silently accepting any v3 C before

Plus an NUT-13 v3 fixed vector at counter=2 (raw HMAC exceeds Fr order,
so the mod-Fr branch is exercised), cross-checked against Nutshell's
_derive_secret_hmac_sha256.
BREAKING CHANGE: `Wallet.checkProofsStates` now requires `id` on
every proof in addition to `secret`. See migration-5.0.0.md.

Closes the six items from the Phase 6 review:

- hasValidDleq: moves parseMintPubKey into the v3 try/catch so
  malformed G2 hex returns false (mirrors secp behaviour).
- verifyUnblindedSignature: adds a===0n guard on the v3 branch to
  match createBlindSignatureBls.
- checkProofsStates: signature tightened to require id on every
  proof. Removes silent secp-miscompute when v3 callers omitted it.
  Only API surface that breaks vs main.
- OutputData.toProof: asserts sig.id === blindedMessage.id, since
  the method is reachable without validateReturnedSignatures
  (e.g. via createMeltChangeProofs).
- Deletes BlindSignatureBls / RawBlindedMessageBls /
  UnblindedSignatureBls (introduced earlier in this branch, never
  shipped to main). These were structurally identical to their
  secp namesakes (G1Point IS WeierstrassPoint<bigint>); BLS
  functions now return/accept the unsuffixed types via type-only
  import from ./core.
- verifyUnblindedSignature signature collapses back to a single
  UnblindedSignature (matches main shape).

Adds migration-5.0.0.md documenting only the change visible vs main.
Adds nutshell-bls-build / nutshell-bls-up / nutshell-bls-down to spin
up a v3 (BLS) Nutshell mint for integration testing. There is no
published BLS image yet, so the target builds from a local checkout
(default ../nutshell on feature/bls12-381-v3-keyset, ≥0.21.0 — which
emits v3 keysets by default via version_tuple dispatch in
cashu/core/base.py).

Reuses NUT_ENVS and port 3338 so test/integration.test.ts retargets
transparently from `make nutshell-up` to `make nutshell-bls-up`.

Built natively (no PLATFORM_FLAG, unlike the pinned cashubtc/nutshell
image): python:3.10-slim is multi-arch, and BLS keygen through
Rosetta is unusably slow on arm64 hosts — initial keyset generation
hangs at "Loaded 0 keysets" for many minutes under emulation; native
arm64 completes it in well under a second.

Override the source path with NUT_BLS_PATH=/elsewhere if your
checkout lives somewhere other than the sibling worktree default.
PLAN.md is a ralph-loop artefact — useful locally, noisy in git
history. Drop it from the index here; future loops keep editing it
freely, no further commits touch it.

Pairs with PLAN.md added to ~/.gitignore_global so it stays
untracked by default in any future worktree / repo too.
`sendOffline(amount, proofs, { requireDleq: true })` filtered every
v3 (BLS) proof because they carry no DLEQ field — even though their
pairing equality provides the same cryptographic guarantee, and the
receive-side `hasValidDleq` already accepts them.

Consumers using `requireDleq: true` against a v3 mint hit "Not enough
funds available to send" on every send. Filter now lets v3 proofs
pass (id starts with `02`); v0/v1/v2 still require the DLEQ field.
Mirrors the receive-side dispatch landed in Phase 6.

Adds unit tests covering both directions: v3 proofs accepted under
requireDleq, v1/v2 proofs without DLEQ still rejected.
Two pieces of plumbing for running the integration suite against a
local v3 (BLS) Nutshell mint:

- Makefile: bump MINT_GLOBAL_RATE_LIMIT_PER_MINUTE alongside the
  existing transaction limit. Nutshell 0.21 defaults the global
  endpoint limit to 60/min which is hit by the test suite around
  the time websocket/restore tests run.

- test/integration.test.ts: add an `isV3Mint(wallet)` helper and
  guard the five v1/v2-specific DLEQ tests on it. These tests
  introspect `p.dleq` on minted/sent proofs, or expect requireDleq
  to fail for proofs without DLEQ — both v1/v2-only semantics.
  v3 (`02…`) proofs use pairing verification (see Phase 6/7 wiring
  in hasValidDleq / verifyDleqIfPresent), so the assertions don't
  apply.

Result: 45/45 pass against a fresh `make nutshell-bls-up` mint
(requires the two upstream Nutshell PR #999 fixes — Proof.Y
hash-to-curve dispatch and BlindedSignature dleq=None — separately
filed).
Replaces 12 `id.startsWith('02')` curve-dispatch sites with a named
`isBlsKeyset(keysetId)` helper that mirrors Nutshell's
`is_bls_keyset` (`cashu/core/crypto/keys.py`): true when the keyset
ID hex-prefix decimal-parses to a value >= 2.

Two wins:

- Self-documenting at call sites — reads as intent ("is this BLS?")
  rather than implementation ("does it start with '02'?").
- Forward-compatible: future BLS-based versions (`03…`, `04…`, …)
  pass automatically without touching the dispatch sites. Matches
  Nutshell's policy on this question.

Touched dispatch sites:

- crypto/NUT01.ts: verifyUnblindedSignature, parseMintPubKey
- crypto/NUT13.ts: deriveHmac mod-Fr reduction
- model/OutputData.ts: toProof and blindMessageForKeyset
- utils/core.ts: hasValidDleq and verifyDleqIfPresent
- wallet/Wallet.ts: validateReturnedSignatures, checkProofsStates,
  and the sendOffline requireDleq filter
- wallet/WalletEvents.ts: proofStateUpdates Y dispatch
- test/integration.test.ts: isV3Mint helper used in DLEQ skip guards

Adds isBlsKeyset to `src/crypto/core.ts` next to the CurvePoint
helpers, with unit tests in `test/crypto/core.test.ts` covering
v0/v1/v3 prefixes, forward-compat (`03…`, `09…`), legacy base64,
and malformed input.

API report regenerated. Node suite 1418/1418, integration 45/45.
Replace the per-proof DLEQ loop in `Wallet.prepareSwapToReceive` with
`verifyProofsForReceive`, which splits proofs by curve:

- v0/v1/v2 subset: per-proof DLEQ via existing helpers.
- v3 (BLS) subset: single multi-pairing via batchVerifyUnblindedSignatureBls.
  On batch failure, re-runs per-proof to pinpoint and name the offender in
  the thrown CTSError. Single-proof v3 takes the direct
  `verifyUnblindedSignatureBls` path to skip the batch wrapper's per-item
  random-scalar multiplication (negligible at N=1 per the benchmark).

`scripts/bench-bls-verify.ts` measures the speedup at N=1,4,8,16,32 (Apple
Silicon, noble v2.2.0): 1.04x / 1.83x / 2.25x / 2.51x / 2.74x. Realistic
receive tokens span N=4-16 so batch wins materially on the typical path.

Error messages now suffix the offending keyset id + amount; substring-
compatible with existing `toThrow('Token contains ...')` assertions.

Node 1426/1426, browser 2883/2886 (3 pre-existing skips), integration
45/45 against `make nutshell-bls-up`.
Given a single aggregated signature C' = a·(Y1+Y2), an attacker can pick
any C1 and set C2 := C' - C1; the un-weighted batch check on Sum(Ci) vs
Sum(Yi) would accept the pair (C1, Y1), (C2, Y2) as valid signatures even
though neither is a real signature on its own. In a Cashu context, one
signed B_ = (Y1+Y2)·r would expand into N spendable proofs.

`batchVerifyUnblindedSignatureBls` already weights each proof by a fresh
random scalar r_i drawn from CSPRNG (reduced mod Fr), which forces the
attack to require r1 = r2 — a 2^-255 event. This commit:

- Adds a regression test that builds C' from scratch, splits it into
  forged (C1, C2), sanity-asserts the un-weighted check *would* accept it
  (proving the test exercises the real attack path), then asserts the
  weighted check rejects it across 8 runs.
- Expands the doc comment on `batchVerifyUnblindedSignatureBls` to spell
  out the attack model, the algebra showing why per-proof randomness
  defends it, and the timing requirement that scalars be drawn after
  items are finalised.

Mutation-tested by setting `rs = items.map(() => 1n)` — the test fails
exactly as predicted. Restored to randomBytes(32) reduced mod Fr.

Node 1427/1427.
`batchVerifyUnblindedSignatureBls` now derives its per-proof weights
deterministically from a SHA-256 transcript over (DST, items, i) rather
than `randomBytes(32)`. Removes the CSPRNG dependency that Phase 8
introduced on the wallet's receive path. Mirrors `deriveDLEQNonce`
(NUT12) and NUT13's BLS branch; standard construction for pairing-based
batch verifiers.

Security equivalent: independent per-proof weights still defeat the
canonical aggregation forgery (`C' = a·(Y1+Y2)` → split into `(C1,
C'-C1)`), which still requires `r1 = r2` (negligible). ROM is already
required by `hashToCurveBls`, NUT-12, and NUT-13, so no new assumption.

Transcript layout: `DST || for each item (C || K2 || len32(secret) ||
secret)`. Length-prefix keeps the encoding distinct against secrets
crafted to contain C/K2-shaped bytes.

Other changes:
- `verifyProofsForReceive` accepts `ProofLike[]` and normalises
  internally, matching the convention of other wallet-facing helpers.
- NUT13's nested-ternary curve dispatch flattened to plain if/else.
- `scripts/bench-mod-vs-sub.ts` records `% Fr` vs subtract-twice timing
  (mod is 2x slower per op but invisible against pairing cost; clarity
  wins).
- 8 new tests in `test/crypto/bls.test.ts` covering determinism,
  transcript field coverage, position commitment, and result stability.

Node 1435/1435, integration 45/45 against fresh `make nutshell-bls-up`.
The `{@link deriveBatchWeights}` reference already conveys the
Fiat-Shamir / no-CSPRNG provenance; the inline parenthetical was
duplicative.
Replace the per-proof hasValidDleq loop in AuthManager.topUp with
verifyProofsForReceive, matching the wallet receive path. v3 BAT proofs
(no DLEQ, BLS pairing instead) now verify correctly; v0/v1/v2 BAT proofs
keep the same DLEQ semantic. Multi-proof v3 top-ups go through the
single-pairing batch path. Error wording widened from "invalid DLEQ" to
"BAT that failed verification" since v3 has no DLEQ; the inner CTSError
(which names the offender and the specific failure) is attached as cause.

Nutshell 0.21.0 generates v3 auth keysets by default, so a fresh
Nutshell auth mint will return BLS BATs; this change is what makes the
wallet-side verification succeed against them.
Mirrors the existing CDK auth_mint demo against a locally-built
Nutshell 0.21.0+ image (the first version that emits v3 BLS keysets
by default for the auth ledger). Exercises the BLS BAT verification
path wired into AuthManager.

- docker-compose-nutshell.yml — Keycloak + Nutshell auth mint built
  from ${NUT_BLS_PATH:-../../../nutshell}. Same authnet /
  keycloak.localtest.me alias setup as the CDK compose; mirrors
  NUT_ENVS from the repo-root Makefile; mint command blocks on a
  working OIDC discovery URL before exec'ing `poetry run mint`
  (depends_on only waits for container start, not for Keycloak to
  finish serving the realm).
- examples/auth_mint/Makefile: nutshell-up / nutshell-down /
  nutshell-demo / nutshell-demo-device / nutshell-demo-pkce /
  nutshell-demo-ci / nutshell-logs / nutshell-clean. Existing CDK
  targets untouched.
- cashu-realm.json: "sslRequired": "none". Keycloak 25's realm
  default is "external", which rejects plain HTTP from non-loopback
  source IPs; the wallet running on the host loops in via docker
  port mapping and gets rejected. Dev-only setting; also fixes the
  CDK demo on Keycloak 25 if anyone has noticed it broken there.

Demo end-to-end requires the Nutshell-side fixes in
cashubtc/nutshell#1004; once that lands on feature/bls12-381-v3-keyset
the local build picks them up automatically.
Spell out that the security of `batchVerifyUnblindedSignatureBls` does
not depend on weight secrecy. The transcript binds each `rᵢ` to the
full batch `(Cᵢ, K2ᵢ, secretᵢ)`, so an attacker who knows the
derivation still cannot tune proofs against the weights — fixing the
proofs is what fixes the weights.
Rewrite verifyUnblindedSignatureBls as e(-C, G2) · e(Y, K2) == 1 evaluated
through pairingBatch — one final exponentiation instead of two. Saves ~27%
per single-proof verify on Apple Silicon (final exp is ~60% of BLS12-381
pairing cost). Math is identical; the batch path was already using this
shape, so single-proof now mirrors it.

Bench numbers (scripts/bench-bls-verify.ts, Node 22, noble v2.2.0):

  N    per-proof (ms)  before → after
  1    24.49 → 17.91   (−27%)
  4    98.17 → 70.78   (−28%)
  8    197.28 → 143.08 (−27%)
  16   392.97 → 287.09 (−27%)
  32   789.70 → 575.92 (−27%)

This compresses the batch-vs-per-proof speedup ratio (now 1.12×–1.55×
across N=4..32) but does not flip the ordering — batch still wins for
N≥4, so the verifyProofsForReceive wiring decision stands.

Also refresh the bench script header — the original "decision threshold"
note is stale; the script is now a regression bench.
createNewMintKeys with versionByte=2 now hardens the per-amount BIP32 path
(/${counter}') and sha256-hashes the BIP32 output before reducing mod Fr —
matching Nutshell derive_keys_v3.

Two reasons:

- Hardened path prevents sibling-key recovery from a leaked child key + xpub.
- sha256 gives a uniform 256-bit input to Fr reduction; raw BIP32 output is
  bounded mod secp's `n`, which differs from Fr and would yield a slight
  bias if reduced directly.

v0/v1/v2 paths are unchanged for back-compat with TEST_PRIV_KEY_PUBS and
existing deployments. Hardening v0/v1/v2 is parked as a TODO for v5 since
it would invalidate the secp fixture.

Result: cashu-ts v3 mint keys now match Nutshell PR #999 from the same
seed — useful for deterministic test vectors and any cross-implementation
parity checks.
Generated via `derive_keys_v3('TEST_PRIVATE_KEY', "m/0'/0'/0'", [1,2,4,8])`
+ `derive_keyset_id_v3(..., 'sat')` in Nutshell PR #999. cashu-ts produces
byte-identical G2 pubkeys and keyset id with the hardened-path + sha256
derivation landed in c12597c. Guards against accidental regressions in
either side of the path or hash step.
A malicious mint could return garbage C_ in mint/swap responses for v3
(BLS) outputs. The wallet would compute C = garbage·r⁻¹, store an invalid
proof, mark its inputs spent, and the user would lose funds.

The secp path is mitigated because toProof verifies the DLEQ inline.
v3 carries no DLEQ — the pairing equality e(C, G2_gen) == e(Y, K2) is
the equivalent guarantee, but it was only run downstream during receive
(verifyProofsForReceive). Mint/swap/restore paths build proofs via
toProof and never round-tripped through receive verification.

Fix: run the pairing check inline in toProof for v3, mirroring the secp
DLEQ check. K2 is looked up from the keyset already passed in. Affected
call sites (now uniformly defended): Wallet.swap, _doMint, _meltInternal,
_restoreBatch, AuthManager.requestNewAuthTokens.

Tests:
- Forged-amount C_ (signed with the wrong amount's key) now throws at
  toProof instead of "succeeds-then-fails-later".
- New garbage-C_ test exercises the CTF scenario directly.
- Nutshell deterministic vector updated to pass the real K2=2·G2_BASE
  through to toProof.

Full suite: 4353 pass (node + chromium + firefox + webkit).
After OutputData.toProof now verifies the BLS pairing inline, the
follow-up verifyProofsForReceive call here runs a second pairing for
v3 BATs (~17ms each, top-up path only). Kept deliberately for the secp
branch where requireDleq: true enforces DLEQ-must-be-present — toProof
alone can't give that guarantee for secp because its DLEQ check is
conditional on the field being sent.
OutputData.toProof, hasValidDleq, and verifyProofsForReceive all sit
inside an `if (isBlsKeyset(id))` branch — they already know the curve.
Going through parseMintPubKey just to get back a `MintPubKey` union and
then assert kind === 'blsG2' was buying a defensive type-narrow that
could never trip in practice (parseMintPubKey returns blsG2 iff
isBlsKeyset(id) is true).

Switch these callers to pointFromHexG2(hex) directly, mirroring the secp
side which uses pointFromHex(hex). Removes the union, the type-narrow
checks, the c8-ignores, and a layer of indirection — net 12 fewer lines.

parseMintPubKey itself stays as a public-API utility for callers that
genuinely don't know the curve up front.
After moving v3-aware callers to pointFromHexG2 directly (4edc89a), the
parseMintPubKey wrapper has zero internal users — and zero external value:
the dispatch is just isBlsKeyset(id), and both branches call exported
parsers (pointFromHexG2 / pointFromHex) that callers can use directly.

The MintPubKey tagged union goes with it — its only producer was
parseMintPubKey, and its existence encouraged the same defensive
kind-narrow pattern we removed.

v5 hasn't shipped, so this is pre-release surface trimming, not a
breaking change. Regenerated etc/cashu-ts.api.md.
Two bugs in the previous isBlsKeyset fix:

1. Number('0a') is NaN. Using Number() to read the version byte
   misclassifies ~39% of the 256-value byte space as non-BLS — any future
   hex version prefix containing a letter (0a, 1e, ff, …) silently fails
   the >=2 check. Forward compat broken. Fix: parseInt(_, 16).

2. A 12-char legacy base64 id that happens to contain only 0-9/a-f chars
   (e.g. '22aabbccddee') passes isValidHex AND yields Number('22')=22 >= 2,
   so the wallet treats a secp legacy mint as BLS and crashes on the proof.
   Probability ~1 in 670k for random base64 but real. Fix: reject
   length === 12 explicitly; cashu legacy ids are exactly 12 chars while
   modern hex ids are 16 (short) or 66 (full) — disjoint length classes.

Regression tests cover both: hex versions with letters (0a/1e/ff) must
classify as BLS; 12-char all-hex base64 must not.

Thanks to the CTF for the catch — my prior fix only addressed the visible
half of the issue.
Lock the id to the only modern hex widths cashu-ts encounters (16 short,
66 full). Switch the version-byte read from Number(...) to parseInt(_, 16)
so hex versions containing letters (0a, 1e, ff …) parse correctly.

Side effect: any non-modern-hex shape — legacy base64, malformed input,
future formats with different lengths — returns false without further
checks. Matches CDK's strict id parsing.
createNewMintKeys can't generate v0 (legacy base64) keys — only hex
(versionByte 0/1/2 → v1/v2/v3). Updated the comment and TODO accordingly.

checkProofsStates docstring previously paired three versions with two
prefixes ("v0/v1/v2 (00…/01…)"), implying v0 had a hex prefix. Dropped
the bracketed prefixes since the version-prefix mapping is established
elsewhere.
`bls12_381.pairingBatch` throws a generic `Error("pairing is not
available for ZERO point")` if any g1/g2 input is the identity. A
v3 proof with `C` encoded as the G1 identity (`0xc0` + zeros) reached
the verify path and crashed `wallet.receive()` with an unwrapped
generic Error instead of a `CTSError`.

Guard both `verifyUnblindedSignatureBls` and
`batchVerifyUnblindedSignatureBls` to return `false` when `C` or `K2`
is the point at infinity. All four call sites already convert `false`
into a properly named `CTSError` (or, in `hasValidDleq`, propagate it
as a clean rejection), so this slots in without scattered try/catch.

`K2` is mint-provided and shouldn't be identity in practice, but the
guard is one extra `is0()` check and protects against malformed
keysets the same way.
`@noble/curves` parses compressed identity encodings (`0xc0` + zeros)
into the ZERO point — strictly subgroup-valid, but never a legitimate
Cashu signature, commitment, or mint pubkey. Reject identity at the
parse boundary in `pointFromHexG1` / `pointFromHexG2` so malicious
proof.C or malformed mint keyset hex throws a `CTSError` at the
parse site instead of leaking into downstream math.

Defense-in-depth with the verify-layer `is0()` guards in
`verifyUnblindedSignatureBls` and `batchVerifyUnblindedSignatureBls`:
the verify-layer guard still matters because both functions are
exported and may be called with constructed points (skipping the
parsers).
A malicious mint can return a signature for a smaller denomination than
the wallet requested. The downgraded signature verifies cleanly against
the smaller-amount K2/A, and the wallet would store a proof with the
downgraded amount — funds lost.

Enforce request/response amount binding in two layers:

- `OutputData.toProof` rejects `sig.amount !== blindedMessage.amount`
  before the key lookup, so direct toProof callers (e.g. `AuthManager`)
  that bypass wallet-level validation are also covered.
- `Wallet.validateReturnedSignatures` mirrors the same check
  unconditionally, with the index-aware error message preserved for
  batch fail-fast.

Blanks (`amount=0`, NUT-08 fee change and NUT-09 restore) are the only
flows that legitimately leave the amount unspecified; both layers use
`Amount.isZero()` as an explicit escape so the mint's amount remains
authoritative there. The previous `checkAmounts: false` opt-out in
`createMeltChangeProofs` is now redundant and dropped.

Test fixtures in `bolt12.test.ts` were mocking `blindedMessage.amount`
with raw bigints (`16n`), violating the `SerializedBlindedMessage.amount:
Amount` type contract. The old equality check tolerated this via
`Amount.equals(AmountLike)`; the new `isZero()` call requires an Amount.
Fixtures fixed to `Amount.from(N)`.
The full mint → swap round-trip drives ~21 BLS pairings (7 amounts ×
3 verifications). Locally it runs in ~700-800ms, well under the 5s
default, but under the 4-environment parallel run (node + chromium +
firefox + webkit) CPU contention can push a node-side run past 5s and
trip a spurious timeout. 15s leaves plenty of headroom while still
catching any real regression that 10×s the work.
Follow-on to the prior commit, which broadened the `checkProofsStates`
parameter type from `Pick<Proof, 'secret' | 'id'>` to
`Pick<ProofLike, 'secret' | 'id'>`. The api-extractor output was not
regenerated at the time; this commit syncs it.
Three small coverage adds for branches this PR introduced or modified:

- OutputData.toProof: new keyset-id mismatch check (src/model/OutputData.ts:145)
  rejects a SerializedBlindedSignature whose `id` does not match the output's
  blinded message id, ahead of the BLS pairing / DLEQ path.

- deriveKeysetId: case 1 and case 2 now share the unit-required guard and the
  throw interpolates the version byte (src/utils/core.ts:459). Covers both
  versionByte=1 and versionByte=2 against the shared branch.

- mapShortKeysetIds: short-ID handling widened from `=== 0x01` to
  `=== 0x01 || 0x02` (src/utils/core.ts:670). Regression check that a v=3 short
  id (first byte 0x03) still falls through to the unknown-version throw and is
  not silently accepted.
`hasValidDleq` previously checked `hasCorrespondingKey` only inside the BLS
branch and the SECP-with-DLEQ branch. SECP proofs with no DLEQ short-circuited
out (via the separate `verifyDleqIfPresent` helper) before any amount check
ran — a forged-amount SECP proof with no DLEQ could pass
`verifyProofsForReceive` and surface only at swap time.

- `hasValidDleq` performs `hasCorrespondingKey` up front before any branching,
  so amount validation is unbypassable on every code path.
- `hasValidDleq` gains optional `{ require?: boolean }`. Default `false` is
  the NUT-12 spec semantic ("MUST verify-if-present"). `true` opts into
  above-spec strictness (missing DLEQ on v0/v1/v2 returns false). v3 (BLS)
  ignores the flag — pairing equality stands in.
- `verifyDleqIfPresent` removed (added in #656; its semantic is now the
  default behaviour of `hasValidDleq`).
- `verifyProofsForReceive` routes v0/v1/v2 through `hasValidDleq` with
  `require: requireDleq`; v3 batch path keeps its own inline amount check
  since it bypasses `hasValidDleq`.

Tests: migrated `verifyDleqIfPresent` describe block to `hasValidDleq default
(NUT-12 verify-if-present)`, added an unbypassable-amount-check case, added a
`require: true` strict-mode block, and a `verifyProofsForReceive` regression
for the forged-amount SECP-no-DLEQ case.

migration-5.0.0.md updated for both the removal and the default flip.
`OutputData.toProof` called the curve-point parsers and amount-keyed key
lookups directly inside `completeSwap`'s post-swap loop. A buggy or malicious
mint that returned malformed `C_` hex would surface a generic `Error` from
`pointFromHexG1` / `pointFromHex`; a mint that returned an amount not in the
active keyset for a blank output (NUT-08 fee change / NUT-09 restore, where
the mint is authoritative over the denomination) would pass `undefined` into
the parser and surface an opaque `TypeError`. In both cases the throw lands
after the mint has already destroyed the user's inputs, so the wallet's
local-storage write is aborted and the user has to NUT-09 restore — without
a clear error message telling them so.

- Extracted a module-local `RECOVERY_HINT` constant and routed the existing
  missing-signature / amount-mismatch messages through it.
- BLS branch: `pointFromHexG1(sig.C_)` and `pointFromHexG2(keyset.keys[amount])`
  wrapped in a single try/catch with an explicit existence check on the keyset
  key (no more `pointFromHexG2(undefined)`). Failure throws `CTSError` with
  the recovery hint and the original error preserved on `cause`.
- SECP branch: parse `A` and `C_` once at the top under the same try/catch
  (same existence check), then reuse `C_` for both the DLEQ verification and
  the final `constructUnblindedSignature` — eliminates the two redundant
  re-parses of `sig.C_` further down.
- Locals typed as `WeierstrassPoint<bigint>` to match the rest of the secp
  code paths.

Tests: new cases in `test/model/OutputDataCreator.test.ts` (secp malformed
`C_`, secp blank with mint-picked amount missing from keyset) and
`test/model/OutputData.bls.test.ts` (BLS malformed `C_`, BLS blank with
mint-picked amount missing from keyset) assert the CTSError shape and the
NUT-09 hint in the message.
Two DoS gaps in the v3 batch-verification path:

`deriveBatchWeights` built an O(n)-sized Fiat-Shamir transcript and then
re-hashed it inside the per-item loop, making the derivation O(n²) in
the proof count. A token with tens of thousands of proofs would freeze
the event loop for minutes. Collapse the transcript to a 32-byte
challenge once and derive each weight from `SHA256(challenge | i | ctr)`
so per-item work is O(1). Length-prefixing still keeps the transcript
injective, so the challenge remains unique per batch.

`verifyProofsForReceive` wrapped the G1 proof parse in `try/catch` but
left the G2 keyset-pubkey parse outside. A keyset with the v3 `02`
prefix whose pubkey is the wrong length (e.g. a 33-byte secp key from a
misclassified or hostile keyset, or a truncated key from corruption)
would throw an unhandled `Error` from `pointFromHexG2` and escape the
receive path. Move both parses under the same `try/catch` so any
malformed point surfaces as a `CTSError` with the offender suffix, in
line with the sibling `hasValidDleq` path.
`deriveBatchWeights` built a `parts` array of `4n+1` chunks and passed
it to `concatBytes(...parts)`. V8 enforces an upper bound on function
argument count (typically ~64k), so a token with roughly 16k+ proofs
would throw a synchronous `RangeError` out of the receive path before
batch verification ever ran.

Switch to noble's streaming SHA-256: `sha256.create()` + `update()` per
chunk + `digest()` produces the same 32-byte challenge with no spread
and no intermediate concatenation. Hash output is byte-identical to the
prior concat-then-hash, so existing transcript-binding / determinism /
length-prefix-injectivity tests cover the refactor.

The pre-existing condition was masked because the `parts` array was
short-lived inside the BLS verify function and only mattered on very
large batches that no test exercised.
The JSDoc on `proofStatesStream` already documents that wallet errors
end the iterator gracefully rather than throwing, and a regression test
covers that path. The implication for consumers — that normal stream
completion is not a delivery guarantee — was left implicit, which is
easy to miss when wiring the iterator into payment-tracking UI.

Spell it out: pair with a timeout or an explicit liveness check
(`Wallet.checkProofsStates`) when the consumer needs to know every
expected update arrived. No behavior change.
The iterator previously swallowed WebSocket failures and mint-side RPC
errors, ending the loop cleanly so consumers couldn't tell a healthy
stream-end from a broken one. The graceful-end behavior was documented
and covered by a regression test, but it diverged from the Node
`AsyncIterable` convention (`Readable`, async generators, etc.) and made
silent UI hangs easy to write — a wallet author reading the type
signature could reasonably assume `for await ... catch` would catch
errors, then find their UI frozen after a transient connection blip.

v5 flips the contract: errors from the wallet are thrown from the
iterator. Abort handling is unchanged — aborting the supplied signal
still ends the stream cleanly without throwing. Both error sources
(setup-promise rejection and the runtime `proofStateUpdates` callback)
now flow into a single `streamErr` slot that's re-thrown after the queue
drains, so any in-flight payloads are still delivered before the throw.

Docs and migration guide updated. The `proofStatesStream` example in
docs-src now shows the `try/catch` + `AbortError` branch pattern.

Migration: wrap the `for await` in `try/catch`. Consumers that already
caught aborts need no change beyond not assuming silent completion
means every expected update arrived.
The v2/v3 resolution branch was gated only on the version byte (0x01/0x02),
not on length. Full-length 33-byte IDs (66 hex chars) need no resolution but
still entered the cache lookup and could throw when the caller passed an empty
keyset list — e.g. `getDecodedToken(token, [])` used as a standalone parser
for a token carrying non-truncated IDs.

Now:
- length 16 → resolve against the cache (the actual short-ID path)
- length 66 → pass through unchanged (already fully qualified)
- any other length on a 0x01/0x02-prefixed ID → throw as malformed

Also reword the empty-cache error to name the offending ID and point at
`wallet.loadMint()` / `KeyChain.getAllKeysetIds()` as the fix.
Previously `>= 0x02` to mirror Nutshell's permissive `is_bls_keyset`.
In cashu-ts, isBlsKeyset doubles as the gate for the Fr-order reduction
in NUT-13 HMAC derivation (BLS_FR_ORDER vs SECP256K1_N), where a wrong
choice silently produces invalid blinding factors and breaks restore —
same loss-of-funds class as a wrong KDF.

Strict gate now matches `getDerivationKind` (always required v=01|02):
when a future BLS-based version lands, both must be widened together.

mapShortKeysetIds stays permissive — it's pure ID-format resolution
(prefix-match), not crypto dispatch. An unrecognised version still
fails loudly downstream once the wallet tries to use the resolved
keyset. Error messages drop the hardcoded "v2/v3" phrasing.
Three spec-alignment fixes from a recent NUTs review pass:

NUT-00 Point Validation. `pointFromHexG{1,2}` now reject (a) encodings
whose field-coordinate value with flag bits stripped is `>= p` (noble's
permissive decoder silently mod-reduces, violating the BLS / Pairing-
Friendly Curves drafts); (b) points outside the prime-order subgroup,
via `isTorsionFree()`. The subgroup check closes the small-subgroup
mint-key-grinding class: attacker submits crafted `B_` with small-order
torsion component, mint returns `a * B_t` revealing `a mod t`; chain
across several t's via CRT to recover a.

NUT-00 batch verification. `deriveBatchWeights` switched from
`OS2IP(h) mod BLS_FR_ORDER` (biased ~7.5% TVD because BLS_FR_ORDER
~ 0.45 * 2^256) to true rejection sampling with u32_BE counter and
reject `x == 0 || x >= BLS_FR_ORDER`. Spec batch vector locked in
byte-for-byte.

NUT-13 v3 deterministic blinding factor. Same fix: rejection sampling
with u32_BE attempt counter appended after the 0x01 derivation-type
byte. V2 (secp256k1) unchanged. New spec vector exercises retry
(attempt=0 rejected, attempt=1 accepted).

Adds scripts/generate-nuts-vectors.ts as the durable source of truth
for cross-repo regeneration of all v3 test vectors. NUT02_V3 keyset
vectors and NUT-13 v3 derivation now anchored to the spec via test
lock-ins.
Every numeric value published in the NUT-00 v3 test vectors (round-trip
and batch verification) now has an explicit hex pin. Previously B_/C_/C
from the round-trip and the two weights from the batch vector were
pinned; Y, K, C_1, C_2, and the Fiat-Shamir challenge were exercised
transitively but not asserted as text.

The batch test now recomputes the transcript locally per NUT-00 (DST +
per-item C || K || u32_BE(|secret|) || secret) and pins the SHA-256
output independently of deriveBatchWeights. A regression in either the
transcript shape or the rejection-sampling derivation will now surface
distinctly.
NUT-02 requires that the V2 (01) and V3 (02) preimage use lowercase
pubkey hex and a lowercase UTF-8 unit string. The reference Python
implementation lowercases the unit explicitly; pubkey hex is implicitly
lowercase via bytes.hex(). cashu-ts embedded both verbatim, so any
uppercase letter reaching deriveKeysetId produced a hash that disagreed
with the spec and with the mint's wire id, causing verifyKeysetId to
report false on otherwise-valid keysets.

Normalize both. The change is a no-op for canonical lowercase input
(every NUT-02 test vector, every conformant mint), and brings the
verifier into agreement with the spec when input arrives uppercase.

Adds case-insensitivity regression tests against the V2 vector 1 and V3
vector 2 fixtures.
@robwoodgate robwoodgate marked this pull request as ready for review May 21, 2026 22:43
@robwoodgate robwoodgate merged commit a80dbf4 into main May 22, 2026
18 checks passed
@robwoodgate robwoodgate deleted the bls12-381 branch May 22, 2026 11:50
@github-project-automation github-project-automation Bot moved this from Backlog to Done in cashu-ts May 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant