Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
11 changes: 11 additions & 0 deletions 00.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ A `Proof` is also called an _input_ and is generated by `Alice` from a `BlindSig

`amount` is the amount of the `Proof`, `secret` is the secret message and is a utf-8 encoded string (the use of a 64 character hex string generated from 32 random bytes is recommended to prevent fingerprinting), `C` is the unblinded signature on `secret` (hex string), `id` is the [keyset id][02] of the mint public keys that signed the token (hex string).

> [!NOTE]
> A proof may optionally be extended by other NUTs. These include:
>
> - [NUT-28][28]: Pay-to-Blinded-Key - adds `"p2pk_e": hex_str` to store an ephemeral pubkey

## 0.2 - Protocol

### Errors
Expand Down Expand Up @@ -277,6 +282,11 @@ If a short keyset ID resolves to more than one known full keyset ID, the identif

The mint is unaware of the `s_id`. All API endpoints exposed by the mint use the full keyset ID.

> [!NOTE]
> The token format may optionally be extended by other NUTs. These include:
>
> - [NUT-28][28]: Pay-to-Blinded-Key - adds `"pe": bytes` to individual proofs

##### Example

Below is a TokenV4 JSON before CBOR and `base64_urlsafe` serialization.
Expand Down Expand Up @@ -347,3 +357,4 @@ utf8("craw") || utf8(<token_version>) || <serialised_token>
[10]: 10.md
[11]: 11.md
[12]: 12.md
[28]: 28.md
2 changes: 2 additions & 0 deletions 11.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

`depends on: NUT-10, NUT-08`

`extended by: NUT-28`

---

This NUT describes Pay-to-Public-Key (P2PK) which is one kind of spending condition based on [NUT-10][10]'s well-known `Secret`. Using P2PK, we can lock ecash Proofs (see [NUT-00][00]) to a receiver's ECC public key and require a Schnorr signature with the corresponding private key to unlock the ecash. The spending condition is enforced by the mint.
Expand Down
4 changes: 3 additions & 1 deletion 14.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

`optional`

`depends on: NUT-10`
`depends on: NUT-10, NUT-11`

`extended by: NUT-28`

---

Expand Down
178 changes: 178 additions & 0 deletions 28.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# NUT-28: Pay-to-Blinded-Key (P2BK)

`optional`

`depends on: NUT-11`

---

## Summary

This NUT describes Pay-to-Blinded-Key (P2BK), which extends the [NUT-11][11] (P2PK) spending conditions. By implication, it also extends [NUT-14][14] (HTLC).

P2BK preserves privacy by blinding each NUT-11 receiver pubkey `P` with an ECDH-derived scalar `rᵢ`. Both sides can deterministically derive the same `rᵢ` from their own keys, but a third party cannot.

This brings _"silent payments"_ to Cashu: Proofs can be locked to a well known public key, posted in public without compromising privacy, and spent by the recipient without needing any side-channel communication.

## ECDH Shared Secret (Zx)

ECDH allows two parties to create an x-coordinate shared secret (`Zx`) by combining their private key with the public key of the other party: `Zx = x(epG) = x(eP) = x(pE)`.

For P2BK, the sender creates an ephemeral keypair (private key: `e`, public key: `E`). This protects the privacy of their own long-lived public key. They then calculate the shared secret by combining the ephemeral private key (`e`) and the receiver's long-lived public key (`P`).

The receiver calculates the same shared secret using their private key (`p`) and the ephemeral public key (`E`), which is supplied by the sender in the [proof metadata](#proof-object-extension).

This shared secret is then used to derive the blinded public keys.

## Deriving Blinded Public Keys

Per NUT-11, there are up to 11 locking 'slots' in the order: `[data, ...pubkeys, ...refund]`.

Slot 0 is the `data` tag. Slots 1-10 can be any combination of `pubkeys` and `refund` keys.

Each public key in the NUT-11 proof is permanently blinded using a deterministic blinding scalar (`rᵢ`), where `i` is the _slot index_.

The blinding scalar for each slot is calculated as:

```
rᵢ = SHA-256( DOMAIN_SEPARATOR || Zx || i_byte)
```

Where:

- `DOMAIN_SEPARATOR` constant byte string `b"Cashu_P2BK_v1"`
- `Zx` is the ECDH shared secret (`eP` for sender, `pE` for receiver).
- `i_byte` is the single unsigned byte representation of `i`: (`0x00` to `0x0A`)
- `||` denotes concatenation

For broader compatibility, `rᵢ` **MUST NOT** be normalised modulo `n`

If `rᵢ` is not in the range `1 ≤ rᵢ ≤ n−1`, retry once with an extra `0xff` byte appended to the hash input as follows:

```
rᵢ = SHA-256( b"Cashu_P2BK_v1" || Zx || i_byte || 0xff )
```

If `rᵢ` is still not in the range `1 ≤ rᵢ ≤ n−1`, abort and discard the ephemeral keypair.

Finally, the public key (`P`) for slot `i` is blinded (`P'`) as follows:

```
P' = P + rᵢG
```

Here is a code example in TypeScript:

```ts
function deriveP2BKBlindingTweakFromECDH(
point: WeierstrassPoint<bigint>, // E or P
scalar: bigint, // p or e
slotIndex: number, // i
): bigint {
// Calculate x-only ECDH shared point (Zx)
const Zx = point.multiply(scalar).toBytes(true).slice(1);
const iByte = new Uint8Array([slotIndex & 0xff]);
// Derive deterministic blinding factor (r):
// Note: bytesToNumber does NOT reduce modulo n
let r: bigint = bytesToNumber(sha256(Bytes.concat(P2BK_DST, Zx, iByte)));
if (r === 0n || r >= secp256k1.Point.CURVE().n) {
// Very unlikely to get here!
r = bytesToNumber(
sha256(Bytes.concat(P2BK_DST, Zx, iByte, new Uint8Array([0xff]))),
);
if (r === 0n || r >= secp256k1.Point.CURVE().n) {
// Astronomically unlikely to get here!
throw new Error("P2BK: tweak derivation failed");
}
}
return r;
}
```

For detailed examples of slot blinding, see the [test vectors][tests].

> [!IMPORTANT]
> All receiver keys **MUST** be in compressed SEC1 format (33 bytes) before ECDH and blinding. \
> The sender **MUST add an '02' prefix** to BIP-340 x-only pubkeys (eg Nostr).

## Proof Object Extension

Each proof adds a single new metadata field:

```jsonc
{
"amount": int,
"id": hex_str,
"secret": str, // still ["P2PK", {...}]
"C": hex_str,
"p2pk_e": hex_str // 33-byte SEC1 compressed ephemeral public key E
}
```

- `p2pk_e` contains the sender's ephemeral pubkey (`E`) used for blinding
- All pubkeys inside the `"P2PK"` secret are the blinded forms `P'`
- The mint sees standard P2PK data and remains unaware of the blinding
- For Token V4 encoding, the `p2pk_e` field is named `pe`, and `E` is encoded as a 33 byte CBOR bstr

## Deriving Private Keys

With P2BK, the NUT-11 public locking keys are permanently blinded. The mint sees only the blinded public keys, and expects signatures from the corresponding private key.

The receiver must therefore derive the correct blinded private key. Because BIP-340 lifts public keys to even-Y parity, there are two possible derivation paths:

- Standard derivation: `k = (p + rᵢ) mod n`
- Negated derivation: `k = (-p + rᵢ) mod n`

Where `p` is the receiver's long lived private key.

To decide which derivation to use, the receiver calculates their natural pubkey (`pG`) and compares the parity to their actual pubkey (`P`).

If the parity matches, use standard derivation, otherwise use negated derivation.

The fastest way to do this in a wallet is to unblind, verify the key is a match, then select derivation by parity:

a. compute `Rᵢ = rᵢG` \
b. unblind `P = P' − Rᵢ` \
c. verify `x(P) == x(pG)` \
d. use standard derivation if `parity(P) == parity(pG)`, otherwise use negated derivation

## Sender Workflow

1. Generate a fresh random scalar `e` and compute `E = eG`
2. For **each receiver key** `P`, compute: \
a. Unique shared secret for this key: `Zx = x(eP)` \
b. Slot index `i` in `[data, ...pubkeys, ...refund]` \
c. Blinding scalar: `rᵢ = SHA-256(b"Cashu_P2BK_v1" || Zx || i_byte)` \
d. Blinded Public Key: `P' = P + rᵢG`
3. Build the canonical P2PK secret with the blinded `P'` keys in their slots.
4. Interact with the mint normally; the mint never learns `P` or `rᵢ`
5. Include `p2pk_e = E` in the final proof

> [!IMPORTANT]
> Use a fresh ephemeral keypair (`e` / `E`) for each new output, so that every proof has
> unique blinded keys and a unique `E` in the `Proof.p2pk_e` field.
>
> In the case of `SIG_ALL`, the **SAME** ephemeral keypair **MUST** be used for all
> outputs, as all `SIG_ALL` proof secrets must have IDENTICAL `data` and `tags` fields.

## Receiver Workflow

1. Read `E` from `proof.p2pk_e` and the key slot order index `i` from `[data, ...pubkeys, ...refund]`
2. Calculate your unique shared secret: `Zx = x(pE)`
3. For each slot `i`, compute: \
a. Blinding scalar: `rᵢ = SHA-256(b"Cashu_P2BK_v1" || Zx || i_byte)` \
b. Compute `Rᵢ = rᵢG` \
c. Unblind `P = P' − Rᵢ` \
d. Verify `x(P) == x(pG)`. If it does not match, this `P'` is not for this private key, skip it. \
e. Derive the secret key using: \
• standard derivation if `parity(P) == parity(pG)` \
• negative derivation otherwise
4. Remove the `p2pk_e` field from the proof
5. Sign with the derived private keys and spend as an ordinary P2PK proof

> [!NOTE]
> Each receiver can only calculate their OWN shared secret (`pE`), because a shared secret requires either the receiver's private key (`pE`) or the sender's ephemeral private key (`eP`).

[11]: 11.md
[14]: 14.md
[tests]: tests/28-tests.md
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio
| [25][25] | Payment Method: BOLT12 | [cdk], [cashu-ts][ts] | [cdk-mintd] |
| [26][26] | Payment Request Bech32m Encoding | [cdk] | - |
| [27][27] | Nostr Mint Backup | [Cashu.me][cashume], [cdk] | - |
| [28][28] | Pay to Blinded Key (P2BK) | - | - |

#### Wallets:

Expand Down Expand Up @@ -102,3 +103,4 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio
[25]: 25.md
[26]: 26.md
[27]: 27.md
[28]: 28.md
127 changes: 127 additions & 0 deletions tests/28-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# NUT-28 Test Vectors

The test vectors in this section use the following inputs.

```shell
# Sender ephemeral Keypair (E = e·G)
e: "1cedb9df0c6872188b560ace9e35fd55c2532d53e19ae65b46159073886482ca" # hex encoded private key
E: "02a8cda4cf448bfce9a9e46e588c06ea1780fcb94e3bbdf3277f42995d403a8b0c" # hex encoded public key

# Receiver long-lived Keypair (P = p·G)
p: "ad37e8abd800be3e8272b14045873f4353327eedeb702b72ddcc5c5adff5129c" # hex encoded private key
P: "02771fed6cb88aaac38b8b32104a942bf4b8f4696bc361171b3c7d06fa2ebddf06" # hex encoded public key

```

Per NUT-11, there are up to 11 locking 'slots' in the order: `[data, ...pubkeys, ...refund]`.

Slot 0 is the `data` tag. Slots 1-10 can be any combination of `pubkeys` and `refund` keys.

### Example P2BK proof

The following P2BK proof shows the receiver's public key (P) blinded in the `data` tag (slot `0`), and the ephemeral public key (E) in the `p2pk_e`metadata field.

```json
{
"amount": 64,
"C": "0381855ddcc434a9a90b3564f29ef78e7271f8544d0056763b418b00e88525c0ff",
"id": "009a1f293253e41e",
"secret": "[\"P2PK\",{\"nonce\":\"d4a17a88f5d0c09001f7b453c42c1f9d5a87363b1f6637a5a83fc31a6a3b7266\",\"data\":\"03b7c03eb05a0a539cfc438e81bcf38b65b7bb8685e8790f9b853bfe3d77ad5315\",\"tags\":[]}]",
"dleq": {
"s": "6178978456c42eee8eefb50830fc3146be27b05619f04e3490dc596005f0cc78",
"e": "23f2190b18bfd043d3a526103e15f4a938d646a6bf93b017e2bb7c85e1540b32",
"r": "d26a55aa39ca50957fdaf54036b01053b0de42048b96a6fb2a167e03f00d0a0f"
},
"p2pk_e": "02a8cda4cf448bfce9a9e46e588c06ea1780fcb94e3bbdf3277f42995d403a8b0c"
}
```

### Shared Secret (Zx)

The unique shared secret between sender and receiver is: `x(e·p·G) = x(e·P) = x(p·E)`:

```shell
Zx: "40d6ba4430a6dfa915bb441579b0f4dee032307434e9957a092bbca73151df8b" # hex encoded bytes
```

### Deterministic blinding scalar (r)

The following are valid ECDH blinding scalars for receiver pubkey (P), derived by locking slot.

```shell
r0: "f43cfecf4d44e109872ed601156a01211c0d9eba0460d5be254a510782a2d4aa" # scalar as hex padded 64
r1: "4a57e6acb9db19344af5632aa45000cd2c643550bc63c7d5732221171ab0f5b3" # scalar as hex padded 64
r2: "d4a8b84b21f2b0ad31654e96eddbc32bfdedae2d05dc179bdd6cc20236b1104d" # scalar as hex padded 64
r3: "ecebf43123d1da3de611a05f5020085d63ca20829242cdc07f7c780e19594798" # scalar as hex padded 64
r4: "5f42d463ead44cbb20e51843d9eb3b8b0e0021566fd89852d23ae85f57d60858" # scalar as hex padded 64
r5: "a8f1c9d336954997ad571e5a5b59fe340c80902b10b9099d44e17abb3070118c" # scalar as hex padded 64
r6: "c39fa43b707215c163593fb8cadc0eddb4fe2f82c0c79c82a6fc2e3b6b051a7e" # scalar as hex padded 64
r7: "b17d6a51396eb926f4a901e20ff760a852563f90fd4b85e193888f34fd2ee523" # scalar as hex padded 64
r8: "4d4af85ea296457155b7ce328cf9accbe232e8ac23a1dfe901a36ab1b72ea04d" # scalar as hex padded 64
r9: "ce311248ea9f42a73fc874b3ce351d55964652840d695382f0018b36bb089dd1" # scalar as hex padded 64
r10 "9de35112d62e6343d02301d8f58fef87958e99bb68cfdfa855e04fe18b95b114" # scalar as hex padded 64
```

### Blinded Public Keys (P')

The following are valid blinded public keys for receiver pubkey (P), derived by locking slot.

```shell
0: "03b7c03eb05a0a539cfc438e81bcf38b65b7bb8685e8790f9b853bfe3d77ad5315" # hex encoded public key
1: "0352fb6d93360b7c2538eedf3c861f32ea5883fceec9f3e573d9d84377420da838" # hex encoded public key
2: "03667361ca925065dcafea0a705ba49e75bdd7975751fcc933e05953463c79fff1" # hex encoded public key
3: "02aca3ed09382151250b38c85087ae0a1436a057b40f824a5569ba353d40347d08" # hex encoded public key
4: "02cd397bd6e326677128f1b0e5f1d745ad89b933b1b8671e947592778c9fc2301d" # hex encoded public key
5: "0394140369aae01dbaf74977ccbb09b3a9cf2252c274c791ac734a331716f1f7d4" # hex encoded public key
6: "03480f28e8f8775d56a4254c7e0dfdd5a6ecd6318c757fcec9e84c1b48ada0666d" # hex encoded public key
7: "02f8a7be813f7ba2253d09705cc68c703a9fd785a055bf8766057fc6695ec80efc" # hex encoded public key
8: "03aa5446aaf07ca9730b233f5c404fd024ef92e3787cd1c34c81c0778fe23c59e9" # hex encoded public key
9: "037f82d4e0a79b0624a58ef7181344b95afad8acf4275dad49bcd39c189b73ece2" # hex encoded public key
10: "032371fc0eef6885062581a3852494e2eab8f384b7dd196281b85b77f94770fac5" # hex encoded public key
```

### Derived Secret Keys

The following are valid derived secret keys for the receiver secret key (p), by locking slot.

```shell
# skStd: standard derivation, (p + r0) mod n
0: "a174e77b25459f4809a187415af14065b49140c1408860f543444ed59261a605" # hex encoded private key
1: "f78fcf5891dbd772cd68146ae9d740107f96b43ea7d3f34850ee7d71faa6084f" # hex encoded private key
2: "81e0a0f6f9f36eebb3d7ffd733630270967150344203a2d2fb66bfd0466fe1a8" # hex encoded private key
3: "9a23dcdcfbd2987c6884519f95a747a1fc4dc289ce6a58f79d7675dc291818f3" # hex encoded private key
4: "0c7abd0fc2d50af9a357c9841f727acfa683c35dac002389f034e62d6794d9b3" # hex encoded private key
5: "5629b27f0e9607d62fc9cf9aa0e13d78a50432324ce094d462db7889402ee2e7" # hex encoded private key
6: "70d78ce74872d3ffe5cbf0f910634e224d81d189fcef27b9c4f62c097ac3ebd9" # hex encoded private key
7: "5eb552fd116f7765771bb322557e9fecead9e19839731118b1828d030cedb67e" # hex encoded private key
8: "fa82e10a7a9703afd82a7f72d280ec0f3565679a0f120b5bdf6fc70c9723b2e9" # hex encoded private key
9: "7b68faf4c2a000e5c23b25f413bc5c9a2ec9f48b4990deba0dfb8904cac76f2c" # hex encoded private key
10: "4b1b39beae2f21825295b3193b172ecc2e123bc2a4f76adf73da4daf9b54826f" # hex encoded private key

# skNeg: negated derivation, (-p + r0) mod n
0: "47051623754422cb04bc24c0cfe2c1ddc8db1fcc18f0aa4b477df4aca2adc20e" # hex encoded private key
1: "9d1ffe00e1da5af5c882b1ea5ec8c18893e09349803c3c9e552823490af22458" # hex encoded private key
2: "2770cf9f49f1f26eaef29d56a85483e8aabb2f3f1a6bec28ffa065a756bbfdb1" # hex encoded private key
3: "3fb40b854bd11bff639eef1f0a98c91a1097a194a6d2a24da1b01bb3396434fc" # hex encoded private key
4: "b20aebb812d38e7c9e7267039463fc46757c7f4f33b10d1bb440ea91481736fd" # hex encoded private key
5: "fbb9e1275e948b592ae46d1a15d2beef73fcee23d4917e6626e77ced20b14031" # hex encoded private key
6: "1667bb8f98715782e0e68e788554cf9a61cbb094d557710fc92fd1e08b1007e2" # hex encoded private key
7: "044581a5616dfae8723650a1ca702164ff23c0a311db5a6eb5bc32da1d39d287" # hex encoded private key
8: "a0130fb2ca958732d3451cf247726d8749af46a4e77a54b1e3a96ce3a76fcef2" # hex encoded private key
9: "20f9299d129e8468bd55c37388adde124313d39621f9281012352edbdb138b35" # hex encoded private key
10: "f0ab6866fe2da5054db05098b008b042fd0af7b42ca8547137e652137bd6dfb9" # hex encoded private key
```

### Choosing Correct Secret Key Derivation

To decide which derivation to use, receiver calculates their natural Pubkey and compares the parity to their actual pubkey. If the parity matches, use standard derivation, otherwise negated.

```shell
# Natural Pubkey: pG
pG: "03771fed6cb88aaac38b8b32104a942bf4b8f4696bc361171b3c7d06fa2ebddf06" # hex encoded public key

# Actual Pubkey:
P: "02771fed6cb88aaac38b8b32104a942bf4b8f4696bc361171b3c7d06fa2ebddf06" # hex encoded public key

# Parity is mismatched (Schnorr even-Y lifted), so use negated derivation key for slot
```