Skip to content

fix(crypto): align BLS v3 with NUT-00/13 spec hardening - #1012

Closed
robwoodgate wants to merge 1 commit into
cashubtc:feature/bls12-381-v3-keysetfrom
robwoodgate:bls-v3-spec-hardening
Closed

fix(crypto): align BLS v3 with NUT-00/13 spec hardening#1012
robwoodgate wants to merge 1 commit into
cashubtc:feature/bls12-381-v3-keysetfrom
robwoodgate:bls-v3-spec-hardening

Conversation

@robwoodgate

Copy link
Copy Markdown
Contributor

Stacks on top of #999 to bring the BLS12-381 v3 implementation in line with the spec changes from the recent NUT-00 / NUT-01 / NUT-02 / NUT-13 review pass (cashubtc/nuts#bls-protocol).

Summary

Three spec-alignment fixes; none are exploitable in current Cashu flows but all violate strict spec MUSTs and weaken interoperability with stricter mints (e.g. blst-backed ones).

NUT-00 Point Validation (`cashu/core/crypto/bls.py`)

`PublicKey.init` now enforces, on parsed bytes:

  • Identity rejection. blst's `uncompress` accepts the canonical infinity encoding; the spec forbids it as a signature, mint key, or commitment.
  • Prime-order subgroup membership (G1 and G2). pyblst does not expose blst's fast endomorphism-based `in_g1` / `in_g2` / `KeyValidate` predicates, so this falls back to the textbook `P · q == identity` test via `scalar_mul` (one full subgroup-order scalar multiplication per parse). When pyblst grows a predicate, swap for the fast check.

This closes the small-subgroup mint-key-grinding class: attacker submits crafted `B_` with a small-order torsion component, mint returns `a · B_t` revealing `a mod t`, chain across several t's via CRT to recover `a`.

Canonicality (`x < p`, `c0 < p`, `c1 < p`, valid flag combinations) is already enforced by blst's `uncompress` (`BLST_BAD_ENCODING` for non-canonical inputs), so no extra check is needed.

NUT-00 Batch Verification (`cashu/core/crypto/bls_dhke.py`)

`batch_pairing_verification` previously used `os.urandom(32)` for the per-proof weights. The spec requires a deterministic Fiat-Shamir transcript:

  1. Length-prefix `(C_i, K_i, secret_i)` into a transcript prefixed with `b"Cashu_BLS_Batch_v1"`.
  2. Collapse to a 32-byte SHA-256 `challenge`.
  3. Derive each `r_i` by rejection sampling: `OS2IP(SHA256(challenge || u32_BE(i) || u32_BE(ctr)))` with `0 < x < BLS_FR_ORDER`.

Deterministic weights are required for the security argument: with random weights an attacker holding one aggregated signature `C' = a · (Y_1 + Y_2)` can pick any `C_1` and present `(C_1, C' − C_1)` as two proofs that pass a sum check. The transcript binds each `r_i` to the inputs before the attacker sees the weights.

Rejection sampling (not mod reduction) because `BLS_FR_ORDER ≈ 0.45 · 2^256` would bias mod reduction by ~7.5%.

NUT-13 V3 Deterministic Blinding Factor (`cashu/wallet/secrets.py`)

Split `_derive_secret_hmac_sha256` into V2 and V3 paths:

  • V2 (secp256k1) unchanged: single HMAC, returns raw 32-byte digest. `SECP256K1_N ≈ 2^256` so mod-reduction bias is `~2^-128`, negligible.
  • V3 (BLS12-381) uses rejection sampling with a `u32_BE(attempt)` counter appended to the HMAC input after the `0x01` derivation-type byte:

```
base = b"Cashu_KDF_HMAC_SHA256" || keyset_id_bytes || counter_bytes
secret = HMAC_SHA256(seed, base || 0x00)
for attempt = 0, 1, ...:
digest = HMAC_SHA256(seed, base || 0x01 || u32_BE(attempt))
x = OS2IP(digest)
if x == 0 or x >= BLS_FR_ORDER: continue
r = digest; break
```

Expected ~2.2 iterations per derivation.

Test vectors updated

  • `tests/wallet/test_wallet_secrets.py` regenerated to exercise retry: `(seed="nut13 v3 test seed", keyset_id=02abd02e…, counter=3)` produces `attempt=0` rejected, `attempt=1` accepted. Implementations that skip the rejection loop fail this vector.
  • `tests/mint/test_mint_keysets.py` V3 keyset vectors regenerated with distinct G2 keys per amount (per the new "distinct keys MUST" in NUT-01). Old placeholder shared-key vectors removed.
  • `tests/wallet/test_wallet_keysets_v2.py` call site updated for the V2 helper rename.

Test plan

  • `pytest tests/wallet/test_wallet_secrets.py tests/wallet/test_wallet_keysets_v2.py tests/mint/test_mint_keysets.py` — 32/32 pass.
  • BLS-tagged tests across the suite (`-k "bls or BLS or v3 or v2_keyset"`) — 13 pass, 1 skipped, 0 fail.
  • `test_keyset_rotation` passes in isolation (the earlier failure when run with other suites is unrelated test-order side-effect, not from these changes).

Notes

  • pyblst's permissive minimal API means the subgroup check is a full scalar multiplication. Acceptable per parse but worth replacing with the Bowe-2019 endomorphism check when (if) pyblst exposes the helpers.
  • Cross-implementation: matching changes have landed on the cashu-ts-bls side (`feat(crypto): align BLS v3 with NUT-00/13 spec hardening`) so the two implementations will be interoperable on batch verification, deterministic recovery, and point validation.

Three spec-alignment fixes informed by a recent NUTs review pass:

NUT-00 Point Validation. `PublicKey.__init__` now enforces (a) explicit
identity rejection on parsed bytes (blst's `uncompress` accepts the
infinity encoding); (b) prime-order subgroup membership for both G1 and
G2 inputs. pyblst does not expose blst's fast endomorphism-based
`in_g1`/`in_g2`/`KeyValidate` predicates, so the check falls back to
`P * q == identity` via `scalar_mul` (one full subgroup-order scalar
multiplication per parse). This closes the small-subgroup mint-key-
grinding class: attacker submits crafted `B_` with a small-order
torsion component, mint returns `a * B_t` revealing `a mod t`; chain
across several t's via CRT to recover a. Canonicality (x < p, c0 < p,
c1 < p, valid flag combinations) is already enforced by blst's
`uncompress` (BLST_BAD_ENCODING for non-canonical inputs).

NUT-00 batch verification. `batch_pairing_verification` switched from
non-deterministic `os.urandom(32)` weights to the spec's
Fiat-Shamir transcript with rejection sampling: build a length-prefixed
transcript binding (C_i, K_i, secret_i), collapse to a 32-byte
SHA-256 challenge, derive per-proof weights via
`OS2IP(SHA256(challenge || u32_BE(i) || u32_BE(ctr)))` with
0 < x < BLS_FR_ORDER. Deterministic weights are required for the
security argument: with random weights an attacker holding one
aggregated signature `C' = a * (Y_1 + Y_2)` can fix C_1 arbitrarily
and let C_2 = C' - C_1 to forge two proofs that pass a sum check.
Modular reduction would bias ~7.5% because BLS_FR_ORDER ~ 0.45 * 2^256.

NUT-13 V3 deterministic blinding factor. Split the HMAC-SHA256
derivation into V2 and V3 paths. V2 (secp256k1) keeps the existing
single-HMAC path (bias ~2^-128, negligible). V3 (BLS12-381) uses
rejection sampling with a u32_BE attempt counter appended to the
HMAC input after the 0x01 derivation-type byte:

    base = b"Cashu_KDF_HMAC_SHA256" || keyset_id_bytes || counter_bytes
    secret = HMAC_SHA256(seed, base || 0x00)
    for attempt = 0, 1, ...:
        digest = HMAC_SHA256(seed, base || 0x01 || u32_BE(attempt))
        x = OS2IP(digest)
        if x == 0 or x >= BLS_FR_ORDER: continue
        r = digest; break

Test vectors updated to the new spec values
(nuts/tests/{02,13}-tests.md):
- NUT-02 v3 keyset vectors regenerated with distinct G2 keys per
  amount (per the new "distinct keys MUST"); placeholder shared-key
  vectors removed.
- NUT-13 v3 vector exercises retry: (seed, keyset_id, counter) chosen
  so attempt=0 is rejected and attempt=1 is accepted.

V2 wallet keysets test updated to call the renamed
`_derive_secret_hmac_sha256_v2` helper.
@github-project-automation github-project-automation Bot moved this to Backlog in nutshell May 21, 2026
@github-project-automation github-project-automation Bot moved this from Backlog to Done in nutshell May 21, 2026
@robwoodgate
robwoodgate deleted the bls-v3-spec-hardening branch August 6, 2026 11:47
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