feat: P2BK #1253

Merged
a1denvalu3 merged 23 commits from blinded-p2pk into main 2026-03-06 13:25:40 +00:00
a1denvalu3 commented 2025-11-04 12:48:55 +00:00 (Migrated from github.com)

Pay-to-Blinded-Key (P2BK) Implementation for Enhanced Privacy

Overview

This PR implements NUT-28: Pay-to-Blinded-Key, a privacy enhancement for P2PK (NUT-11) transactions that enables "silent payments" in Cashu. P2BK allows tokens to be locked to a public key without exposing which public key they're locked to, even to the mint itself.

Key Features

  • Enhanced Privacy: Tokens can be sent to recipients without the mint learning the recipient's public key
  • Canonical Slot Mapping: Supports multi-key proofs with proper slot organization
  • Backward Compatible: Works with existing mints without requiring protocol changes on the mint side

Technical Implementation

  • ECDH Key Derivation: Uses Elliptic Curve Diffie-Hellman to derive shared secrets between sender and receiver
  • Blinding Operations: Implements cryptographic blinding of public keys using random scalar multiplication
  • Key Recovery: Enables receivers to recover the correct signing key through the shared ECDH secret
  • Validation: Includes comprehensive tests for key derivation, blinding, and signing operations

User-Facing Changes

  • Added use_p2bk option to the wallet's send operations to toggle P2BK functionality
  • When enabled, all P2PK transactions will use blinded keys for enhanced privacy

Checklist

# Pay-to-Blinded-Key (P2BK) Implementation for Enhanced Privacy ## Overview This PR implements [NUT-28: Pay-to-Blinded-Key](https://github.com/cashubtc/nuts/blob/main/28.md), a privacy enhancement for P2PK (NUT-11) transactions that enables "silent payments" in Cashu. P2BK allows tokens to be locked to a public key without exposing which public key they're locked to, even to the mint itself. ## Key Features - **Enhanced Privacy**: Tokens can be sent to recipients without the mint learning the recipient's public key - **Canonical Slot Mapping**: Supports multi-key proofs with proper slot organization - **Backward Compatible**: Works with existing mints without requiring protocol changes on the mint side ## Technical Implementation - **ECDH Key Derivation**: Uses Elliptic Curve Diffie-Hellman to derive shared secrets between sender and receiver - **Blinding Operations**: Implements cryptographic blinding of public keys using random scalar multiplication - **Key Recovery**: Enables receivers to recover the correct signing key through the shared ECDH secret - **Validation**: Includes comprehensive tests for key derivation, blinding, and signing operations ## User-Facing Changes - Added `use_p2bk` option to the wallet's send operations to toggle P2BK functionality - When enabled, all P2PK transactions will use blinded keys for enhanced privacy ---- ### Checklist * [ ] I followed the [code style guidelines](https://github.com/cashubtc/cdk/blob/main/CODE_STYLE.md) * [ ] I ran `just final-check` before committing
crodas (Migrated from github.com) reviewed 2025-11-05 15:50:42 +00:00
crodas (Migrated from github.com) commented 2025-11-05 15:50:41 +00:00

Is there a way to do Option<&dk_common::nuts::nut11::Conditions>

Is there a way to do `Option<&dk_common::nuts::nut11::Conditions>`
thesimplekid (Migrated from github.com) reviewed 2025-11-13 14:04:59 +00:00
thesimplekid (Migrated from github.com) left a comment

Nice just noticed one thing.

Nice just noticed one thing.
thesimplekid (Migrated from github.com) commented 2025-11-13 13:58:01 +00:00
                if let Some(pubkeys) = conditions.pubkeys {
                    let mut blinded_pubkeys: Vec<PublicKey> = Vec::with_capacity(pubkeys.len());

                    // Blind each additional pubkey using slots 1 through N
                    for (idx, pubkey) in pubkeys.iter().enumerate() {
                        let slot = (idx + current_idx) as u8;
                        if slot > 10 {
                            tracing::warn!(
                                "Too many pubkeys to blind (max 10 slots), skipping rest"
                            );
                            break;
                        }



                        // Derive blinding scalar for this pubkey
                        let add_blinding_scalar =
                            ecdh_kdf(&ephemeral_key, pubkey, keyset_id, slot)?;

                        // Blind the additional pubkey
                        let blinded_pubkey = blind_public_key(pubkey, &add_blinding_scalar)?;
                        blinded_pubkeys.push(blinded_pubkey);
                    }
                    
                    current_idx += blinded_pubkeys.len(); 
                    blinded_conditions.pubkeys = Some(blinded_pubkeys);
                }

I think we're updating the index in the wrong spot here and it should only be done after the loop so the refund can see it. The pubkeys gets increment by idx incrementing it in the loop causes a slot to get skipped. See the below test that fails with the current implementation but passes with the suggested fix.

    #[test]
    fn test_slot_numbers_are_consecutive() {
        let keyset_id = Id::from_str("009a1f293253e41e").unwrap();

        // Create 3 different keys
        let key1 = SecretKey::generate().public_key();
        let key2 = SecretKey::generate().public_key();
        let key3 = SecretKey::generate().public_key();

        let ephemeral_sk = SecretKey::generate();

        let conditions = SpendingConditions::P2PKConditions {
            data: key1,
            conditions: Some(Conditions {
                pubkeys: Some(vec![key2, key3]),
                refund_keys: None,
                num_sigs: Some(1),
                sig_flag: SigFlag::SigInputs,
                locktime: None,
                num_sigs_refund: None,
            }),
        };

        let (_, blinded) =
            PreMintSecrets::apply_p2bk(conditions, keyset_id, Some(ephemeral_sk.clone())).unwrap();

        // Extract blinded keys
        let (blinded_key1, blinded_others) = match blinded {
            SpendingConditions::P2PKConditions { data, conditions } => {
                (data, conditions.unwrap().pubkeys.unwrap())
            }
            _ => panic!("Wrong type"),
        };

        // For each slot, try to derive and see if it matches
        // Slot 0 should match key1
        let r0 = ecdh_kdf(&ephemeral_sk, &key1, keyset_id, 0).unwrap();
        let test0 = blind_public_key(&key1, &r0).unwrap();
        assert_eq!(blinded_key1, test0, "Slot 0 should be key1");

        // Slot 1 should match key2
        let r1 = ecdh_kdf(&ephemeral_sk, &key2, keyset_id, 1).unwrap();
        let test1 = blind_public_key(&key2, &r1).unwrap();
        assert_eq!(blinded_others[0], test1, "Slot 1 should be key2");

        // Slot 2 should match key3 (FAILS with buggy code - it uses slot 3!)
        let r2 = ecdh_kdf(&ephemeral_sk, &key3, keyset_id, 2).unwrap();
        let test2 = blind_public_key(&key3, &r2).unwrap();
        assert_eq!(blinded_others[1], test2, "Slot 2 should be key3");
    }
```suggestion if let Some(pubkeys) = conditions.pubkeys { let mut blinded_pubkeys: Vec<PublicKey> = Vec::with_capacity(pubkeys.len()); // Blind each additional pubkey using slots 1 through N for (idx, pubkey) in pubkeys.iter().enumerate() { let slot = (idx + current_idx) as u8; if slot > 10 { tracing::warn!( "Too many pubkeys to blind (max 10 slots), skipping rest" ); break; } // Derive blinding scalar for this pubkey let add_blinding_scalar = ecdh_kdf(&ephemeral_key, pubkey, keyset_id, slot)?; // Blind the additional pubkey let blinded_pubkey = blind_public_key(pubkey, &add_blinding_scalar)?; blinded_pubkeys.push(blinded_pubkey); } current_idx += blinded_pubkeys.len(); blinded_conditions.pubkeys = Some(blinded_pubkeys); } ``` I think we're updating the index in the wrong spot here and it should only be done after the loop so the refund can see it. The pubkeys gets increment by idx incrementing it in the loop causes a slot to get skipped. See the below test that fails with the current implementation but passes with the suggested fix. ```rust #[test] fn test_slot_numbers_are_consecutive() { let keyset_id = Id::from_str("009a1f293253e41e").unwrap(); // Create 3 different keys let key1 = SecretKey::generate().public_key(); let key2 = SecretKey::generate().public_key(); let key3 = SecretKey::generate().public_key(); let ephemeral_sk = SecretKey::generate(); let conditions = SpendingConditions::P2PKConditions { data: key1, conditions: Some(Conditions { pubkeys: Some(vec![key2, key3]), refund_keys: None, num_sigs: Some(1), sig_flag: SigFlag::SigInputs, locktime: None, num_sigs_refund: None, }), }; let (_, blinded) = PreMintSecrets::apply_p2bk(conditions, keyset_id, Some(ephemeral_sk.clone())).unwrap(); // Extract blinded keys let (blinded_key1, blinded_others) = match blinded { SpendingConditions::P2PKConditions { data, conditions } => { (data, conditions.unwrap().pubkeys.unwrap()) } _ => panic!("Wrong type"), }; // For each slot, try to derive and see if it matches // Slot 0 should match key1 let r0 = ecdh_kdf(&ephemeral_sk, &key1, keyset_id, 0).unwrap(); let test0 = blind_public_key(&key1, &r0).unwrap(); assert_eq!(blinded_key1, test0, "Slot 0 should be key1"); // Slot 1 should match key2 let r1 = ecdh_kdf(&ephemeral_sk, &key2, keyset_id, 1).unwrap(); let test1 = blind_public_key(&key2, &r1).unwrap(); assert_eq!(blinded_others[0], test1, "Slot 1 should be key2"); // Slot 2 should match key3 (FAILS with buggy code - it uses slot 3!) let r2 = ecdh_kdf(&ephemeral_sk, &key3, keyset_id, 2).unwrap(); let test2 = blind_public_key(&key3, &r2).unwrap(); assert_eq!(blinded_others[1], test2, "Slot 2 should be key3"); } ```
@ -0,0 +37,4 @@
use crate::nuts::nut01::{PublicKey, SecretKey};
// Create a static SECP256K1 context that we'll use for operations
static SECP: LazyLock<Secp256k1<bitcoin::secp256k1::All>> = LazyLock::new(Secp256k1::new);
thesimplekid (Migrated from github.com) commented 2025-11-13 13:08:16 +00:00

Not really a comment on this PR but more note to sell. Currently we use secp from bitcoin whitch is an older version then available if we had it as a direct dep, in newer version they removed the context so we should not have to do this. I'm not sure if we use anything else from bitcoin (besides hashes) and should keep doing it this way or we just use it to get secp and should use use that directly.

Not really a comment on this PR but more note to sell. Currently we use secp from bitcoin whitch is an older version then available if we had it as a direct dep, in newer version they removed the context so we should not have to do this. I'm not sure if we use anything else from bitcoin (besides hashes) and should keep doing it this way or we just use it to get secp and should use use that directly.
a1denvalu3 (Migrated from github.com) reviewed 2025-11-14 14:42:35 +00:00
a1denvalu3 (Migrated from github.com) commented 2025-11-14 14:42:35 +00:00

I've fixed this issue and included the test

I've fixed this issue and included the test
robwoodgate commented 2026-01-12 10:13:50 +00:00 (Migrated from github.com)

Please note: keyset id has now been removed from the blinding factor calculation in the NUT spec.
Is also now tentatively renamed NUT-28, as BECH32 PR was merged first.

**Please note:** keyset id has now been removed from the blinding factor calculation in the NUT spec. Is also now tentatively renamed NUT-28, as BECH32 PR was merged first.
robwoodgate (Migrated from github.com) reviewed 2026-01-14 14:34:41 +00:00
robwoodgate (Migrated from github.com) commented 2026-01-14 14:34:42 +00:00

Payment request support has been removed from the spec for now

Payment request support has been removed from the spec for now
robwoodgate (Migrated from github.com) reviewed 2026-01-14 14:37:23 +00:00
@ -0,0 +1,207 @@
//! # Pay-to-Blinded-Key (P2BK) Implementation
robwoodgate (Migrated from github.com) commented 2026-01-14 14:37:23 +00:00

keyset_id was removed in latest spec

keyset_id was removed in latest spec
thesimplekid (Migrated from github.com) reviewed 2026-03-05 12:44:07 +00:00
@ -150,7 +150,7 @@ impl<'a> ReceiveSaga<'a, Initial> {
.unwrap_or_default()
thesimplekid (Migrated from github.com) commented 2026-03-05 12:25:37 +00:00

Some ai comments we should remove

Some ai comments we should remove
thesimplekid (Migrated from github.com) commented 2026-03-05 12:27:23 +00:00

We're in the wallet mod so I don't think we need the feature flag here.

We're in the wallet mod so I don't think we need the feature flag here.
@ -301,2 +337,4 @@
// P2BK requires ephemeral keys which is handled at creation.
blinded_message.sign_p2pk((**signing_key).clone())?
}
}
thesimplekid (Migrated from github.com) commented 2026-03-05 12:27:43 +00:00
```suggestion ```
thesimplekid (Migrated from github.com) commented 2026-03-05 12:32:26 +00:00

We should probably put the new argument at the end. It maybe worth creating a SwapOptions struct to reduce the number or augments on the swap fn, but that can be a separate pr.

We should probably put the new argument at the end. It maybe worth creating a SwapOptions struct to reduce the number or augments on the swap fn, but that can be a separate pr.
thesimplekid (Migrated from github.com) commented 2026-03-05 12:33:20 +00:00

Should we add the SecretKeys to the PreSwap struct instead of a tuple?

Should we add the SecretKeys to the PreSwap struct instead of a tuple?
a1denvalu3 (Migrated from github.com) reviewed 2026-03-05 12:45:36 +00:00
@ -150,7 +150,7 @@ impl<'a> ReceiveSaga<'a, Initial> {
.unwrap_or_default()
a1denvalu3 (Migrated from github.com) commented 2026-03-05 12:45:35 +00:00

yes

yes
thesimplekid (Migrated from github.com) approved these changes 2026-03-06 13:25:11 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
cashubtc/cdk!1253
No description provided.