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