feat(crypto)!: BLS12-381 v3 keysets#661
Merged
Merged
Conversation
Codecov Report❌ Patch coverage is 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
B_ = Y · r,C_ = B_ · a,C = C_ · r⁻¹).e(C, G2_gen) == e(Y, K2)replaces it (K2is the mint pubkey in G2). One pairing call instead of ~4 scalar mults + hash + wire bytes per proof.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.checkProofsStatesnow requires bothidandsecreton every input (previously onlysecret). Theidselects the hash-to-curve variant for the NUT-07 lookup pointY— secp256k1 for v0/v1/v2, BLS12-381 G1 for v3. Without it, v3 proofs silently miscomputeYand the state check returns nothing. Callers passing fullProofobjects are unaffected. See migration-5.0.0.md.Highlights
src/crypto/bls.ts— BLS12-381 primitives (blind, unblind, sign, single + batch verify, hash-to-curve, deterministic batch weights via Fiat-Shamir).CurvePointtagged union — point types widen acrossOutputData,BlindedMessage,BlindedSignature, mint-pubkey parsing to cover both curves cleanly.derive_keys_v3(hardened path + sha256-of-bip32 before Fr reduction).examples/auth_mint/docker-compose-nutshell.yml.Test plan
secret="test_message", r=3, a=2) reproducesB_,C_,Cbyte-for-byte vs Nutshell(C₁, secret₁), (C − C₁, secret₂)aggregation attack rejected_derive_secret_hmac_sha256at counter where the raw HMAC exceedsBLS_FR_ORDER(so the mod-Fr branch is exercised)getEncodedToken→getDecodedTokenpreserves the02…idcheckProofsStatesmigration: v3 proofs now match against the mint's G1Y(previously silently empty)make integration-nutshell-bls) — requires running mint, smoke-tested locallyderive_keys_v3: byte-for-byte locked vector intest/crypto/NUT01.test.ts(mnemonic='TEST_PRIVATE_KEY',path m/0'/0'/0',amounts [1,2,4,8],unit 'sat')References