-
Notifications
You must be signed in to change notification settings - Fork 75
NUT28 - ECDH-derived Pay-to-Blinded-Key (P2BK) #300
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
81697fd
NUTXX - Pay to Blinded Key (P2BK) - ECDH
robwoodgate 6925c5f
Remove keyset_id from blinding factor calculation, rework test vectors
robwoodgate da009a1
adds a line break
robwoodgate d7e7ab8
Set NUT number to 28
robwoodgate b66dfc8
Apply suggestions from code review
robwoodgate 21cacc1
clarify sender public key protection
robwoodgate 51d8ea4
remove redundant line - we spell our the derivation process
robwoodgate File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,9 @@ | |
|
|
||
| `optional` | ||
|
|
||
| `depends on: NUT-10` | ||
| `depends on: NUT-10, NUT-11` | ||
|
|
||
| `extended by: NUT-28` | ||
|
|
||
| --- | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| # 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 improves user privacy by preventing the mint from linking multiple P2PK spends by the same party. | ||
|
|
||
| ## ECDH Shared Secret (Zx) | ||
|
|
||
| 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`), 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). | ||
|
|
||
| The shared secret `Zx` 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 | ||
|
|
||
| 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 | ||
| ``` | ||
|
|
||
| ### Example | ||
|
|
||
| Below is an example implementation 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"); | ||
| } | ||
| } | ||
robwoodgate marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 // NEW: 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 (`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` | ||
robwoodgate marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ``` |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.