diff --git a/crates/cdk-cli/src/main.rs b/crates/cdk-cli/src/main.rs index a4146199d..a69a6b762 100644 --- a/crates/cdk-cli/src/main.rs +++ b/crates/cdk-cli/src/main.rs @@ -114,6 +114,8 @@ enum Commands { Resolve(sub_commands::resolve::ResolveSubCommand), /// Create Payment request CreateRequest(sub_commands::create_request::CreateRequestSubCommand), + /// Manage P2PK signing keys (generate, store, list, remove) + P2pk(sub_commands::p2pk::P2pkSubCommand), /// Mint blind auth proofs MintBlindAuth(sub_commands::mint_blind_auth::MintBlindAuthSubCommand), /// Cat login with username/password @@ -327,6 +329,9 @@ async fn main() -> Result<()> { ) .await } + Commands::P2pk(sub_command_args) => { + sub_commands::p2pk::p2pk(&wallet_repository, sub_command_args, ¤cy_unit).await + } Commands::MintBlindAuth(sub_command_args) => { sub_commands::mint_blind_auth::mint_blind_auth( &wallet_repository, diff --git a/crates/cdk-cli/src/sub_commands/mod.rs b/crates/cdk-cli/src/sub_commands/mod.rs index 4e614d83f..2b067d8e4 100644 --- a/crates/cdk-cli/src/sub_commands/mod.rs +++ b/crates/cdk-cli/src/sub_commands/mod.rs @@ -15,6 +15,7 @@ pub mod mint_blind_auth; pub mod mint_info; #[cfg(feature = "npubcash")] pub mod npubcash; +pub mod p2pk; pub mod pay_request; pub mod pending_mints; pub mod receive; diff --git a/crates/cdk-cli/src/sub_commands/p2pk.rs b/crates/cdk-cli/src/sub_commands/p2pk.rs new file mode 100644 index 000000000..5f1aceadc --- /dev/null +++ b/crates/cdk-cli/src/sub_commands/p2pk.rs @@ -0,0 +1,77 @@ +use std::str::FromStr; + +use anyhow::{anyhow, Result}; +use cdk::nuts::{CurrencyUnit, PublicKey, SecretKey}; +use cdk::wallet::WalletRepository; +use clap::{Args, Subcommand}; + +use crate::utils::get_or_create_wallet; + +#[derive(Args)] +pub struct P2pkSubCommand { + /// Mint URL (required to obtain a wallet context) + #[arg(short, long)] + mint_url: String, + #[command(subcommand)] + command: P2pkCommands, +} + +#[derive(Subcommand)] +pub enum P2pkCommands { + /// Generate a new P2PK signing key and store it in the wallet + Generate, + /// Store an existing P2PK signing key + Store { + /// Secret key in hex format + secret_key: String, + }, + /// List all stored P2PK signing keys (shows public keys) + List, + /// Remove a stored P2PK signing key by its public key + Remove { + /// Public key in hex format + pubkey: String, + }, +} + +pub async fn p2pk( + wallet_repository: &WalletRepository, + sub_command_args: &P2pkSubCommand, + unit: &CurrencyUnit, +) -> Result<()> { + let mint_url = cdk::mint_url::MintUrl::from_str(&sub_command_args.mint_url)?; + let wallet = get_or_create_wallet(wallet_repository, &mint_url, unit).await?; + + match &sub_command_args.command { + P2pkCommands::Generate => { + let pubkey = wallet.generate_p2pk_key().await?; + println!("Generated P2PK key:"); + println!(" Public key: {}", pubkey.to_hex()); + } + P2pkCommands::Store { secret_key } => { + let sk = + SecretKey::from_hex(secret_key).map_err(|e| anyhow!("Invalid secret key: {e}"))?; + let pubkey = wallet.store_p2pk_key(sk).await?; + println!("Stored P2PK key:"); + println!(" Public key: {}", pubkey.to_hex()); + } + P2pkCommands::List => { + let keys = wallet.get_p2pk_signing_keys().await?; + if keys.is_empty() { + println!("No P2PK signing keys stored."); + } else { + println!("Stored P2PK signing keys ({}):", keys.len()); + for xonly in keys.keys() { + println!(" {xonly}"); + } + } + } + P2pkCommands::Remove { pubkey } => { + let pk = PublicKey::from_hex(pubkey).map_err(|e| anyhow!("Invalid public key: {e}"))?; + wallet.remove_p2pk_key(&pk).await?; + println!("Removed P2PK key: {pubkey}"); + } + } + + Ok(()) +} diff --git a/crates/cdk-integration-tests/tests/test_p2pk_autosign.rs b/crates/cdk-integration-tests/tests/test_p2pk_autosign.rs new file mode 100644 index 000000000..7c4640a6f --- /dev/null +++ b/crates/cdk-integration-tests/tests/test_p2pk_autosign.rs @@ -0,0 +1,101 @@ +//! P2PK Auto-Sign Integration Tests +//! +//! Tests that stored P2PK signing keys are automatically used when receiving +//! tokens locked to those keys, without requiring manual key provision. + +use anyhow::Result; +use cdk::nuts::SpendingConditions; +use cdk::wallet::{ReceiveOptions, SendOptions}; +use cdk::Amount; +use cdk_integration_tests::init_pure_tests::*; + +/// Tests the full P2PK auto-sign flow: +/// 1. Receiver generates and stores a P2PK key +/// 2. Sender sends tokens locked to that key +/// 3. Receiver receives tokens WITHOUT providing signing keys manually +/// 4. Verifies balance increased (auto-sign worked) +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_p2pk_autosign_receive() -> Result<()> { + setup_tracing(); + let mint = create_and_start_test_mint().await?; + + let sender = create_test_wallet_for_mint(mint.clone()).await?; + let receiver = create_test_wallet_for_mint(mint.clone()).await?; + + // Fund sender + let fund_amount = 1000_u64; + fund_wallet(sender.clone(), fund_amount, None).await?; + + // Receiver generates and stores a P2PK key + let receiver_pubkey = receiver.generate_p2pk_key().await?; + + // Sender sends tokens locked to receiver's pubkey + let send_amount = Amount::from(500_u64); + let conditions = SpendingConditions::new_p2pk(receiver_pubkey, None); + let prepared = sender + .prepare_send( + send_amount, + SendOptions { + conditions: Some(conditions), + ..Default::default() + }, + ) + .await?; + let token = prepared.confirm(None).await?; + + // Receiver receives WITHOUT providing signing keys — should auto-sign + let received = receiver + .receive(&token.to_string(), ReceiveOptions::default()) + .await?; + + assert!(received > Amount::ZERO, "Should have received some amount"); + + // Verify receiver balance + let balance = receiver.total_balance().await?; + assert!( + balance > Amount::ZERO, + "Receiver balance should be positive" + ); + + Ok(()) +} + +/// Tests that receive fails when token is locked to an unknown key +/// and no signing keys are provided (negative case) +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_p2pk_receive_fails_without_stored_key() -> Result<()> { + setup_tracing(); + let mint = create_and_start_test_mint().await?; + + let sender = create_test_wallet_for_mint(mint.clone()).await?; + let receiver = create_test_wallet_for_mint(mint.clone()).await?; + + // Fund sender + fund_wallet(sender.clone(), 1000, None).await?; + + // Generate a key but DON'T store it in the receiver wallet + let secret_key = cdk::nuts::SecretKey::generate(); + let pubkey = secret_key.public_key(); + + // Send locked to that key + let conditions = SpendingConditions::new_p2pk(pubkey, None); + let prepared = sender + .prepare_send( + Amount::from(500_u64), + SendOptions { + conditions: Some(conditions), + ..Default::default() + }, + ) + .await?; + let token = prepared.confirm(None).await?; + + // Receiver tries to receive without any signing key — should fail + let result = receiver + .receive(&token.to_string(), ReceiveOptions::default()) + .await; + + assert!(result.is_err(), "Should fail without signing key"); + + Ok(()) +} diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index fff0756c2..ac5087515 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -49,6 +49,7 @@ mod mint_connector; mod mint_metadata_cache; #[cfg(feature = "npubcash")] mod npubcash; +mod p2pk_storage; pub mod payment_request; mod proofs; mod receive; diff --git a/crates/cdk/src/wallet/p2pk_storage.rs b/crates/cdk/src/wallet/p2pk_storage.rs new file mode 100644 index 000000000..fdb889e35 --- /dev/null +++ b/crates/cdk/src/wallet/p2pk_storage.rs @@ -0,0 +1,200 @@ +//! P2PK signing key storage for CDK Wallet +//! +//! This module provides persistent storage of P2PK signing keys using the +//! wallet's KV store. When receiving tokens locked to a stored key, the wallet +//! can automatically look up the private key and sign the proofs. + +use std::collections::HashMap; + +use bitcoin::XOnlyPublicKey; +use tracing::instrument; + +use crate::nuts::{PublicKey, SecretKey}; +use crate::wallet::Wallet; +use crate::{Error, SECP256K1}; + +/// KV store namespace for P2PK signing keys +const P2PK_KV_NAMESPACE: &str = "p2pk_signing_keys"; + +impl Wallet { + /// Generate a new P2PK signing key and store it in the wallet. + /// + /// Returns the public key corresponding to the generated secret key. + #[instrument(skip(self))] + pub async fn generate_p2pk_key(&self) -> Result { + let secret_key = SecretKey::generate(); + self.store_p2pk_key(secret_key).await + } + + /// Store an existing P2PK signing key in the wallet. + /// + /// Returns the public key corresponding to the stored secret key. + #[instrument(skip_all)] + pub async fn store_p2pk_key(&self, secret_key: SecretKey) -> Result { + let pubkey = secret_key.public_key(); + self.localstore + .kv_write( + P2PK_KV_NAMESPACE, + "", + &pubkey.to_hex(), + &secret_key.to_secret_bytes(), + ) + .await?; + tracing::info!("Stored P2PK signing key for pubkey {}", pubkey.to_hex()); + Ok(pubkey) + } + + /// Look up a single P2PK signing key by its public key. + #[instrument(skip(self))] + pub async fn get_p2pk_signing_key( + &self, + pubkey: &PublicKey, + ) -> Result, Error> { + let bytes = self + .localstore + .kv_read(P2PK_KV_NAMESPACE, "", &pubkey.to_hex()) + .await?; + match bytes { + Some(b) => Ok(Some(SecretKey::from_slice(&b)?)), + None => Ok(None), + } + } + + /// Load all stored P2PK signing keys, indexed by their x-only public key. + /// + /// This is used by the receive saga to automatically sign P2PK-locked proofs. + #[instrument(skip(self))] + pub async fn get_p2pk_signing_keys(&self) -> Result, Error> { + let key_hexes = self.localstore.kv_list(P2PK_KV_NAMESPACE, "").await?; + + let mut keys = HashMap::new(); + for hex in key_hexes { + let bytes = self.localstore.kv_read(P2PK_KV_NAMESPACE, "", &hex).await?; + if let Some(b) = bytes { + let sk = SecretKey::from_slice(&b)?; + let xonly = sk.x_only_public_key(&SECP256K1).0; + keys.insert(xonly, sk); + } + } + Ok(keys) + } + + /// Remove a stored P2PK signing key by its public key. + #[instrument(skip(self))] + pub async fn remove_p2pk_key(&self, pubkey: &PublicKey) -> Result<(), Error> { + self.localstore + .kv_remove(P2PK_KV_NAMESPACE, "", &pubkey.to_hex()) + .await?; + tracing::info!("Removed P2PK signing key for pubkey {}", pubkey.to_hex()); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::wallet::test_utils::{create_test_db, create_test_wallet}; + + use super::*; + + #[tokio::test] + async fn test_generate_and_retrieve_p2pk_key() { + let db = create_test_db().await; + let wallet = create_test_wallet(db).await; + + let pubkey = wallet.generate_p2pk_key().await.unwrap(); + + let retrieved = wallet.get_p2pk_signing_key(&pubkey).await.unwrap(); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().public_key(), pubkey); + } + + #[tokio::test] + async fn test_store_and_retrieve_p2pk_key() { + let db = create_test_db().await; + let wallet = create_test_wallet(db).await; + + let secret_key = SecretKey::generate(); + let expected_pubkey = secret_key.public_key(); + + let pubkey = wallet.store_p2pk_key(secret_key.clone()).await.unwrap(); + assert_eq!(pubkey, expected_pubkey); + + let retrieved = wallet.get_p2pk_signing_key(&pubkey).await.unwrap(); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().public_key(), expected_pubkey); + } + + #[tokio::test] + async fn test_get_nonexistent_key_returns_none() { + let db = create_test_db().await; + let wallet = create_test_wallet(db).await; + + let random_pubkey = SecretKey::generate().public_key(); + let result = wallet.get_p2pk_signing_key(&random_pubkey).await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_list_p2pk_keys() { + let db = create_test_db().await; + let wallet = create_test_wallet(db).await; + + let pk1 = wallet.generate_p2pk_key().await.unwrap(); + let pk2 = wallet.generate_p2pk_key().await.unwrap(); + + let keys = wallet.get_p2pk_signing_keys().await.unwrap(); + assert_eq!(keys.len(), 2); + assert!(keys.contains_key(&pk1.x_only_public_key())); + assert!(keys.contains_key(&pk2.x_only_public_key())); + } + + #[tokio::test] + async fn test_remove_p2pk_key() { + let db = create_test_db().await; + let wallet = create_test_wallet(db).await; + + let pubkey = wallet.generate_p2pk_key().await.unwrap(); + assert!(wallet + .get_p2pk_signing_key(&pubkey) + .await + .unwrap() + .is_some()); + + wallet.remove_p2pk_key(&pubkey).await.unwrap(); + assert!(wallet + .get_p2pk_signing_key(&pubkey) + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn test_store_overwrites_existing_key() { + let db = create_test_db().await; + let wallet = create_test_wallet(db).await; + + let sk1 = SecretKey::generate(); + let pubkey = sk1.public_key(); + + wallet.store_p2pk_key(sk1).await.unwrap(); + + // Store a different key — but same pubkey is impossible (different sk = different pk) + // So we just verify re-storing the same key works + let sk2 = SecretKey::generate(); + let pubkey2 = wallet.store_p2pk_key(sk2.clone()).await.unwrap(); + + let keys = wallet.get_p2pk_signing_keys().await.unwrap(); + assert_eq!(keys.len(), 2); + assert!(keys.contains_key(&pubkey.x_only_public_key())); + assert!(keys.contains_key(&pubkey2.x_only_public_key())); + } + + #[tokio::test] + async fn test_empty_list() { + let db = create_test_db().await; + let wallet = create_test_wallet(db).await; + + let keys = wallet.get_p2pk_signing_keys().await.unwrap(); + assert!(keys.is_empty()); + } +} diff --git a/crates/cdk/src/wallet/receive/saga/mod.rs b/crates/cdk/src/wallet/receive/saga/mod.rs index 908f6f9bc..81da93a35 100644 --- a/crates/cdk/src/wallet/receive/saga/mod.rs +++ b/crates/cdk/src/wallet/receive/saga/mod.rs @@ -35,7 +35,6 @@ use std::collections::HashMap; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; -use bitcoin::XOnlyPublicKey; use cdk_common::util::unix_time; use cdk_common::wallet::{ OperationData, ProofInfo, ReceiveOperationData, ReceiveSagaState, Transaction, @@ -49,7 +48,7 @@ use super::ReceiveOptions; use crate::dhke::construct_proofs; use crate::nuts::nut00::ProofsMethods; use crate::nuts::nut10::Kind; -use crate::nuts::{Conditions, Proofs, PublicKey, SecretKey, SigFlag, State}; +use crate::nuts::{Conditions, Proofs, PublicKey, SigFlag, State}; use crate::util::hex; use crate::wallet::saga::{ add_compensation, clear_compensations, execute_compensations, new_compensations, Compensations, @@ -124,11 +123,15 @@ impl<'a> ReceiveSaga<'a, Initial> { }) .collect::, _>>()?; - let p2pk_signing_keys: HashMap = opts - .p2pk_signing_keys - .iter() - .map(|s| (s.x_only_public_key(&SECP256K1).0, s)) - .collect(); + // Load stored P2PK signing keys, then merge with manually provided keys + // (manual keys take priority over stored keys) + let mut p2pk_signing_keys = self.wallet.get_p2pk_signing_keys().await?; + for manual_key in &opts.p2pk_signing_keys { + p2pk_signing_keys.insert( + manual_key.x_only_public_key(&SECP256K1).0, + manual_key.clone(), + ); + } // Process each proof: verify DLEQ, handle P2PK/HTLC for proof in &mut proofs { @@ -203,7 +206,7 @@ impl<'a> ReceiveSaga<'a, Initial> { } else if let Some(signing) = p2pk_signing_keys.get(&pubkey.x_only_public_key()) { - proof.sign_p2pk(signing.to_owned().clone())?; + proof.sign_p2pk(signing.clone())?; } } @@ -326,19 +329,20 @@ impl<'a> ReceiveSaga<'a, Prepared> { // Determine if SigAll signing is needed let sig_flag = self.determine_sig_flag()?; if sig_flag == SigFlag::SigAll { - let p2pk_signing_keys: HashMap = self - .state_data - .options - .p2pk_signing_keys - .iter() - .map(|s| (s.x_only_public_key(&SECP256K1).0, s)) - .collect(); + // Load stored P2PK signing keys and merge with manually provided keys + let mut p2pk_signing_keys = self.wallet.get_p2pk_signing_keys().await?; + for manual_key in &self.state_data.options.p2pk_signing_keys { + p2pk_signing_keys.insert( + manual_key.x_only_public_key(&SECP256K1).0, + manual_key.clone(), + ); + } for blinded_message in pre_swap.swap_request.outputs_mut() { for signing_key in p2pk_signing_keys.values() { // Sign the outputs of the swap using standard P2PK since output // P2BK requires ephemeral keys which is handled at creation. - blinded_message.sign_p2pk((**signing_key).clone())? + blinded_message.sign_p2pk(signing_key.clone())? } } }