Skip to content

feat: support NUT-28 P2BK#950

Open
KvngMikey wants to merge 4 commits intocashubtc:mainfrom
KvngMikey:support_p2bk
Open

feat: support NUT-28 P2BK#950
KvngMikey wants to merge 4 commits intocashubtc:mainfrom
KvngMikey:support_p2bk

Conversation

@KvngMikey
Copy link
Copy Markdown
Contributor

@KvngMikey KvngMikey commented Mar 26, 2026

Closes #856

Summary

Implements NUT-28 (Pay-to-Blinded-Key) across Nutshell's core and wallet layers. P2BK extends NUT-11 (P2PK) spending conditions by ECDH-blinding each locking pubkey with an ECDH-derived scalar before the secret is written. The mint sees a standard P2PK secret, enforces standard BIP-340 signatures, and learns nothing about the real receiver pubkey.

What P2BK enables

  • Silent payments for Cashu as sender locks ecash to a receiver pubkey without that pubkey ever appearing at the mint
  • Payment unlinkability as each proof carries a fresh ephemeral pubkey E, making proofs from the same sender to the same receiver cryptographically unlinkable
  • True multi-party slots as per-key ECDH means each pubkey in a multisig proof is independently blinded; slot owners cannot be linked or identified by position

Changes

  • Per-key ECDH in blind_pubkeys
  • p2pk_e on Proof, pe on TokenV4Proof, V3/V4 serialization
  • create_p2bk_lock, _derive_p2bk_signing_key with HTLC-safe try/except
  • P2BK signing on swap + melt, HTLC inheritance comment, strip before mint
  • P2BK: and P2BK-SIGALL: lock prefixes

Copilot AI review requested due to automatic review settings March 26, 2026 21:27
@github-project-automation github-project-automation bot moved this to Backlog in nutshell Mar 26, 2026
@KvngMikey KvngMikey marked this pull request as draft March 26, 2026 21:28
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 26, 2026

Codecov Report

❌ Patch coverage is 85.80247% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.32%. Comparing base (89564fe) to head (fcd9f1b).
⚠️ Report is 73 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
cashu/wallet/helpers.py 8.33% 11 Missing ⚠️
cashu/wallet/p2bk.py 84.78% 7 Missing ⚠️
cashu/core/p2bk.py 93.15% 5 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main     #950       +/-   ##
===========================================
+ Coverage   61.51%   75.32%   +13.80%     
===========================================
  Files          93      101        +8     
  Lines       11051    11868      +817     
===========================================
+ Hits         6798     8939     +2141     
+ Misses       4253     2929     -1324     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements NUT-28 Pay-to-Blinded-Key (P2BK) support end-to-end by introducing core blinding/unblinding primitives, carrying the ephemeral pubkey through proofs/tokens, and updating the wallet to create P2BK locks and sign spends with derived blinded keys.

Changes:

  • Add core P2BK cryptographic helpers (ECDH shared secret, scalar derivation, pubkey blinding, blinded key derivation).
  • Extend Proof/TokenV4 serialization to carry P2BK ephemeral pubkey metadata (p2pk_e / pe) and update wallet signing flow to strip it before mint calls.
  • Add wallet/CLI support for creating P2BK locks and integration/unit tests covering primitives + wallet redemption.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
cashu/core/p2bk.py New core primitives for blinding pubkeys and deriving blinded private keys.
cashu/core/base.py Adds Proof.p2pk_e and TokenV4 pe encoding/decoding for P2BK metadata.
cashu/wallet/p2bk.py New wallet mixin to create P2BK locks and derive P2BK signing keys.
cashu/wallet/p2pk.py Integrates P2BK signing-key derivation into existing P2PK witness/signing flows and strips p2pk_e before mint calls.
cashu/wallet/wallet.py Propagates p2pk_e through swap_to_send/split and attaches it to outgoing proofs.
cashu/wallet/helpers.py Extends CLI send helper to accept P2BK: / P2BK-SIGALL: locks.
tests/wallet/test_wallet_p2bk.py Adds unit + integration tests for P2BK primitives and wallet/token roundtrips.
Comments suppressed due to low confidence (2)

cashu/wallet/wallet.py:676

  • split gained the p2pk_e parameter but the docstring/Args section wasn’t updated to describe it. Please document what p2pk_e represents (ephemeral pubkey E), when it should be provided, and that it is attached only to send_proofs for P2BK.
    async def split(
        self,
        proofs: List[Proof],
        amount: int,
        secret_lock: Optional[Secret] = None,
        include_fees: bool = False,
        p2pk_e: Optional[str] = None,
    ) -> Tuple[List[Proof], List[Proof]]:
        """Calls the swap API to split the proofs into two sets of proofs, one for keeping and one for sending.

        If secret_lock is None, random secrets will be generated for the tokens to keep (keep_outputs)
        and the promises to send (send_outputs). If secret_lock is provided, the wallet will create
        blinded secrets with those to attach a predefined spending condition to the tokens they want to send.

        Calls `sign_proofs_inplace_swap` which parses all proofs and checks whether their
        secrets corresponds to any locks that we have the unlock conditions for. If so,
        it adds the unlock conditions to the proofs.

        Args:
            proofs (List[Proof]): Proofs to be split.
            amount (int): Amount to be sent.
            secret_lock (Optional[Secret], optional): Secret to lock the tokens to be sent. Defaults to None.
            include_fees (bool, optional): If True, the fees are included in the amount to send (output of
                this method, to be sent in the future). This is not the fee that is required to swap the
                `proofs` (input to this method) which must already be included. Defaults to False.

        Returns:
            Tuple[List[Proof], List[Proof]]: Two lists of proofs, one for keeping and one for sending.

cashu/wallet/wallet.py:1256

  • swap_to_send now accepts p2pk_e but the docstring/Args list doesn’t mention it. Please document that this is the NUT-28 ephemeral pubkey E to be attached to the resulting send_proofs (and that it should only be used together with a P2BK-blinded secret_lock).
    async def swap_to_send(
        self,
        proofs: List[Proof],
        amount: int,
        *,
        secret_lock: Optional[Secret] = None,
        set_reserved: bool = False,
        include_fees: bool = False,
        p2pk_e: Optional[str] = None,
    ) -> Tuple[List[Proof], List[Proof]]:
        """
        Swaps a set of proofs with the mint to get a set that sums up to a desired amount that can be sent. The remaining
        proofs are returned to be kept. All newly created proofs will be stored in the database but if `set_reserved` is set
        to True, the proofs to be sent (which sum up to `amount`) will be marked as reserved so they aren't used in other
        transactions.

        Args:
            proofs (List[Proof]): Proofs to split
            amount (int): Amount to split to
            secret_lock (Optional[str], optional): If set, a custom secret is used to lock new outputs. Defaults to None.
            set_reserved (bool, optional): If set, the proofs are marked as reserved. Should be set to False if a payment attempt
                is made with the split that could fail (like a Lightning payment). Should be set to True if the token to be sent is
                displayed to the user to be then sent to someone else. Defaults to False.
            include_fees (bool, optional): If set, the fees for spending the send_proofs later are included in the amount to be selected. Defaults to True.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@ye0man ye0man added this to the 0.20.0 milestone Mar 31, 2026
@a1denvalu3
Copy link
Copy Markdown
Collaborator

Vulnerability: Permanent Loss of Funds in P2BK Refund Path

The NUT-28 Pay-to-Blinded-Key (P2BK) implementation introduces a critical vulnerability where refund keys are blinded using the receiver's ECDH shared secret instead of the sender's. When a sender creates a P2BK lock with a locktime (which is default behavior when locktimes are configured), the wallet adds a refund tag containing the sender's public key.

However, in cashu/core/p2bk.py and WalletP2BK.create_p2bk_lock(), the blind_pubkeys function computes the ECDH shared secret zx exclusively using the receiver_pubkey (passed as data_pubkey=data and receiver_pubkey=data). It then derives the blinding scalars r_i from this single zx and applies them to all pubkeys, including the refund_pubkeys.

Because the sender's refund pubkeys are blinded using the receiver's shared secret, the sender cannot unblind them later. When the sender attempts to refund an unclaimed token, derive_blinded_private_key() tries to recompute zx = x(sender_priv * E). This zx will not match the original zx = x(receiver_priv * E) used during blinding. Consequently, the derived scalar r_i will be incorrect, and the wallet will silently fail to recognize or sign the refund token.

This permanently locks the funds in the refund path. If the receiver does not claim the token, the sender's funds are burned permanently.

Working Exploit PoC:

import asyncio
from cashu.core.base import Proof
from cashu.core.crypto.secp import PrivateKey
from cashu.wallet.p2bk import WalletP2BK
from cashu.core.secret import Tags

async def main():
    # Setup Sender Wallet
    sender_wallet = WalletP2BK()
    sender_wallet.private_key = PrivateKey()
    sender_pub = sender_wallet.private_key.public_key.format(compressed=True).hex()

    # Setup Receiver Wallet
    receiver_wallet = WalletP2BK()
    receiver_wallet.private_key = PrivateKey()
    receiver_pub = receiver_wallet.private_key.public_key.format(compressed=True).hex()

    # Sender creates a P2BK lock to send to the receiver, but adds a refund condition for themselves.
    # This automatically happens in CLI via locktime_delta_seconds.
    tags = Tags()
    tags["refund"] = [sender_pub]
    
    # Bug: create_p2bk_lock blinds all keys (including refund) using receiver_pub
    secret_lock, ephemeral_pub = await sender_wallet.create_p2bk_lock(
        data=receiver_pub,
        tags=tags,
    )
    
    # Create a dummy proof with this lock representing the token in transit
    proof = Proof(id="test", amount=1, secret=secret_lock.serialize(), C="test", p2pk_e=ephemeral_pub)

    # The Receiver CAN unblind their main slot (slot 0) using their private key
    receiver_unblinded = receiver_wallet._derive_p2bk_signing_key(proof)
    print(f"Receiver unblind success (should be True): {receiver_unblinded is not None}")

    # The Sender CANNOT unblind their own refund slot (slot 1) using their private key
    sender_unblinded = sender_wallet._derive_p2bk_signing_key(proof)
    print(f"Sender unblind refund success (should be False): {sender_unblinded is not None}")
    
    if sender_unblinded is None:
        print("VULNERABILITY CONFIRMED: Sender cannot refund their own P2BK token!")

asyncio.run(main())

@KvngMikey KvngMikey marked this pull request as ready for review April 9, 2026 15:02
@KvngMikey KvngMikey changed the title Support NUT-28 P2BK feat: support NUT-28 P2BK Apr 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

Ensure Nutshell Compatibility with P2BK (ECDH-derived P2PK) Proofs

4 participants