diff --git a/00.md b/00.md index 375ee76d..8c58f1df 100644 --- a/00.md +++ b/00.md @@ -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 @@ -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. @@ -347,3 +357,4 @@ utf8("craw") || utf8() || [10]: 10.md [11]: 11.md [12]: 12.md +[28]: 28.md diff --git a/11.md b/11.md index 51baeddf..a529d414 100644 --- a/11.md +++ b/11.md @@ -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. diff --git a/14.md b/14.md index 17accd84..84444c8c 100644 --- a/14.md +++ b/14.md @@ -2,7 +2,9 @@ `optional` -`depends on: NUT-10` +`depends on: NUT-10, NUT-11` + +`extended by: NUT-28` --- diff --git a/28.md b/28.md new file mode 100644 index 00000000..3b8005f6 --- /dev/null +++ b/28.md @@ -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, // 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 diff --git a/README.md b/README.md index 60d2f47a..29dfc8f6 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/tests/28-tests.md b/tests/28-tests.md new file mode 100644 index 00000000..8c0a4279 --- /dev/null +++ b/tests/28-tests.md @@ -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 +```