From 81697fd74dcd672084a164d938bfef16abcc1f0f Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Sun, 19 Oct 2025 21:57:34 +0100 Subject: [PATCH 1/7] NUTXX - Pay to Blinded Key (P2BK) - ECDH --- 00.md | 11 +++ 11.md | 2 + 14.md | 4 +- 28x.md | 183 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 + tests/28x-tests.md | 129 ++++++++++++++++++++++++++++++++ 6 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 28x.md create mode 100644 tests/28x-tests.md diff --git a/00.md b/00.md index 375ee76d..60fb91e6 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]: 28x.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/28x.md b/28x.md new file mode 100644 index 00000000..b14998d0 --- /dev/null +++ b/28x.md @@ -0,0 +1,183 @@ +# 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 || keyset_id_bytes || i_byte) +``` + +Where: + +- `DOMAIN_SEPARATOR` constant byte string `b"Cashu_P2BK_v1"` +- `Zx` is the ECDH shared secret (`eP` for sender, `pE` for receiver). +- `keyset_id_bytes` is the `keyset_id` **hex-decoded** to raw bytes +- `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 || keyset_id_bytes || 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 + keysetId: Uint8Array, // keyset_id_bytes + 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, keysetId, iByte)), + ); + if (r === 0n || r >= secp256k1.Point.CURVE().n) { + // Very unlikely to get here! + r = bytesToNumber( + sha256( + Bytes.concat(P2BK_DST, Zx, keysetId, 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 || keyset_id_bytes || 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`, `keyset_id` from `proof.id`, 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 || keyset_id_bytes || 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/28x-tests.md diff --git a/README.md b/README.md index 60d2f47a..64120be6 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]: 28x.md diff --git a/tests/28x-tests.md b/tests/28x-tests.md new file mode 100644 index 00000000..7f584dc3 --- /dev/null +++ b/tests/28x-tests.md @@ -0,0 +1,129 @@ +# 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 + +# Keyset ID +kid: "009a1f293253e41e" # hex keyset ID from Mint +``` + +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\":\"03f221b62aa21ee45982d14505de2b582716ae95c265168f586dc547f0ea8f135f\",\"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: "41b5f15975f787bd5bd8d91753cbbe56d0d7aface851b1063e8011f68551862d" # scalar as hex padded 64 +r1: "c4d68c79b8676841f767bcd53437af3f43d51b205f351d5cdfe5cb866ec41494" # scalar as hex padded 64 +r2: "04ecf53095882f28965f267e46d2c555f15bcd74c3a84f42cf0de8ebfb712c7c" # scalar as hex padded 64 +r3: "4163bc31b3087901b8b28249213b0ecc447cee3ea1f0c04e4dd5934e0c3f78ad" # scalar as hex padded 64 +r4: "f5d6d20c399887f29bdda771660f87226e3a0d4ef36a90f40d3f717085957b60" # scalar as hex padded 64 +r5: "f275404a115cd720ee099f5d6b7d5dc705d1c95ac6ae01c917031b64f7dccc72" # scalar as hex padded 64 +r6: "39dffa9f0160bcda63920305fc12f88d824f5b654970dbd579c08367c12fcd78" # scalar as hex padded 64 +r7: "3331338e87608c7f36265c9b52bb5ebeac1bb3e2220d2682370f4b7c09dccd4b" # scalar as hex padded 64 +r8: "44947bd36c0200fb5d5d05187861364f6b666aac8ce37b368e27f01cea7cf147" # scalar as hex padded 64 +r9: "cf4e69842833e0dab8a7302933d648fee98de80284af2d7ead71b420a8f0ebde" # scalar as hex padded 64 +r10 "3638eae8a9889bbd96769637526010b34cd1e121805eaaaaa0602405529ca92f" # 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: "03f221b62aa21ee45982d14505de2b582716ae95c265168f586dc547f0ea8f135f" # hex encoded public key +1: "0299692178029fe08c49e8123bb0e84d6e960b27f82c8aed43013526489d46c0d5" # hex encoded public key +2: "03ae189850bda004f9723e17372c99ff9df9e29750d2147d40efb45ac8ab2cdd2c" # hex encoded public key +3: "03109838d718fbe02e9458ffa423f25bae0388146542534f8e2a094de6f7b697fa" # hex encoded public key +4: "0339d5ed7ea93292e60a4211b2daf20dff53f050835614643a43edccc35c8313db" # hex encoded public key +5: "0237861efcd52fe959bce07c33b5607aeae0929749b8339f68ba4365f2fb5d2d8d" # hex encoded public key +6: "026d5500988a62cde23096047db61e9fb5ef2fea5c521019e23862108ea4e14d72" # hex encoded public key +7: "039024fd20b26e73143509537d7c18595cfd101da4b18bb86ddd30e944aac6ef1b" # hex encoded public key +8: "03017ec4218ca2ed0fbe050e3f1a91221407bf8c896b803a891c3a52d162867ef8" # hex encoded public key +9: "0380dc0d2c79249e47b5afb61b7d40e37b9b0370ec7c80b50c62111021b886ab31" # hex encoded public key +10: "0261a8a32e718f5f27610a2b7c2069d6bab05d1ead7da21aa9dd2a3c758bdf6479" # 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: "eeedda054df845fbde4b8a579952fd9a240a2e9ad3c1dc791c4c6e51654698c9" # hex encoded private key +1: "720e75259068268079da6e1579beee83dc58bd279b5ca893fddfc9547e82e5ef" # hex encoded private key +2: "b224dddc6d88ed6718d1d7be8c5a0499448e4c62af187ab5acda4546db663f18" # hex encoded private key +3: "ee9ba4dd8b0937403b25338966c24e0f97af6d2c8d60ebc12ba1efa8ec348b49" # hex encoded private key +4: "a30ebab8119946311e5058b1ab96c66706bdaf562f921c2b2b396f3e95544cbb" # hex encoded private key +5: "9fad28f5e95d955f707c509db1049d0b9e556b6202d58d0034fd1933079b9dcd" # hex encoded private key +6: "e717e34ad9617b18e604b446419a37d0d581da5334e10748578cdfc2a124e014" # hex encoded private key +7: "e0691c3a5f614abdb8990ddb98429e01ff4e32d00d7d51f514dba7d6e9d1dfe7" # hex encoded private key +8: "f1cc647f4402bf39dfcfb658bde87592be98e99a7853a6a96bf44c77ca7203e3" # hex encoded private key +9: "7c86523000349f193b19e169795d884382118a09c0d6b8b5cb6bb1eeb8afbd39" # hex encoded private key +10: "e370d394818959fc18e9477797e74ff6a004600f6bced61d7e2c80603291bbcb" # hex encoded private key + +# skNeg: negated derivation, (-p + r0) mod n +0: "947e08ad9df6c97ed96627d70e447f1238540da5ac2a25cf208614287592b4d2" # hex encoded private key +1: "179ea3cde066aa0374f50b94eeb06ffbf0a29c3273c4f1ea02196f2b8ecf01f8" # hex encoded private key +2: "57b50c84bd8770ea13ec753e014b861158d82b6d8780c40bb113eb1debb25b21" # hex encoded private key +3: "942bd385db07bac3363fd108dbb3cf87abf94c3765c935172fdb957ffc80a752" # hex encoded private key +4: "489ee9606197c9b4196af631208847df1b078e6107fa65812f731515a5a068c4" # hex encoded private key +5: "453d579e395c18e26b96ee1d25f61e83b29f4a6cdb3dd6563936bf0a17e7b9d6" # hex encoded private key +6: "8ca811f3295ffe9be11f51c5b68bb948e9cbb95e0d49509e5bc68599b170fc1d" # hex encoded private key +7: "85f94ae2af5fce40b3b3ab5b0d341f7a139811dae5e59b4b19154dadfa1dfbf0" # hex encoded private key +8: "975c9327940142bcdaea53d832d9f70ad2e2c8a550bbefff702df24edabe1fec" # hex encoded private key +9: "221680d85033229c36347ee8ee4f09bb965b6914993f020bcfa557c5c8fbd942" # hex encoded private key +10: "8901023cd187dd7f1403e4f70cd8d16eb44e3f1a44371f738266263742ddd7d4" # 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 +``` From 6925c5ffa916c5b8debfe9af8082a4fc1e8222ca Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Sun, 11 Jan 2026 17:01:05 +0000 Subject: [PATCH 2/7] Remove keyset_id from blinding factor calculation, rework test vectors --- 28x.md | 20 ++++------ tests/28x-tests.md | 92 +++++++++++++++++++++++----------------------- 2 files changed, 52 insertions(+), 60 deletions(-) diff --git a/28x.md b/28x.md index b14998d0..0cb43f9a 100644 --- a/28x.md +++ b/28x.md @@ -35,14 +35,13 @@ Each public key in the NUT-11 proof is permanently blinded using a deterministic The blinding scalar for each slot is calculated as: ``` -rᵢ = SHA-256( DOMAIN_SEPARATOR || Zx || keyset_id_bytes || i_byte) +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). -- `keyset_id_bytes` is the `keyset_id` **hex-decoded** to raw bytes - `i_byte` is the single unsigned byte representation of `i`: (`0x00` to `0x0A`) - `||` denotes concatenation @@ -51,7 +50,7 @@ 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 || keyset_id_bytes || i_byte || 0xff ) +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. @@ -68,7 +67,6 @@ Here is a code example in TypeScript: function deriveP2BKBlindingTweakFromECDH( point: WeierstrassPoint, // E or P scalar: bigint, // p or e - keysetId: Uint8Array, // keyset_id_bytes slotIndex: number, // i ): bigint { // Calculate x-only ECDH shared point (Zx) @@ -76,15 +74,11 @@ function deriveP2BKBlindingTweakFromECDH( 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, keysetId, iByte)), - ); + 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, keysetId, iByte, new Uint8Array([0xff])), - ), + sha256(Bytes.concat(P2BK_DST, Zx, iByte, new Uint8Array([0xff]))), ); if (r === 0n || r >= secp256k1.Point.CURVE().n) { // Astronomically unlikely to get here! @@ -148,7 +142,7 @@ d. use standard derivation if `parity(P) == parity(pG)`, otherwise use negated d 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 || keyset_id_bytes || i_byte)` \ + 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ᵢ` @@ -162,10 +156,10 @@ d. use standard derivation if `parity(P) == parity(pG)`, otherwise use negated d ## Receiver Workflow -1. Read `E` from `proof.p2pk_e`, `keyset_id` from `proof.id`, and the key slot order index `i` from `[data, ...pubkeys, ...refund]` +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 || keyset_id_bytes || i_byte)` \ + 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. \ diff --git a/tests/28x-tests.md b/tests/28x-tests.md index 7f584dc3..8c0a4279 100644 --- a/tests/28x-tests.md +++ b/tests/28x-tests.md @@ -11,8 +11,6 @@ E: "02a8cda4cf448bfce9a9e46e588c06ea1780fcb94e3bbdf3277f42995d403a8b0c" # hex en p: "ad37e8abd800be3e8272b14045873f4353327eedeb702b72ddcc5c5adff5129c" # hex encoded private key P: "02771fed6cb88aaac38b8b32104a942bf4b8f4696bc361171b3c7d06fa2ebddf06" # hex encoded public key -# Keyset ID -kid: "009a1f293253e41e" # hex keyset ID from Mint ``` Per NUT-11, there are up to 11 locking 'slots' in the order: `[data, ...pubkeys, ...refund]`. @@ -28,7 +26,7 @@ The following P2BK proof shows the receiver's public key (P) blinded in the `dat "amount": 64, "C": "0381855ddcc434a9a90b3564f29ef78e7271f8544d0056763b418b00e88525c0ff", "id": "009a1f293253e41e", - "secret": "[\"P2PK\",{\"nonce\":\"d4a17a88f5d0c09001f7b453c42c1f9d5a87363b1f6637a5a83fc31a6a3b7266\",\"data\":\"03f221b62aa21ee45982d14505de2b582716ae95c265168f586dc547f0ea8f135f\",\"tags\":[]}]", + "secret": "[\"P2PK\",{\"nonce\":\"d4a17a88f5d0c09001f7b453c42c1f9d5a87363b1f6637a5a83fc31a6a3b7266\",\"data\":\"03b7c03eb05a0a539cfc438e81bcf38b65b7bb8685e8790f9b853bfe3d77ad5315\",\"tags\":[]}]", "dleq": { "s": "6178978456c42eee8eefb50830fc3146be27b05619f04e3490dc596005f0cc78", "e": "23f2190b18bfd043d3a526103e15f4a938d646a6bf93b017e2bb7c85e1540b32", @@ -51,17 +49,17 @@ Zx: "40d6ba4430a6dfa915bb441579b0f4dee032307434e9957a092bbca73151df8b" # hex enc The following are valid ECDH blinding scalars for receiver pubkey (P), derived by locking slot. ```shell -r0: "41b5f15975f787bd5bd8d91753cbbe56d0d7aface851b1063e8011f68551862d" # scalar as hex padded 64 -r1: "c4d68c79b8676841f767bcd53437af3f43d51b205f351d5cdfe5cb866ec41494" # scalar as hex padded 64 -r2: "04ecf53095882f28965f267e46d2c555f15bcd74c3a84f42cf0de8ebfb712c7c" # scalar as hex padded 64 -r3: "4163bc31b3087901b8b28249213b0ecc447cee3ea1f0c04e4dd5934e0c3f78ad" # scalar as hex padded 64 -r4: "f5d6d20c399887f29bdda771660f87226e3a0d4ef36a90f40d3f717085957b60" # scalar as hex padded 64 -r5: "f275404a115cd720ee099f5d6b7d5dc705d1c95ac6ae01c917031b64f7dccc72" # scalar as hex padded 64 -r6: "39dffa9f0160bcda63920305fc12f88d824f5b654970dbd579c08367c12fcd78" # scalar as hex padded 64 -r7: "3331338e87608c7f36265c9b52bb5ebeac1bb3e2220d2682370f4b7c09dccd4b" # scalar as hex padded 64 -r8: "44947bd36c0200fb5d5d05187861364f6b666aac8ce37b368e27f01cea7cf147" # scalar as hex padded 64 -r9: "cf4e69842833e0dab8a7302933d648fee98de80284af2d7ead71b420a8f0ebde" # scalar as hex padded 64 -r10 "3638eae8a9889bbd96769637526010b34cd1e121805eaaaaa0602405529ca92f" # scalar as hex padded 64 +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') @@ -69,17 +67,17 @@ r10 "3638eae8a9889bbd96769637526010b34cd1e121805eaaaaa0602405529ca92f" # scalar The following are valid blinded public keys for receiver pubkey (P), derived by locking slot. ```shell -0: "03f221b62aa21ee45982d14505de2b582716ae95c265168f586dc547f0ea8f135f" # hex encoded public key -1: "0299692178029fe08c49e8123bb0e84d6e960b27f82c8aed43013526489d46c0d5" # hex encoded public key -2: "03ae189850bda004f9723e17372c99ff9df9e29750d2147d40efb45ac8ab2cdd2c" # hex encoded public key -3: "03109838d718fbe02e9458ffa423f25bae0388146542534f8e2a094de6f7b697fa" # hex encoded public key -4: "0339d5ed7ea93292e60a4211b2daf20dff53f050835614643a43edccc35c8313db" # hex encoded public key -5: "0237861efcd52fe959bce07c33b5607aeae0929749b8339f68ba4365f2fb5d2d8d" # hex encoded public key -6: "026d5500988a62cde23096047db61e9fb5ef2fea5c521019e23862108ea4e14d72" # hex encoded public key -7: "039024fd20b26e73143509537d7c18595cfd101da4b18bb86ddd30e944aac6ef1b" # hex encoded public key -8: "03017ec4218ca2ed0fbe050e3f1a91221407bf8c896b803a891c3a52d162867ef8" # hex encoded public key -9: "0380dc0d2c79249e47b5afb61b7d40e37b9b0370ec7c80b50c62111021b886ab31" # hex encoded public key -10: "0261a8a32e718f5f27610a2b7c2069d6bab05d1ead7da21aa9dd2a3c758bdf6479" # hex encoded public key +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 @@ -88,30 +86,30 @@ The following are valid derived secret keys for the receiver secret key (p), by ```shell # skStd: standard derivation, (p + r0) mod n -0: "eeedda054df845fbde4b8a579952fd9a240a2e9ad3c1dc791c4c6e51654698c9" # hex encoded private key -1: "720e75259068268079da6e1579beee83dc58bd279b5ca893fddfc9547e82e5ef" # hex encoded private key -2: "b224dddc6d88ed6718d1d7be8c5a0499448e4c62af187ab5acda4546db663f18" # hex encoded private key -3: "ee9ba4dd8b0937403b25338966c24e0f97af6d2c8d60ebc12ba1efa8ec348b49" # hex encoded private key -4: "a30ebab8119946311e5058b1ab96c66706bdaf562f921c2b2b396f3e95544cbb" # hex encoded private key -5: "9fad28f5e95d955f707c509db1049d0b9e556b6202d58d0034fd1933079b9dcd" # hex encoded private key -6: "e717e34ad9617b18e604b446419a37d0d581da5334e10748578cdfc2a124e014" # hex encoded private key -7: "e0691c3a5f614abdb8990ddb98429e01ff4e32d00d7d51f514dba7d6e9d1dfe7" # hex encoded private key -8: "f1cc647f4402bf39dfcfb658bde87592be98e99a7853a6a96bf44c77ca7203e3" # hex encoded private key -9: "7c86523000349f193b19e169795d884382118a09c0d6b8b5cb6bb1eeb8afbd39" # hex encoded private key -10: "e370d394818959fc18e9477797e74ff6a004600f6bced61d7e2c80603291bbcb" # hex encoded private key +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: "947e08ad9df6c97ed96627d70e447f1238540da5ac2a25cf208614287592b4d2" # hex encoded private key -1: "179ea3cde066aa0374f50b94eeb06ffbf0a29c3273c4f1ea02196f2b8ecf01f8" # hex encoded private key -2: "57b50c84bd8770ea13ec753e014b861158d82b6d8780c40bb113eb1debb25b21" # hex encoded private key -3: "942bd385db07bac3363fd108dbb3cf87abf94c3765c935172fdb957ffc80a752" # hex encoded private key -4: "489ee9606197c9b4196af631208847df1b078e6107fa65812f731515a5a068c4" # hex encoded private key -5: "453d579e395c18e26b96ee1d25f61e83b29f4a6cdb3dd6563936bf0a17e7b9d6" # hex encoded private key -6: "8ca811f3295ffe9be11f51c5b68bb948e9cbb95e0d49509e5bc68599b170fc1d" # hex encoded private key -7: "85f94ae2af5fce40b3b3ab5b0d341f7a139811dae5e59b4b19154dadfa1dfbf0" # hex encoded private key -8: "975c9327940142bcdaea53d832d9f70ad2e2c8a550bbefff702df24edabe1fec" # hex encoded private key -9: "221680d85033229c36347ee8ee4f09bb965b6914993f020bcfa557c5c8fbd942" # hex encoded private key -10: "8901023cd187dd7f1403e4f70cd8d16eb44e3f1a44371f738266263742ddd7d4" # hex encoded private key +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 From da009a16f5389c4e1ed81a4dd157304c85c00d20 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Sun, 11 Jan 2026 17:20:37 +0000 Subject: [PATCH 3/7] adds a line break --- 28x.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/28x.md b/28x.md index 0cb43f9a..37375b49 100644 --- a/28x.md +++ b/28x.md @@ -150,7 +150,8 @@ d. use standard derivation if `parity(P) == parity(pG)`, otherwise use negated d > [!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. \ +> 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. From d7e7ab8103db16bcb855889cc278f0fb13615867 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Tue, 13 Jan 2026 15:24:29 +0000 Subject: [PATCH 4/7] Set NUT number to 28 --- 00.md | 2 +- 28x.md => 28.md | 2 +- README.md | 2 +- tests/{28x-tests.md => 28-tests.md} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename 28x.md => 28.md (99%) rename tests/{28x-tests.md => 28-tests.md} (100%) diff --git a/00.md b/00.md index 60fb91e6..8c58f1df 100644 --- a/00.md +++ b/00.md @@ -357,4 +357,4 @@ utf8("craw") || utf8() || [10]: 10.md [11]: 11.md [12]: 12.md -[28]: 28x.md +[28]: 28.md diff --git a/28x.md b/28.md similarity index 99% rename from 28x.md rename to 28.md index 37375b49..3b8005f6 100644 --- a/28x.md +++ b/28.md @@ -175,4 +175,4 @@ d. use standard derivation if `parity(P) == parity(pG)`, otherwise use negated d [11]: 11.md [14]: 14.md -[tests]: tests/28x-tests.md +[tests]: tests/28-tests.md diff --git a/README.md b/README.md index 64120be6..29dfc8f6 100644 --- a/README.md +++ b/README.md @@ -103,4 +103,4 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio [25]: 25.md [26]: 26.md [27]: 27.md -[28]: 28x.md +[28]: 28.md diff --git a/tests/28x-tests.md b/tests/28-tests.md similarity index 100% rename from tests/28x-tests.md rename to tests/28-tests.md From b66dfc8bfeea65f727d2d4c3c873fea553ffa9bf Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Thu, 12 Feb 2026 16:51:38 +0000 Subject: [PATCH 5/7] Apply suggestions from code review --- 00.md | 11 ----------- 28.md | 18 +++++++++--------- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/00.md b/00.md index 8c58f1df..375ee76d 100644 --- a/00.md +++ b/00.md @@ -95,11 +95,6 @@ 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 @@ -282,11 +277,6 @@ 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. @@ -357,4 +347,3 @@ utf8("craw") || utf8() || [10]: 10.md [11]: 11.md [12]: 12.md -[28]: 28.md diff --git a/28.md b/28.md index 3b8005f6..0fe787f7 100644 --- a/28.md +++ b/28.md @@ -10,19 +10,17 @@ 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. +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 improves user privacy by preventing the mint from linking multiple P2PK spends by the same party. ## 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)`. +Elliptic-curve Diffie–Hellman (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). +The receiver calculates the same shared secret `Zx` 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. +The shared secret `Zx` is then used to derive the blinded public keys. ## Deriving Blinded Public Keys @@ -61,7 +59,9 @@ Finally, the public key (`P`) for slot `i` is blinded (`P'`) as follows: P' = P + rᵢG ``` -Here is a code example in TypeScript: +### Example + +Below is an example implementation in TypeScript. ```ts function deriveP2BKBlindingTweakFromECDH( @@ -105,7 +105,7 @@ Each proof adds a single new metadata field: "id": hex_str, "secret": str, // still ["P2PK", {...}] "C": hex_str, - "p2pk_e": hex_str // 33-byte SEC1 compressed ephemeral public key E + "p2pk_e": hex_str // NEW: 33-byte SEC1 compressed ephemeral public key E } ``` @@ -118,7 +118,7 @@ Each proof adds a single new metadata field: 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: +The receiver must therefore derive the correct blinded private key (`k`). 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` From 21cacc13e308cc850f3996938145fcfc75c0daf9 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Mon, 16 Feb 2026 12:36:39 +0000 Subject: [PATCH 6/7] clarify sender public key protection --- 28.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/28.md b/28.md index 0fe787f7..ffa1ab50 100644 --- a/28.md +++ b/28.md @@ -16,7 +16,7 @@ P2BK preserves privacy by blinding each NUT-11 receiver pubkey `P` with an ECDH- Elliptic-curve Diffie–Hellman (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`). +For P2BK, the sender creates an ephemeral keypair (private key: `e`, public key: `E`), which protects the privacy of their usual 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 `Zx` using their private key (`p`) and the ephemeral public key (`E`), which is supplied by the sender in the [proof metadata](#proof-object-extension). From 51d8ea4b6e835804f47f072e785d886370ea3832 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Mon, 16 Feb 2026 12:51:13 +0000 Subject: [PATCH 7/7] remove redundant line - we spell our the derivation process --- 28.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/28.md b/28.md index ffa1ab50..8080f3d6 100644 --- a/28.md +++ b/28.md @@ -43,8 +43,6 @@ Where: - `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: ```