NUT-00: add BLS12-381 (v3) protocol #371

Open
robwoodgate wants to merge 1 commit from robwoodgate/bls-protocol into main
robwoodgate commented 2026-05-20 19:53:54 +00:00 (Migrated from github.com)

Summary

Adds Pairing-based BDHKE (BLS12-381) as the v3 Cashu blind-signature protocol for keysets with version byte 02, alongside the legacy secp256k1 protocol for 00/01 keysets.

  • NUT-00 defines the BLS12-381 protocol: hash_to_curve_G1 with the Cashu RFC 9380 DST, multiplicative blinding B_ = r·Y, blind signing C_ = a·B_, unblinding C = r^-1·C_, and verification by e(C, G2) == e(Y, K).
  • NUT-00 also defines deterministic weighted batch verification using a Fiat-Shamir transcript, rejection-sampled weights in Fr*, and explicit point-validation requirements to reject non-canonical encodings, identity points, off-curve points, and non-prime-order subgroup points before signing or verification.
  • NUT-01 version-gates key serialization: 00/01 keysets use compressed secp256k1 keys, while 02 keysets use compressed BLS12-381 G2 keys.
  • NUT-02 adds V3 keyset ID derivation with version byte 02, G2 public keys in the preimage, lowercased units, and updated V3 test vectors.
  • NUT-12 scopes DLEQ to secp256k1 keysets only. V3 proofs and blind signatures do not carry dleq; offline verification uses the pairing equality from NUT-00.
  • NUT-13 extends deterministic recovery for V3 keysets: secrets remain HMAC-SHA256-derived as in V2, while blinding factors use rejection sampling against BLS_FR_ORDER instead of modular reduction.
  • tests/00, 02, 13 add vectors for BLS round-trip signing, weighted batch verification, V3 keyset IDs, and V3 deterministic blinding-factor rejection sampling.

The JSON/wire shape of BlindedMessage, BlindSignature, Proof, and TokenV4 remains unchanged; the keyset version selects the curve and byte widths for B_, C_, C, and mint public keys.

## Summary Adds **Pairing-based BDHKE (BLS12-381)** as the v3 Cashu blind-signature protocol for keysets with version byte `02`, alongside the legacy secp256k1 protocol for `00`/`01` keysets. - **NUT-00** defines the BLS12-381 protocol: `hash_to_curve_G1` with the Cashu RFC 9380 DST, multiplicative blinding `B_ = r·Y`, blind signing `C_ = a·B_`, unblinding `C = r^-1·C_`, and verification by `e(C, G2) == e(Y, K)`. - **NUT-00** also defines deterministic weighted batch verification using a Fiat-Shamir transcript, rejection-sampled weights in `Fr*`, and explicit point-validation requirements to reject non-canonical encodings, identity points, off-curve points, and non-prime-order subgroup points before signing or verification. - **NUT-01** version-gates key serialization: `00`/`01` keysets use compressed secp256k1 keys, while `02` keysets use compressed BLS12-381 G2 keys. - **NUT-02** adds V3 keyset ID derivation with version byte `02`, G2 public keys in the preimage, lowercased units, and updated V3 test vectors. - **NUT-12** scopes DLEQ to secp256k1 keysets only. V3 proofs and blind signatures do not carry `dleq`; offline verification uses the pairing equality from NUT-00. - **NUT-13** extends deterministic recovery for V3 keysets: secrets remain HMAC-SHA256-derived as in V2, while blinding factors use rejection sampling against `BLS_FR_ORDER` instead of modular reduction. - **tests/00, 02, 13** add vectors for BLS round-trip signing, weighted batch verification, V3 keyset IDs, and V3 deterministic blinding-factor rejection sampling. The JSON/wire shape of `BlindedMessage`, `BlindSignature`, `Proof`, and TokenV4 remains unchanged; the keyset version selects the curve and byte widths for `B_`, `C_`, `C`, and mint public keys.
Egge21M commented 2026-05-20 21:01:10 +00:00 (Migrated from github.com)

ChatGPT Pro Review

1. Blocking: externally supplied BLS points need explicit subgroup validation

The new BLS section says compressed encodings are fixed-width and that infinity is never valid, but it does not explicitly require subgroup checks for B_, C_, C_, and K. That is too loose for BLS12-381, because the serialized curve format validates curve points but does not, by itself, guarantee prime-order subgroup membership; the BLS draft defines subgroup_check(P) separately and requires public keys to be valid, non-identity points in the correct subgroup, and CoreVerify performs a signature subgroup check before pairing verification. ([IETF Datatracker][1])

I’d add normative text along these lines in NUT-00:

Implementations MUST deserialize all v3 B_, C_, C, and K values using the canonical compressed BLS12-381 encoding, reject non-canonical encodings, reject infinity, reject points not on the relevant curve, and reject points not in the prime-order subgroup. Mints MUST perform this validation on B_ before computing C_ = a·B_; wallets and receivers MUST perform it on mint public keys and signatures before pairing checks.

This matters especially for B_, because it is attacker-controlled input to the mint’s secret scalar multiplication.

2. Blocking/high: batch “rejection sampling” is actually modulo reduction

The batch section says weights are derived “by rejection sampling,” but the pseudocode does:

h   = SHA256(challenge || u32_BE(i) || u8(ctr))
r_i = OS2IP(h) mod BLS_FR_ORDER

and only rejects r_i == 0. That is not rejection sampling; it is modulo reduction. With BLS12-381’s 255-bit scalar order and a 256-bit SHA-256 output, the bias is not as negligible as with secp256k1: 2^256 = 2*q + rem, so some residues have three preimages and the rest have two. The total variation distance is about 7.5% by this derivation.

I’d change this to one of:

x = OS2IP(h)
if x == 0 or x >= BLS_FR_ORDER: continue
r_i = x

or use a wider expand, for example 64 bytes via XMD/HKDF/expand_message, then reduce modulo BLS_FR_ORDER and describe it as wide reduction, not rejection sampling. If you choose true 32-byte rejection sampling, u8(ctr) is also unnecessarily bounded; use u32_BE(ctr) or define behavior after 255 retries.

The same issue is worth revisiting in NUT-13: the v3 deterministic blinding factor is currently OS2IP(HMAC-SHA256(...)) mod BLS_FR_ORDER. That may be acceptable if the project explicitly accepts biased deterministic blinders, but it should be a deliberate choice, not an accidental carry-over from secp256k1 where the order is much closer to 2^256.

3. High: v3 keyset-ID test vectors reuse the same G2 key for multiple amounts

The new NUT-02 v3 test vectors use the exact same G2 public key for amount 1 and 2, and again for 1, 2, 4, and 8.

That is risky as a canonical example. In this protocol, verification of a proof checks e(C, G2) == e(Y, K) for the amount’s selected mint key K; the amount itself is not cryptographically inside the signature. If two denominations share the same private/public key, a proof signed for one amount can be relabeled as another amount using the same K.

I’d either update the vectors to use distinct valid G2 public keys per amount, or add a strong rule in NUT-01/NUT-02:

Within a keyset, public keys for distinct amounts MUST be distinct. Mints MUST NOT reuse the same signing scalar/public key for multiple amounts.

The safer option is to do both.

4. Medium: the new test vectors do not actually expose Y or K

The NUT-00 v3 vector says it covers hash_to_curve_G1, blinding, signing, and unblinding, but it only lists secret, r, a, B_, C_, and C; it does not list Y = hash_to_curve_G1(secret) or K = a·G2.

That makes it hard for independent implementations to isolate failures in hash-to-curve, serialization, signing, or unblinding. I decoded the provided G1 points and verified the scalar relations C_ = 2·B_ and C_ = 3·C; the implied Y from B_ = 3·Y is:

Y = 860d58e5aeda1376185436ed96412313424cc079e056d1dab595e6db4c2c9685fec7da052c8db68d88985b75a42388ad

That still should be independently confirmed as the RFC 9380 hash-to-curve output for test_message with the Cashu DST. RFC 9380 defines hash_to_curve as hashing to field, mapping, adding, and clearing the cofactor, and requires domain separation tags for these encodings. ([RFC Editor]2)

I’d add:

Y:      ...
K:      ...   # compressed G2 for a = 2

and at least one batch-vector containing the transcript, challenge, per-proof weights, and expected pass/fail result.

5. Medium: version gating is inconsistent and may accidentally bind future versions

NUT-00 says the BLS scheme applies to keysets with version byte 02 “onwards,” while NUT-01 says version byte 02 uses compressed BLS12-381 G2 keys.

I’d avoid “onwards” unless the intent is to permanently reserve all future keyset versions for this exact BLS12-381 protocol and encoding. Safer wording:

Applies to keysets with version byte 02. Future version bytes MUST specify their own cryptographic protocol or explicitly inherit this one.

6. Low: the serialization reference is pinned to an older draft

NUT-00 links to draft-irtf-cfrg-pairing-friendly-curves-11. The Datatracker currently shows draft-irtf-cfrg-pairing-friendly-curves-12 as the latest revision, with revision date 2025-11-02. ([IETF Datatracker][3])

Since the PR relies on the Zcash-style BLS12-381 compressed encoding, I’d either link the unversioned Datatracker document or explicitly cite the serialization format being used. The curves draft’s appendix describes compressed G1 as 48 bytes, compressed G2 as 96 bytes, and the three metadata bits, which matches the PR’s intended format. ([IETF Datatracker][4])

7. Low: the NUT-02 sample code is ambiguous around serialize()

The V3 derivation text says to concatenate lowercase compressed G2 public key hex strings, but the Python sample uses:

f"{k}:{v.serialize()}".encode("utf-8")

If serialize() returns bytes, Python will hash the b'...' representation rather than the lowercase hex string.

I’d make the sample explicit:

pubkey_hex = v.serialize().hex()  # or v.to_hex(), depending on the type

and also apply .lower() to the unit in the sample, since the prose says the unit is lowercased.

[1]: https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-bls-signature "

            draft-irtf-cfrg-bls-signature-06
        
    "

[3]: https://datatracker.ietf.org/doc/draft-irtf-cfrg-pairing-friendly-curves/ "

    draft-irtf-cfrg-pairing-friendly-curves-12 - Pairing-Friendly Curves


    "

[4]: https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-pairing-friendly-curves-11 "

            draft-irtf-cfrg-pairing-friendly-curves-11
        
    "
ChatGPT Pro Review ### 1. Blocking: externally supplied BLS points need explicit subgroup validation The new BLS section says compressed encodings are fixed-width and that infinity is never valid, but it does **not** explicitly require subgroup checks for `B_`, `C_`, `C_`, and `K`. That is too loose for BLS12-381, because the serialized curve format validates curve points but does not, by itself, guarantee prime-order subgroup membership; the BLS draft defines `subgroup_check(P)` separately and requires public keys to be valid, non-identity points in the correct subgroup, and `CoreVerify` performs a signature subgroup check before pairing verification. ([[IETF Datatracker](https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-bls-signature)][1]) I’d add normative text along these lines in NUT-00: > Implementations **MUST** deserialize all v3 `B_`, `C_`, `C`, and `K` values using the canonical compressed BLS12-381 encoding, reject non-canonical encodings, reject infinity, reject points not on the relevant curve, and reject points not in the prime-order subgroup. Mints **MUST** perform this validation on `B_` before computing `C_ = a·B_`; wallets and receivers **MUST** perform it on mint public keys and signatures before pairing checks. This matters especially for `B_`, because it is attacker-controlled input to the mint’s secret scalar multiplication. ### 2. Blocking/high: batch “rejection sampling” is actually modulo reduction The batch section says weights are derived “by rejection sampling,” but the pseudocode does: ```text h = SHA256(challenge || u32_BE(i) || u8(ctr)) r_i = OS2IP(h) mod BLS_FR_ORDER ``` and only rejects `r_i == 0`. That is not rejection sampling; it is modulo reduction. With BLS12-381’s 255-bit scalar order and a 256-bit SHA-256 output, the bias is not as negligible as with secp256k1: `2^256 = 2*q + rem`, so some residues have three preimages and the rest have two. The total variation distance is about 7.5% by this derivation. I’d change this to one of: ```text x = OS2IP(h) if x == 0 or x >= BLS_FR_ORDER: continue r_i = x ``` or use a wider expand, for example 64 bytes via XMD/HKDF/expand_message, then reduce modulo `BLS_FR_ORDER` and describe it as wide reduction, not rejection sampling. If you choose true 32-byte rejection sampling, `u8(ctr)` is also unnecessarily bounded; use `u32_BE(ctr)` or define behavior after 255 retries. The same issue is worth revisiting in NUT-13: the v3 deterministic blinding factor is currently `OS2IP(HMAC-SHA256(...)) mod BLS_FR_ORDER`. That may be acceptable if the project explicitly accepts biased deterministic blinders, but it should be a deliberate choice, not an accidental carry-over from secp256k1 where the order is much closer to `2^256`. ### 3. High: v3 keyset-ID test vectors reuse the same G2 key for multiple amounts The new NUT-02 v3 test vectors use the exact same G2 public key for amount `1` and `2`, and again for `1`, `2`, `4`, and `8`. That is risky as a canonical example. In this protocol, verification of a proof checks `e(C, G2) == e(Y, K)` for the amount’s selected mint key `K`; the amount itself is not cryptographically inside the signature. If two denominations share the same private/public key, a proof signed for one amount can be relabeled as another amount using the same `K`. I’d either update the vectors to use distinct valid G2 public keys per amount, or add a strong rule in NUT-01/NUT-02: > Within a keyset, public keys for distinct amounts **MUST** be distinct. Mints **MUST NOT** reuse the same signing scalar/public key for multiple amounts. The safer option is to do both. ### 4. Medium: the new test vectors do not actually expose `Y` or `K` The NUT-00 v3 vector says it covers `hash_to_curve_G1`, blinding, signing, and unblinding, but it only lists `secret`, `r`, `a`, `B_`, `C_`, and `C`; it does not list `Y = hash_to_curve_G1(secret)` or `K = a·G2`. That makes it hard for independent implementations to isolate failures in hash-to-curve, serialization, signing, or unblinding. I decoded the provided G1 points and verified the scalar relations `C_ = 2·B_` and `C_ = 3·C`; the implied `Y` from `B_ = 3·Y` is: ```text Y = 860d58e5aeda1376185436ed96412313424cc079e056d1dab595e6db4c2c9685fec7da052c8db68d88985b75a42388ad ``` That still should be independently confirmed as the RFC 9380 hash-to-curve output for `test_message` with the Cashu DST. RFC 9380 defines `hash_to_curve` as hashing to field, mapping, adding, and clearing the cofactor, and requires domain separation tags for these encodings. ([[RFC Editor](https://www.rfc-editor.org/rfc/rfc9380.html)][2]) I’d add: ```text Y: ... K: ... # compressed G2 for a = 2 ``` and at least one batch-vector containing the transcript, challenge, per-proof weights, and expected pass/fail result. ### 5. Medium: version gating is inconsistent and may accidentally bind future versions NUT-00 says the BLS scheme applies to keysets with version byte `02` “onwards,” while NUT-01 says version byte `02` uses compressed BLS12-381 G2 keys. I’d avoid “onwards” unless the intent is to permanently reserve all future keyset versions for this exact BLS12-381 protocol and encoding. Safer wording: > Applies to keysets with version byte `02`. Future version bytes MUST specify their own cryptographic protocol or explicitly inherit this one. ### 6. Low: the serialization reference is pinned to an older draft NUT-00 links to `draft-irtf-cfrg-pairing-friendly-curves-11`. The Datatracker currently shows `draft-irtf-cfrg-pairing-friendly-curves-12` as the latest revision, with revision date 2025-11-02. ([[IETF Datatracker](https://datatracker.ietf.org/doc/draft-irtf-cfrg-pairing-friendly-curves/)][3]) Since the PR relies on the Zcash-style BLS12-381 compressed encoding, I’d either link the unversioned Datatracker document or explicitly cite the serialization format being used. The curves draft’s appendix describes compressed G1 as 48 bytes, compressed G2 as 96 bytes, and the three metadata bits, which matches the PR’s intended format. ([[IETF Datatracker](https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-pairing-friendly-curves-11)][4]) ### 7. Low: the NUT-02 sample code is ambiguous around `serialize()` The V3 derivation text says to concatenate lowercase compressed G2 public key hex strings, but the Python sample uses: ```python f"{k}:{v.serialize()}".encode("utf-8") ``` If `serialize()` returns bytes, Python will hash the `b'...'` representation rather than the lowercase hex string. I’d make the sample explicit: ```python pubkey_hex = v.serialize().hex() # or v.to_hex(), depending on the type ``` and also apply `.lower()` to the unit in the sample, since the prose says the unit is lowercased. [1]: https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-bls-signature " draft-irtf-cfrg-bls-signature-06 " [2]: https://www.rfc-editor.org/rfc/rfc9380.html "RFC 9380: Hashing to Elliptic Curves" [3]: https://datatracker.ietf.org/doc/draft-irtf-cfrg-pairing-friendly-curves/ " draft-irtf-cfrg-pairing-friendly-curves-12 - Pairing-Friendly Curves " [4]: https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-pairing-friendly-curves-11 " draft-irtf-cfrg-pairing-friendly-curves-11 "
Egge21M commented 2026-05-21 10:49:18 +00:00 (Migrated from github.com)

I would still request changes due to the new Mint Scalar Construction section.

The formula a = (H_G1 * a_raw) mod BLS_FR_ORDER does not make the resulting integer representative generally divisible by H_G1; reducing modulo the prime subgroup order destroys the property needed to annihilate torsion on arbitrary full-curve G1 inputs. Since B_ is attacker-controlled input to the signing oracle, the spec should make mint-side subgroup validation of B_ a MUST before computing C_ = a·B_.

Suggested fix: remove the normative Mint Scalar Construction section and replace the critical note with an unambiguous requirement:

"Mints MUST deserialize and validate v3 B_ using canonical compressed BLS12-381 G1 encoding, and MUST reject infinity, non-curve points, non-canonical encodings, and points not in the prime-order G1 subgroup before signing."

The other updates look good: batch rejection sampling, NUT-13 v3 rejection sampling, distinct v3 amount keys, Y/K vectors, and batch challenge/weight vectors are all substantial improvements.

I would still request changes due to the new Mint Scalar Construction section. The formula `a = (H_G1 * a_raw) mod BLS_FR_ORDER` does not make the resulting integer representative generally divisible by `H_G1`; reducing modulo the prime subgroup order destroys the property needed to annihilate torsion on arbitrary full-curve G1 inputs. Since `B_` is attacker-controlled input to the signing oracle, the spec should make mint-side subgroup validation of `B_` a MUST before computing `C_ = a·B_`. Suggested fix: remove the normative Mint Scalar Construction section and replace the critical note with an unambiguous requirement: "Mints MUST deserialize and validate v3 `B_` using canonical compressed BLS12-381 G1 encoding, and MUST reject infinity, non-curve points, non-canonical encodings, and points not in the prime-order G1 subgroup before signing." The other updates look good: batch rejection sampling, NUT-13 v3 rejection sampling, distinct v3 amount keys, Y/K vectors, and batch challenge/weight vectors are all substantial improvements.
robwoodgate commented 2026-05-21 15:22:16 +00:00 (Migrated from github.com)

@Egge21M - Thank you for running the review bot. All issues raised have been addressed and this should be ready to review now.

cc: @callebtc @a1denvalu3 @thesimplekid

@Egge21M - Thank you for running the review bot. All issues raised have been addressed and this should be ready to review now. cc: @callebtc @a1denvalu3 @thesimplekid
a1denvalu3 (Migrated from github.com) reviewed 2026-05-25 08:25:09 +00:00
a1denvalu3 (Migrated from github.com) commented 2026-05-25 08:25:10 +00:00

is this a correction or are we changing how we derive keysets ID v2?

is this a correction or are we changing how we derive keysets ID v2?
a1denvalu3 (Migrated from github.com) reviewed 2026-05-25 08:25:33 +00:00
a1denvalu3 (Migrated from github.com) commented 2026-05-25 08:25:33 +00:00

same here

same here
robwoodgate (Migrated from github.com) reviewed 2026-05-25 09:03:24 +00:00
robwoodgate (Migrated from github.com) commented 2026-05-25 09:03:24 +00:00

Unless I've misunderstood the python, it is just a correction to the example code to bring it in line with spec. Clanker says:

v.serialize().hex() fixes the Python example to hash amount:publickey_hex;
plain v.serialize() inside an f-string would render Python bytes syntax like b'...', which is the wrong preimage
Unless I've misunderstood the python, it is just a correction to the example code to bring it in line with spec. Clanker says: ``` v.serialize().hex() fixes the Python example to hash amount:publickey_hex; plain v.serialize() inside an f-string would render Python bytes syntax like b'...', which is the wrong preimage ```
robwoodgate (Migrated from github.com) reviewed 2026-05-25 09:05:36 +00:00
robwoodgate (Migrated from github.com) commented 2026-05-25 09:05:36 +00:00

This is also intended to bring the python example into line with the existing spec. Although we assume unit is normalized lowercase elsewhere, I think the example should explicitly normalize.

This is also intended to bring the python example into line with the existing spec. Although we assume unit is normalized lowercase elsewhere, I think the example should explicitly normalize.
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin robwoodgate/bls-protocol:robwoodgate/bls-protocol
git switch robwoodgate/bls-protocol

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff robwoodgate/bls-protocol
git switch robwoodgate/bls-protocol
git rebase main
git switch main
git merge --ff-only robwoodgate/bls-protocol
git switch robwoodgate/bls-protocol
git rebase main
git switch main
git merge --no-ff robwoodgate/bls-protocol
git switch main
git merge --squash robwoodgate/bls-protocol
git switch main
git merge --ff-only robwoodgate/bls-protocol
git switch main
git merge robwoodgate/bls-protocol
git push origin main
Sign in to join this conversation.
No description provided.