Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
.pre-commit-config.yaml
.claude
.codex
.DS_Store
145 changes: 144 additions & 1 deletion 00.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,149 @@ This document details the notation and models used throughout the specification
- Receiving user: `Carol`
- Mint: `Bob`

# Blind Diffie-Hellmann key exchange (BDHKE)
Cashu uses a blind BLS signatures scheme on BLS12-381. The deprecated Blind Diffie-Hellmann key exchange (BDHKE) scheme was based on secp256k1 (legacy).

# Pairing-based BLS blind signatures

> _Applies to keysets with version byte `02` (see [NUT-02][02])._

## Groups and Scalars

- `G1` generator point of BLS12-381 G1 curve group of prime order `BLS_FR_ORDER` (where `Y`, `B_`, `C_`, `C` live); points come from `hash_to_curve_G1`.
- `G2` generator point of BLS12-381 G2 of the same order
Comment thread
robwoodgate marked this conversation as resolved.
Outdated
- `Fr` finite field of integers modulo `BLS_FR_ORDER`; scalars live here.

`BLS_FR_ORDER` is the order of the BLS12-381 prime-order subgroup (the order of `Fr`):
Comment thread
robwoodgate marked this conversation as resolved.
Outdated

```
BLS_FR_ORDER = 52435875175126190479447740508185965837690552500527637822603658699938581184513
```

## Notation And Conventions

These specifications use [BLS12-381](https://datatracker.ietf.org/doc/draft-irtf-cfrg-pairing-friendly-curves/), a pairing-friendly curve, with a Type-3 pairing `e: G1 × G2 → G_T`.

### Types

Values are one of:

- Scalars: integers in `Fr` (the prime field of order `BLS_FR_ORDER`)
- G1 points: compressed 48 bytes / 96 hex characters
- G2 points: compressed 96 bytes / 192 hex characters
- Byte sequences

Compressed point encoding follows the standard BLS12-381 serialisation. Hex encodings in JSON fields are lowercase, with no `0x` prefix and the fixed widths above. The identity point (point at infinity) is never a valid signature, mint key, or commitment.
Comment thread
robwoodgate marked this conversation as resolved.
Outdated

### Bob (mint)

- `a` private key of mint, scalar in `Fr` (one for each amount)
- `K = a·G2` public key in `G2` corresponding to `a` (compressed 96 bytes)
- `C_` blind signature in `G1` on `B_`

### Alice (user)

- `x` UTF-8-encoded random string (secret message), corresponds to point `Y = hash_to_curve_G1(x)` in `G1`
- `r` blinding factor, scalar in `Fr`
- `B_ = r·Y` blinded message in `G1`
- `C` unblinded signature in `G1`

## Protocol

- Mint `Bob` publishes per-amount public key `K = a·G2`
- `Alice` picks secret `x` and computes `Y = hash_to_curve_G1(x)`
- `Alice` sends to `Bob`: `B_ = r·Y` with `r` a random non-zero scalar in `Fr` (**blinding**)
- `Bob` sends back to `Alice`: `C_ = a·B_` (**signing**)
- `Alice` unblinds: `C = r⁻¹·C_ = a·Y` (**unblinding**)
- `Alice` can take the pair `(x, C)` as a token and send it to `Carol`.
- Anyone holding `K` can verify a proof `(x, C)` (**verification**) by computing `Y = hash_to_curve_G1(x)` and accepting only if `e(C, G2) == e(Y, K)`.
- `Carol` can send `(x, C)` to `Bob`, who treats it as a valid spend of a token, adding `x` to the list of spent secrets, if the token verifies and has not been seen before.

### `hash_to_curve_G1(x: bytes) -> G1 point Y`

Maps `x` deterministically to a point in `G1` using the BLS12-381 G1 random-oracle suite defined in [RFC 9380](https://www.rfc-editor.org/rfc/rfc9380) §8.8.1 (`BLS12381G1_XMD:SHA-256_SSWU_RO_`), with Cashu domain separation tag (DST):

```
DST = b"CASHU_BLS12_381_G1_XMD:SHA-256_SSWU_RO_"
```

> [!NOTE]
> Under the discrete-log hardness assumption, `hash_to_curve_G1(x)` is one-way and yields a NUMS point in `G1`, meaning no one is assumed to know a scalar `y` such that `Y = y·G1`.
Comment thread
robwoodgate marked this conversation as resolved.
Outdated

### Optional: Batch Verification

`Bob` (and any other verifier) can verify many proofs in a single multi-pairing.

Constants:

```
BLS_BATCH_DST = b"Cashu_BLS_Batch_v1"
```

For a batch of `n` proofs, each with mint key `K_i`, unblinded signature `C_i`, and secret bytes `secret_i` (the UTF-8 bytes of the proof's `secret` field, the same bytes fed to `hash_to_curve_G1`):

1. Build a length-prefixed transcript:

```
transcript = BLS_BATCH_DST
for i in 0..n-1:
transcript ||= C_i.compressed # 48 bytes
transcript ||= K_i.compressed # 96 bytes
transcript ||= u32_BE(len(secret_i))
transcript ||= secret_i
```

2. Collapse to a single 32-byte challenge:

```
challenge = SHA256(transcript)
```

3. Derive each per-proof weight `w_i ∈ Fr*` by **rejection sampling**. For `i = 0..n-1`, iterate `ctr = 0, 1, …` until a valid scalar is produced:

```
h = SHA256(challenge || u32_BE(i) || u32_BE(ctr))
x = OS2IP(h)
if x == 0 or x >= BLS_FR_ORDER:
continue # try next ctr
w_i = x
```

> [!IMPORTANT]
> Rejection sampling **MUST** be used to avoid bias. `BLS_FR_ORDER ≈ 0.45 · 2^256`, so each iteration succeeds with probability ≈ 0.45 (about 2.2 expected iterations per proof). This yields `w_i` uniformly distributed over `Fr*`. Plain `mod BLS_FR_ORDER` would over-represent some values by ~36%.
Comment thread
robwoodgate marked this conversation as resolved.
Outdated

4. Group items by distinct mint key `K_k` and check:

```
e( Σ_i w_i·C_i , G2 ) == Π_k e( Σ_{i:K_i=K_k} w_i·Y_i , K_k )
```

Implementations **SHOULD** evaluate the equation with a single call to a multi-pairing primitive; this performs one final exponentiation for the whole equation instead of one per pairing.
Comment thread
robwoodgate marked this conversation as resolved.
Outdated

Typical batch scopes:

- A wallet receiving a token batches all v3 proofs across that token.
- A mint batches all v3 input proofs of a single `/v1/swap` or `/v1/melt` request.

A batch of one proof reduces to the single-proof pairing check above.

The weights are public and deterministic; security does not rely on their secrecy. Per-proof weights are required because, without them, an attacker holding one aggregated signature `C' = a·(Y_1+Y_2)` could split it into two forgeries that both verify under a sum check.
Comment thread
robwoodgate marked this conversation as resolved.
Outdated

### Point Validation

Implementations **MUST** deserialise all v3 `B_`, `C_`, `C`, and `K` values using the canonical compressed BLS12-381 encoding, and **MUST** reject:

- non-canonical encodings,
- the identity (point at infinity),
- points not on the curve,
- points not in the prime-order subgroup of `G1` (for `B_`, `C_`, `C`) or `G2` (for `K`).
Comment thread
robwoodgate marked this conversation as resolved.

> [!IMPORTANT]
> To defend against the [small-subgroup attack][lim-lee], mints **MUST** perform full validation on `B_` before computing `C_ = a·B_`; wallets and receivers **MUST** perform it on mint public keys `K`, on `C_` before unblinding, and on signatures `C` before any pairing check.
> Use the library's prime-order subgroup predicate, such as `KeyValidate` / `in_g1` / `in_g2` (blst) or `isTorsionFree()` (`@noble/curves`).
Comment thread
robwoodgate marked this conversation as resolved.
Outdated

# Legacy secp256k1 based BDHKE

> _This protocol is deprecated, and only applies to keysets with version byte `00` or `01`._

## Variables

Expand Down Expand Up @@ -371,3 +513,4 @@ utf8("craw") || utf8(<token_version>) || <serialised_token>
[10]: 10.md
[11]: 11.md
[12]: 12.md
[lim-lee]: https://link.springer.com/chapter/10.1007/BFb0052240
7 changes: 6 additions & 1 deletion 01.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ A mint may support any currency unit(s) they can mint ([NUT-04][04]) and melt([N

Keysets are generated by the mint. The mint is free to use any key generation method they like. Each keyset is identified by its keyset `id` which can be computed by anyone from its public keys (see [NUT-02][02]).

Keys in Keysets are maps of the form `{<amount_1> : <mint_pubkey_1>, <amount_2> : <mint_pubkey_2>, ...}` for each `<amount_i>` of the amounts the mint `Bob` supports and the corresponding public key `<mint_pubkey_1>`, that is `K_i` (see [NUT-00][00]). The mint **MUST** use the [compressed Secp256k1 public key format](https://learnmeabitcoin.com/technical/public-key#public-key-format) to represent its public keys.
Keys in Keysets are maps of the form `{<amount_1> : <mint_pubkey_1>, <amount_2> : <mint_pubkey_2>, ...}` for each `<amount_i>` of the amounts the mint `Bob` supports and the corresponding public key `<mint_pubkey_1>`, that is `K_i` (see [NUT-00][00]). The public key serialization is determined by the keyset's version byte (see [NUT-02][02]):

- For keysets with version byte `00` or `01`, the mint **MUST** use the [compressed Secp256k1 public key format](https://learnmeabitcoin.com/technical/public-key#public-key-format) (33 bytes / 66 hex characters).
- For keysets with version byte `02`, the mint **MUST** use the compressed BLS12-381 G2 public key format (96 bytes / 192 hex characters); see [Pairing-based BDHKE (BLS12-381)](00.md#pairing-based-bdhke-bls12-381).

Mint keys **MUST** be unique and never reused.

## Example

Expand Down
44 changes: 40 additions & 4 deletions 02.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,50 @@ Notice that since transactions can spend inputs from different keysets, the sum

### Deriving the keyset ID

Three keyset ID versions are defined: V3 (version byte `02`, current), V2 (`01`, deprecated), and V1 (`00`, deprecated).

#### Keyset ID V3

V3 keyset IDs are 33 byte hex strings with version byte `02`. V3 identifies keysets that use the BLS12-381 blinding protocol defined in [Pairing-based BDHKE (BLS12-381)](00.md#pairing-based-bdhke-bls12-381).

Keyset IDs are derived from public data. To derive the keyset ID of a keyset, execute the following steps:

```
1 - sort public keys by their amount in ascending numerical order
2 - concatenate each amount and its corresponding lowercase compressed BLS12-381 G2 public key hex string (as "amount:publickey_hex") to a single byte array, separating each pair with a comma (",")
3 - add the lowercase UTF8-encoded unit string prefixed with "|unit:" to the byte array (e.g. "|unit:sat")
4 - If input_fee_ppk is specified and non-zero, add the UTF8-encoded string prefixed with "|input_fee_ppk:" (e.g. "|input_fee_ppk:100"). If input_fee_ppk is omitted, null, or 0, it MUST be omitted from the preimage.
5 - If a final expiration is specified, add the UTF8-encoded string prefixed with "|final_expiry:" (e.g. "|final_expiry:1896187313")
Comment thread
robwoodgate marked this conversation as resolved.
Outdated
6 - HASH_SHA256 the concatenated byte array
7 - prefix it with a keyset ID version byte "02"
```

An example implementation in Python:

```python
def derive_keyset_id_v3(keys: Dict[int, G2PublicKey], unit: str, input_fee_ppk: Optional[int], final_expiry: Optional[int]) -> str:
sorted_keys = dict(sorted(keys.items()))
# v.serialize() returns compressed G2 bytes; .hex() yields the lowercase 192-char hex.
keyset_id_bytes = b",".join(
[f"{k}:{v.serialize().hex()}".encode("utf-8") for k, v in sorted_keys.items()]
)
keyset_id_bytes += f"|unit:{unit.lower()}".encode("utf-8")
if input_fee_ppk is not None and input_fee_ppk != 0:
keyset_id_bytes += f"|input_fee_ppk:{input_fee_ppk}".encode("utf-8")
if final_expiry is not None and final_expiry != 0:
keyset_id_bytes += f"|final_expiry:{final_expiry}".encode("utf-8")
return "02" + hashlib.sha256(keyset_id_bytes).hexdigest()
```

#### Keyset ID V2

Keyset IDs are 33 byte hex strings with a version byte (two hexadecimal characters). The currently used version byte is `01`.
V2 keyset IDs are 33 byte hex strings with version byte `01`. They identify legacy keysets that use the secp256k1 BDHKE protocol defined in [Legacy secp256k1 based BDHKE](00.md#legacy-secp256k1-based-bdhke).

Keyset IDs are derived from public data. To derive the keyset ID of a keyset, execute the following steps:

```
1 - sort public keys by their amount in ascending numerical order
2 - concatenate each amount and its corresponding lowercase public key hex string (as "amount:publickey_hex") to a single byte array, separating each pair with a comma (",")
2 - concatenate each amount and its corresponding lowercase compressed secp256k1 public key hex string (as "amount:publickey_hex") to a single byte array, separating each pair with a comma (",")
3 - add the lowercase UTF8-encoded unit string prefixed with "|unit:" to the byte array (e.g. "|unit:sat")
4 - If input_fee_ppk is specified and non-zero, add the UTF8-encoded string prefixed with "|input_fee_ppk:" (e.g. "|input_fee_ppk:100"). If input_fee_ppk is omitted, null, or 0, it MUST be omitted from the preimage.
5 - If a final expiration is specified, add the UTF8-encoded string prefixed with "|final_expiry:" (e.g. "|final_expiry:1896187313")
Expand All @@ -77,10 +112,11 @@ An example implementation in Python:
```python
def derive_keyset_id_v2(keys: Dict[int, PublicKey], unit: str, input_fee_ppk: Optional[int], final_expiry: Optional[int]) -> str:
sorted_keys = dict(sorted(keys.items()))
# v.serialize() returns the compressed secp256k1 bytes; .hex() yields the lowercase 66-char hex.
keyset_id_bytes = b",".join(
[f"{k}:{v.serialize()}".encode("utf-8") for k, v in sorted_keys.items()]
[f"{k}:{v.serialize().hex()}".encode("utf-8") for k, v in sorted_keys.items()]
Comment thread
robwoodgate marked this conversation as resolved.
)
keyset_id_bytes += f"|unit:{unit}".encode("utf-8")
keyset_id_bytes += f"|unit:{unit.lower()}".encode("utf-8")
Comment thread
robwoodgate marked this conversation as resolved.
if input_fee_ppk is not None and input_fee_ppk != 0:
keyset_id_bytes += f"|input_fee_ppk:{input_fee_ppk}".encode("utf-8")
if final_expiry is not None and final_expiry != 0:
Expand Down
5 changes: 5 additions & 0 deletions 12.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

In this document, we present an extension of Cashu's crypto system to allow a user `Alice` to verify the mint `Bob`'s signature using only `Bob`'s public keys. We explain how another user `Carol` who receives ecash from `Alice` can execute the DLEQ proof as well. This is achieved using a Discrete Log Equality (DLEQ) proof. Previously, `Bob`'s signature could only be checked by himself using his own private keys ([NUT-00][00]).

> [!IMPORTANT]
> This NUT applies only to keysets with version byte `00` or `01` (secp256k1).
> For keysets with version byte `02` (BLS12-381), mints **MUST NOT** include a `dleq` field in `BlindSignature` responses, and wallets **MUST NOT** include a `dleq` field in `Proof` objects. Verification for those keysets uses the pairing equality defined in [Pairing-based BDHKE (BLS12-381)](00.md#pairing-based-bdhke-bls12-381).
> Receivers **SHOULD** treat a v3 `BlindSignature` or `Proof` that carries a `dleq` field as malformed.

# The DLEQ proof

The purpose of this DLEQ is to prove that the mint has used the same private key `a` for creating its public key `A` ([NUT-01][01]) and for signing the BlindedMessage `B'`. `Bob` returns the DLEQ proof additional to the blind signature `C'` for a mint or swap operation.
Expand Down
Loading
Loading