Skip to content

feat: support NUT-28 P2BK#950

Open
KvngMikey wants to merge 6 commits into
cashubtc:mainfrom
KvngMikey:support_p2bk
Open

feat: support NUT-28 P2BK#950
KvngMikey wants to merge 6 commits into
cashubtc:mainfrom
KvngMikey:support_p2bk

Conversation

@KvngMikey

@KvngMikey KvngMikey commented Mar 26, 2026

Copy link
Copy Markdown
Collaborator

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

codecov Bot commented Mar 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.71429% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.24%. Comparing base (cfbfe8f) to head (21f2928).
⚠️ Report is 147 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 85.10% 7 Missing ⚠️
cashu/core/p2bk.py 93.15% 5 Missing ⚠️
cashu/wallet/p2pk.py 92.85% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #950      +/-   ##
==========================================
+ Coverage   68.14%   75.24%   +7.10%     
==========================================
  Files          96      112      +16     
  Lines       11500    12282     +782     
==========================================
+ Hits         7837     9242    +1405     
+ Misses       3663     3040     -623     

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

Copilot AI left a comment

Copy link
Copy Markdown

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.

Comment thread cashu/wallet/p2pk.py
Comment thread cashu/core/p2bk.py
Comment thread cashu/wallet/p2bk.py
Comment thread cashu/wallet/helpers.py
Comment thread tests/wallet/test_wallet_p2bk.py Outdated
@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
@ye0man ye0man modified the milestones: 0.20.0, 0.21.0 May 5, 2026
@a1denvalu3

Copy link
Copy Markdown
Collaborator

The Pay-to-Blinded-Key (NUT-28) implementation introduces an ephemeral public key (p2pk_e) which is critical for unblinding tokens. However, the wallet database schema and related INSERT statements were not updated to store the p2pk_e field. As a result, when a P2BK token is saved to the database and later retrieved (e.g., via the cashu pending command, or after a wallet restart), the p2pk_e field is permanently lost. This renders the generated token unspendable by the receiver and irrecoverable by the sender via refund paths, leading to a permanent loss of funds.

@ye0man ye0man moved this from Backlog to Needs Review in nutshell Jun 5, 2026
@a1denvalu3

Copy link
Copy Markdown
Collaborator

title: "P2BK Refund Path Permanently Locked for Co-Signers"
slug: p2bk-refund-cosigner-lockup
date: 2026-06-05
status: confirmed
severity: high
target: [cashubtc/nutshell]
nuts: [NUT-28, NUT-11]

Summary

The P2BK (Pay-to-Blinded-Key) implementation introduced in PR #950 contains a fundamental logic flaw in how it derives signing keys for tokens with multiple spending paths. When a wallet unblinds a token, it iterates through all slots (main pubkeys and refund pubkeys) and returns the first successfully derived private key. If a user is a co-signer on the main multisig path AND the designated fallback/refund key, the wallet will always generate a signature for the main path. After the locktime expires, if the user attempts to claim the refund path, the mint will reject the transaction because the wallet signed with the wrong key, permanently locking the funds.

Root Cause

In cashu/wallet/p2bk.py, the _derive_p2bk_signing_key method aggregates all blinded pubkeys from the secret's data, pubkeys, and refund fields into a single list all_blinded_pubkeys. It then iterates through this list to find a slot that it can unblind:

        all_blinded_pubkeys = (
            [secret.data]
            + secret.tags.get_tag_all("pubkeys")
            + secret.tags.get_tag_all("refund")
        )
        for i, blinded_pk in enumerate(all_blinded_pubkeys):
            try:
                derived = derive_blinded_private_key(
                    privkey=self.private_key,
                    ephemeral_pubkey_hex=proof.p2pk_e,
                    blinded_pubkey_hex=blinded_pk,
                    slot_index=i,
                )
            except Exception:
                continue
            if derived is not None:
                return derived

This early-return logic assumes that any successfully derived key is the correct one to use. However, the scalar r_i depends on the slot index i, meaning each slot requires a distinct signature. If Alice is in the pubkeys tag (e.g. slot 0 or 1) and also the refund tag (slot 2), this loop will always return the derived key for the pubkeys slot and completely ignore the refund slot.

During a swap, signatures_proofs_sig_inputs requests a single signature per proof using whatever key _derive_p2bk_signing_key returned. If Alice tries to claim her refund, the mint evaluates the refund path and expects a signature matching the blinded_refund public key. However, the wallet provides a signature generated with the blinded_data key, causing the mint to reject the transaction (verify_schnorr_signature fails).

Attack Steps

  1. Alice creates a 2-of-2 multisig P2BK token locked to [Bob, Alice], with a locktime and a fallback refund path exclusively for [Alice].
  2. Bob becomes uncooperative or loses his key, making the main 2-of-2 spending path impossible.
  3. The locktime expires. Alice attempts to claim the tokens via the refund path.
  4. Alice's wallet calls _derive_p2bk_signing_key. Because Alice's key is also in the main pubkeys slot, it unblinds that slot first, returning k_data.
  5. The wallet signs the spend request with k_data.
  6. The mint checks the main path: it requires 2 signatures but only receives 1, so it fails and falls through to the refund path.
  7. The mint checks the refund path: it requires 1 signature matching Alice's blinded refund key. It receives the signature generated by k_data, which is mathematically invalid for the refund pubkey.
  8. The transaction is rejected. The tokens are permanently unspendable.

Impact

A permanent loss of funds in multisig setups where a party acts as both a co-signer and the ultimate recovery/refund key. This breaks the security guarantees of the refund path (NUT-11) when combined with P2BK (NUT-28).

Test Results

Executing the following script against the core primitives demonstrates that the derived key produces an invalid signature for the refund path:

from cashu.core.crypto.secp import PrivateKey, PublicKey
from cashu.core.p2bk import blind_pubkeys, derive_blinded_private_key
from cashu.core.p2pk import schnorr_sign, verify_schnorr_signature
import secrets

alice_priv = PrivateKey(secrets.token_bytes(32))
bob_pub = PrivateKey(secrets.token_bytes(32)).public_key.format(compressed=True).hex()
alice_pub = alice_priv.public_key.format(compressed=True).hex()

e = PrivateKey(secrets.token_bytes(32))
E = e.public_key.format(compressed=True).hex()

blinded_data, blinded_add, blinded_refund, _ = blind_pubkeys(
    data_pubkey=bob_pub, additional_pubkeys=[alice_pub], refund_pubkeys=[alice_pub], ephemeral_privkey=e
)

# Wallet unblinds
derived = None
all_blinded = [blinded_data, blinded_add[0], blinded_refund[0]]
for i, pk in enumerate(all_blinded):
    derived_k = derive_blinded_private_key(alice_priv, E, pk, i)
    if derived_k is not None:
        derived = derived_k
        break

# Wallet signs
msg = b"test"
sig = schnorr_sign(msg, derived)

# Mint verifies refund
mint_refund_pk = PublicKey(bytes.fromhex("02" + blinded_refund[0][2:] if len(blinded_refund[0])==64 else blinded_refund[0]))
print("Valid refund signature?", verify_schnorr_signature(msg, mint_refund_pk, sig))
# Output: False

Proposed Fix

The wallet must generate signatures for all slots it can successfully unblind, or explicitly target the refund path when the locktime has passed.

In WalletP2PK.signatures_proofs_sig_inputs, modify the logic to allow generating multiple signatures if the wallet holds multiple derived keys for a single proof:

def _derive_all_p2bk_signing_keys(self, proof: Proof) -> List[PrivateKey]:
    keys = []
    # ... iterate through all slots ...
    if derived is not None:
        keys.append(derived)
    return keys

Then, append signatures for all derived keys to the proof's witness so the mint can satisfy whatever path is currently active.

@ye0man ye0man requested review from a1denvalu3 and callebtc June 26, 2026 10:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Needs Review

Development

Successfully merging this pull request may close these issues.

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

4 participants