-
-
Notifications
You must be signed in to change notification settings - Fork 168
feat: support NUT-28 P2BK #950
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
Open
KvngMikey
wants to merge
5
commits into
cashubtc:main
Choose a base branch
from
KvngMikey:support_p2bk
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
922d8dc
feat(p2bk): implement NUT-28 Pay-to-Blinded-Key
KvngMikey ab0f829
chore: copilot reviews
KvngMikey 7e8bd18
chore: vulnerability fix
KvngMikey fcd9f1b
chore: HTLC inheritance, multisig slot independence, per-key ECDH har…
KvngMikey 4371957
chore: add p2pk_e to proofs
KvngMikey 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 |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| import hashlib | ||
| import os | ||
| from typing import List, Optional, Tuple | ||
|
|
||
| from .crypto.secp import PrivateKey, PublicKey | ||
|
|
||
| # Domain separator for P2BK blinding scalar derivation | ||
| P2BK_DOMAIN_SEPARATOR = b"Cashu_P2BK_v1" | ||
|
|
||
| # secp256k1 curve order | ||
| SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 | ||
|
|
||
| def _compressed_pubkey(pubkey_hex: str) -> str: | ||
| """Ensure a pubkey is in compressed SEC1 format (33 bytes / 66 hex chars). | ||
| Silently adds '02' prefix to bare 32-byte (BIP-340 / Nostr) x-only keys. | ||
| """ | ||
| raw = bytes.fromhex(pubkey_hex) | ||
| if len(raw) == 32: | ||
| # x-only key, add 02 prefix | ||
| return "02" + pubkey_hex | ||
| if len(raw) == 33 and raw[0] in (0x02, 0x03): | ||
| return pubkey_hex | ||
| raise ValueError(f"Invalid pubkey length: {len(raw)} bytes") | ||
|
|
||
| def ecdh_shared_secret(point: PublicKey, scalar: PrivateKey) -> bytes: | ||
| """Compute x-only ECDH shared secret Zx = x(scalar * point)""" | ||
| shared_point = point.multiply(bytes.fromhex(scalar.to_hex())) | ||
| # compressed format is prefix (1 byte) + x-coordinate (32 bytes) | ||
| compressed = shared_point.format(compressed=True) | ||
| return compressed[1:] # strip the 02/03 prefix to get Zx | ||
|
|
||
| def derive_blinding_scalar(zx: bytes, slot_index: int) -> int: | ||
| """Derive a deterministic blinding scalar r_i from ECDH shared secret and slot index.""" | ||
| i_byte = bytes([slot_index & 0xFF]) | ||
| data = P2BK_DOMAIN_SEPARATOR + zx + i_byte | ||
| r = int.from_bytes(hashlib.sha256(data).digest(), "big") | ||
| if r == 0 or r >= SECP256K1_ORDER: | ||
| # retry with 0xff appended | ||
| data_retry = data + b"\xff" | ||
| r = int.from_bytes(hashlib.sha256(data_retry).digest(), "big") | ||
| if r == 0 or r >= SECP256K1_ORDER: | ||
| raise ValueError("P2BK: blinding scalar derivation failed") | ||
| return r | ||
|
|
||
| def _scalar_to_privkey(scalar: int) -> PrivateKey: | ||
| """Convert an integer scalar to a PrivateKey.""" | ||
| return PrivateKey(scalar.to_bytes(32, "big")) | ||
|
|
||
| def _pubkey_x(pubkey: PublicKey) -> bytes: | ||
| """Get the x-coordinate (32 bytes) from a compressed public key.""" | ||
| return pubkey.format(compressed=True)[1:] | ||
|
|
||
| def blind_pubkeys( | ||
| data_pubkey: str, | ||
| additional_pubkeys: List[str], | ||
| refund_pubkeys: List[str], | ||
| ephemeral_privkey: Optional[PrivateKey] = None, | ||
| ) -> Tuple[str, List[str], List[str], str]: | ||
| """Blind all pubkeys in a P2PK secret using per-key ECDH. | ||
|
|
||
| Each pubkey P_i gets its own shared secret Zx_i = x(e * P_i), as required | ||
| by NUT-28: "For each receiver key P, compute: Unique shared secret Zx = x(eP)." | ||
|
|
||
| Args: | ||
| data_pubkey: The main locking pubkey. | ||
| additional_pubkeys: Additional pubkeys from the "pubkeys" tag. | ||
| refund_pubkeys: Refund pubkeys from the "refund" tag. | ||
| ephemeral_privkey: Optional ephemeral private key. Generated if None. | ||
|
|
||
| Returns: | ||
| Tuple of (blinded_data_pubkey, blinded_additional, blinded_refund, ephemeral_pubkey_hex) | ||
| """ | ||
| if ephemeral_privkey is None: | ||
| ephemeral_privkey = PrivateKey(os.urandom(32)) | ||
|
|
||
| assert ephemeral_privkey.public_key | ||
| ephemeral_pubkey_hex = ephemeral_privkey.public_key.format(compressed=True).hex() | ||
|
|
||
| # collect all pubkeys in slot order: [data, ...pubkeys, ...refund] | ||
| all_pubkeys = [data_pubkey] + additional_pubkeys + refund_pubkeys | ||
| blinded = [] | ||
| for i, pk_hex in enumerate(all_pubkeys): | ||
| pk_hex = _compressed_pubkey(pk_hex) | ||
| pk = PublicKey(bytes.fromhex(pk_hex)) | ||
| # Per-key ECDH: Zx_i = x(e * P_i) | ||
| zx_i = ecdh_shared_secret(pk, ephemeral_privkey) | ||
| r_i = derive_blinding_scalar(zx_i, i) | ||
| blinding_point = _scalar_to_privkey(r_i).public_key | ||
| assert blinding_point | ||
| blinded_pk = pk + blinding_point # type: ignore[operator] # P' = P + r_i*G | ||
| blinded.append(blinded_pk.format(compressed=True).hex()) | ||
|
|
||
| # split back into data, pubkeys, refund | ||
| blinded_data = blinded[0] | ||
| blinded_additional = blinded[1 : 1 + len(additional_pubkeys)] | ||
| blinded_refund = blinded[1 + len(additional_pubkeys) :] | ||
|
|
||
| return blinded_data, blinded_additional, blinded_refund, ephemeral_pubkey_hex | ||
|
|
||
|
|
||
| def derive_blinded_private_key( | ||
| privkey: PrivateKey, | ||
| ephemeral_pubkey_hex: str, | ||
| blinded_pubkey_hex: str, | ||
| slot_index: int, | ||
| ) -> Optional[PrivateKey]: | ||
| """derive the blinded private key for a given slot. | ||
|
|
||
| Args: | ||
| privkey: Receiver's long-lived private key p. | ||
| ephemeral_pubkey_hex: Sender's ephemeral public key E (hex, 33 bytes). | ||
| blinded_pubkey_hex: The blinded public key P' from the secret. | ||
| slot_index: The slot index i. | ||
|
|
||
| Returns: | ||
| The blinded PrivateKey k, or None if this slot is not for this key. | ||
| """ | ||
| ephemeral_pubkey_hex = _compressed_pubkey(ephemeral_pubkey_hex) | ||
| E = PublicKey(bytes.fromhex(ephemeral_pubkey_hex)) | ||
|
|
||
| # Zx = x(p * E) | ||
| zx = ecdh_shared_secret(E, privkey) | ||
|
|
||
| r_i = derive_blinding_scalar(zx, slot_index) | ||
|
|
||
| # R_i = r_i * G | ||
| r_i_key = _scalar_to_privkey(r_i) | ||
| R_i = r_i_key.public_key | ||
| assert R_i | ||
|
|
||
| # Unblind: P = P' - R_i | ||
| blinded_pubkey_hex = _compressed_pubkey(blinded_pubkey_hex) | ||
| P_prime = PublicKey(bytes.fromhex(blinded_pubkey_hex)) | ||
| P = P_prime - R_i # type: ignore | ||
|
|
||
| # Verify x(P) == x(p*G) | ||
| assert privkey.public_key | ||
| pG = privkey.public_key | ||
| if _pubkey_x(P) != _pubkey_x(pG): | ||
| return None # this slot is not for this key | ||
|
|
||
| # Parity check | ||
| p_int = int.from_bytes(bytes.fromhex(privkey.to_hex()), "big") | ||
| P_prefix = P.format(compressed=True)[0] | ||
| pG_prefix = pG.format(compressed=True)[0] | ||
|
|
||
| if P_prefix == pG_prefix: | ||
| # standard derivation: k = (p + r_i) mod n | ||
| k = (p_int + r_i) % SECP256K1_ORDER | ||
| else: | ||
| # negated derivation: k = (-p + r_i) mod n | ||
| k = ((-p_int) + r_i) % SECP256K1_ORDER | ||
|
|
||
| return _scalar_to_privkey(k) | ||
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
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
Oops, something went wrong.
Oops, something went wrong.
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.