From 6517e2a048210626f6324021d681545a02d5b4b1 Mon Sep 17 00:00:00 2001 From: yashbhutwala Date: Mon, 8 Sep 2025 12:03:25 -0400 Subject: [PATCH 1/9] feat(wallet,sql): P2PK key storage; autosign --- crates/cdk-common/src/database/wallet.rs | 21 +++++- .../20250908090000_p2pk_signing_key.sql | 7 ++ .../20250908090000_p2pk_signing_key.sql | 7 ++ crates/cdk-sql-common/src/wallet/mod.rs | 74 +++++++++++++++++++ crates/cdk/src/wallet/mod.rs | 22 ++++++ crates/cdk/src/wallet/receive.rs | 32 ++++++-- 6 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 crates/cdk-sql-common/src/wallet/migrations/postgres/20250908090000_p2pk_signing_key.sql create mode 100644 crates/cdk-sql-common/src/wallet/migrations/sqlite/20250908090000_p2pk_signing_key.sql diff --git a/crates/cdk-common/src/database/wallet.rs b/crates/cdk-common/src/database/wallet.rs index 0619ad3869..4629a0d39b 100644 --- a/crates/cdk-common/src/database/wallet.rs +++ b/crates/cdk-common/src/database/wallet.rs @@ -10,7 +10,7 @@ use super::Error; use crate::common::ProofInfo; use crate::mint_url::MintUrl; use crate::nuts::{ - CurrencyUnit, Id, KeySetInfo, Keys, MintInfo, PublicKey, SpendingConditions, State, + CurrencyUnit, Id, KeySetInfo, Keys, MintInfo, PublicKey, SecretKey, SpendingConditions, State, }; use crate::wallet::{ self, MintQuote as WalletMintQuote, Transaction, TransactionDirection, TransactionId, @@ -118,4 +118,23 @@ pub trait Database: Debug { ) -> Result, Self::Err>; /// Remove transaction from storage async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), Self::Err>; + + // --- P2PK signing key storage --- + /// Store a P2PK signing key. Implementations should upsert by derived pubkey. + /// Default no-op for backends that don't support it. + async fn add_p2pk_key(&self, _secret_key: SecretKey) -> Result<(), Self::Err> { + Ok(()) + } + /// Get a stored P2PK secret key by pubkey. Default returns None. + async fn get_p2pk_key(&self, _pubkey: PublicKey) -> Result, Self::Err> { + Ok(None) + } + /// List all stored P2PK signing keys. Default returns empty list. + async fn list_p2pk_keys(&self) -> Result, Self::Err> { + Ok(vec![]) + } + /// Remove a stored P2PK signing key by pubkey. Default no-op. + async fn remove_p2pk_key(&self, _pubkey: PublicKey) -> Result<(), Self::Err> { + Ok(()) + } } diff --git a/crates/cdk-sql-common/src/wallet/migrations/postgres/20250908090000_p2pk_signing_key.sql b/crates/cdk-sql-common/src/wallet/migrations/postgres/20250908090000_p2pk_signing_key.sql new file mode 100644 index 0000000000..c31dbb1ff9 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/postgres/20250908090000_p2pk_signing_key.sql @@ -0,0 +1,7 @@ +-- Store P2PK signing keys for automatic signing on receive +CREATE TABLE IF NOT EXISTS p2pk_signing_key ( + pubkey BYTEA PRIMARY KEY, + secret_key BYTEA NOT NULL, + created_time BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW())::BIGINT) +); + diff --git a/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250908090000_p2pk_signing_key.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250908090000_p2pk_signing_key.sql new file mode 100644 index 0000000000..d98364c1fd --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250908090000_p2pk_signing_key.sql @@ -0,0 +1,7 @@ +-- Store P2PK signing keys for automatic signing on receive +CREATE TABLE IF NOT EXISTS p2pk_signing_key ( + pubkey BLOB PRIMARY KEY, + secret_key BLOB NOT NULL, + created_time INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + diff --git a/crates/cdk-sql-common/src/wallet/mod.rs b/crates/cdk-sql-common/src/wallet/mod.rs index 8e2817d24d..b0424259dc 100644 --- a/crates/cdk-sql-common/src/wallet/mod.rs +++ b/crates/cdk-sql-common/src/wallet/mod.rs @@ -652,6 +652,80 @@ ON CONFLICT(id) DO UPDATE SET Ok(()) } + // --- P2PK signing key storage --- + async fn add_p2pk_key(&self, secret_key: SecretKey) -> Result<(), Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + let pubkey = secret_key.public_key(); + + query( + r#" +INSERT INTO p2pk_signing_key + (pubkey, secret_key) +VALUES + (:pubkey, :secret_key) +ON CONFLICT(pubkey) DO UPDATE SET + secret_key = excluded.secret_key +; "#, + )? + .bind("pubkey", pubkey.to_bytes().to_vec()) + .bind("secret_key", secret_key.to_secret_bytes().to_vec()) + .execute(&*conn) + .await?; + + Ok(()) + } + + async fn get_p2pk_key(&self, pubkey: PublicKey) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + Ok(query( + r#" + SELECT secret_key + FROM p2pk_signing_key + WHERE pubkey = :pubkey + "#, + )? + .bind("pubkey", pubkey.to_bytes().to_vec()) + .pluck(&*conn) + .await? + .map(|sk| { + let bytes = column_as_binary!(sk); + SecretKey::from_slice(&bytes).map_err(Error::from) + }) + .transpose()?) + } + + async fn list_p2pk_keys(&self) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + Ok(query( + r#" + SELECT secret_key + FROM p2pk_signing_key + "#, + )? + .fetch_all(&*conn) + .await? + .into_iter() + .map(|row| { + let bytes = column_as_binary!(row.first().unwrap()); + SecretKey::from_slice(&bytes).map_err(Error::from) + }) + .collect::, _>>()?) + } + + async fn remove_p2pk_key(&self, pubkey: PublicKey) -> Result<(), Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + query(r#"DELETE FROM p2pk_signing_key WHERE pubkey = :pubkey"#)? + .bind("pubkey", pubkey.to_bytes().to_vec()) + .execute(&*conn) + .await?; + + Ok(()) + } + #[instrument(skip_all)] async fn add_keys(&self, keyset: KeySet) -> Result<(), Self::Err> { let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index c2adec46e9..dc639f77c5 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -669,6 +669,28 @@ impl Wallet { Ok(()) } + + /// Add a P2PK signing key to the wallet's local store + pub async fn add_p2pk_signing_key( + &self, + secret_key: crate::nuts::SecretKey, + ) -> Result<(), Error> { + self.localstore.add_p2pk_key(secret_key).await?; + Ok(()) + } + + /// Generate a new P2PK signing key, store it, and return its public key + pub async fn generate_p2pk_signing_key(&self) -> Result { + let sk = crate::nuts::SecretKey::generate(); + let pk = sk.public_key(); + self.localstore.add_p2pk_key(sk).await?; + Ok(pk) + } + + /// List stored P2PK signing keys + pub async fn list_p2pk_signing_keys(&self) -> Result, Error> { + self.localstore.list_p2pk_keys().await.map_err(Into::into) + } } impl Drop for Wallet { diff --git a/crates/cdk/src/wallet/receive.rs b/crates/cdk/src/wallet/receive.rs index 2d0334b4fd..8749f62b28 100644 --- a/crates/cdk/src/wallet/receive.rs +++ b/crates/cdk/src/wallet/receive.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::str::FromStr; use bitcoin::hashes::sha256::Hash as Sha256Hash; @@ -51,9 +51,29 @@ impl Wallet { }) .collect::, _>>()?; - let p2pk_signing_keys: HashMap = opts - .p2pk_signing_keys - .iter() + // Build map of X-only pubkey -> SecretKey from stored keys and provided options + let mut merged_keys: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + // Add keys explicitly provided in options first + for sk in &opts.p2pk_signing_keys { + let x = sk.x_only_public_key(&SECP256K1).0; + if seen.insert(x) { + merged_keys.push(sk.clone()); + } + } + + // Merge in any keys stored in the wallet database + let stored_keys = self.localstore.list_p2pk_keys().await.unwrap_or_default(); + for sk in stored_keys { + let x = sk.x_only_public_key(&SECP256K1).0; + if seen.insert(x) { + merged_keys.push(sk); + } + } + + let p2pk_signing_keys: HashMap = merged_keys + .into_iter() .map(|s| (s.x_only_public_key(&SECP256K1).0, s)) .collect(); @@ -95,7 +115,7 @@ impl Wallet { } for pubkey in pubkeys { 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())?; } } @@ -123,7 +143,7 @@ impl Wallet { if sig_flag.eq(&SigFlag::SigAll) { for blinded_message in pre_swap.swap_request.outputs_mut() { for signing_key in p2pk_signing_keys.values() { - blinded_message.sign_p2pk(signing_key.to_owned().clone())? + blinded_message.sign_p2pk(signing_key.clone())? } } } From 67eba653c570f0077265125de02fda215e5de989 Mon Sep 17 00:00:00 2001 From: yashbhutwala Date: Mon, 8 Sep 2025 12:17:02 -0400 Subject: [PATCH 2/9] test(cdk-integration-tests): P2PK autosign receive (sqlite) --- .../tests/p2pk_autosign.rs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 crates/cdk-integration-tests/tests/p2pk_autosign.rs diff --git a/crates/cdk-integration-tests/tests/p2pk_autosign.rs b/crates/cdk-integration-tests/tests/p2pk_autosign.rs new file mode 100644 index 0000000000..1140d70818 --- /dev/null +++ b/crates/cdk-integration-tests/tests/p2pk_autosign.rs @@ -0,0 +1,63 @@ +//! Focused test: auto-sign P2PK receive using SQLite memory wallet DB + +use cdk::amount::SplitTarget; +use cdk::nuts::{SecretKey, SpendingConditions}; +use cdk::wallet::{ReceiveOptions, SendOptions}; +use cdk_integration_tests::init_pure_tests::{ + create_and_start_test_mint, create_test_wallet_for_mint, fund_wallet, setup_tracing, +}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_autosign_receive_with_sqlite_memory_db() { + setup_tracing(); + + // Create in-process mint and wallet (wallet DB type provided via CDK_TEST_DB_TYPE) + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint) + .await + .expect("Failed to create test wallet"); + + // Fund wallet with some amount to create a P2PK-locked token to self + let _ = fund_wallet(wallet.clone(), 100, Some(SplitTarget::default())) + .await + .expect("Failed to fund wallet"); + + // Generate and store a P2PK signing key in the wallet DB + // Keep the secret locally to craft the spending condition + let signing_sk = SecretKey::generate(); + let signing_pk = signing_sk.public_key(); + wallet + .add_p2pk_signing_key(signing_sk.clone()) + .await + .expect("Failed to store P2PK signing key"); + + // Create a token locked to the stored P2PK public key + let spending = SpendingConditions::new_p2pk(signing_pk, None); + let prepared = wallet + .prepare_send( + 10u64.into(), + SendOptions { + conditions: Some(spending), + include_fee: true, + ..Default::default() + }, + ) + .await + .expect("Failed to prepare send"); + let expected_received = 10u64 - u64::from(prepared.fee()); + let token = prepared + .confirm(None) + .await + .expect("Failed to finalize send"); + + // Receive without providing any p2pk_signing_keys in options. + // This should auto-sign using the key stored in the wallet DB. + let received = wallet + .receive(&token.to_string(), ReceiveOptions::default()) + .await + .expect("Receive should auto-sign and succeed"); + + assert_eq!(u64::from(received), expected_received); +} From 3fbc42c7ca02d25ebb1e2f7f0003c93b904267b3 Mon Sep 17 00:00:00 2001 From: yashbhutwala Date: Mon, 8 Sep 2025 12:19:36 -0400 Subject: [PATCH 3/9] test(cdk-integration-tests): P2PK autosign negative case --- .../tests/p2pk_autosign.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/cdk-integration-tests/tests/p2pk_autosign.rs b/crates/cdk-integration-tests/tests/p2pk_autosign.rs index 1140d70818..9e72d79acf 100644 --- a/crates/cdk-integration-tests/tests/p2pk_autosign.rs +++ b/crates/cdk-integration-tests/tests/p2pk_autosign.rs @@ -61,3 +61,55 @@ async fn test_autosign_receive_with_sqlite_memory_db() { assert_eq!(u64::from(received), expected_received); } + +/// Negative case: receive should fail when no signing key is provided or stored +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_receive_fails_without_signing_key() { + setup_tracing(); + + // Fresh mint and wallet; do NOT store any P2PK signing keys + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint) + .await + .expect("Failed to create test wallet"); + + // Fund wallet and create a token locked to a brand new key (unknown to wallet DB) + let _ = fund_wallet(wallet.clone(), 100, Some(SplitTarget::default())) + .await + .expect("Failed to fund wallet"); + + let locking_sk = SecretKey::generate(); + let spending = SpendingConditions::new_p2pk(locking_sk.public_key(), None); + + let prepared = wallet + .prepare_send( + 10u64.into(), + SendOptions { + conditions: Some(spending), + include_fee: true, + ..Default::default() + }, + ) + .await + .expect("Failed to prepare send"); + let token = prepared + .confirm(None) + .await + .expect("Failed to finalize send"); + + // Attempt to receive without providing any keys and none stored → should fail + let res = wallet + .receive(&token.to_string(), ReceiveOptions::default()) + .await; + + match res { + Ok(_) => panic!("Receive unexpectedly succeeded without signing key"), + Err(e) => match e { + // Expect signature-related error at the mint + cdk::Error::NUT11(cdk::nuts::nut11::Error::SignaturesNotProvided) => (), + other => panic!("Unexpected error: {:?}", other), + }, + } +} From 5a8dc5f42284716b6b65efb4d94c43bfdc264aec Mon Sep 17 00:00:00 2001 From: Yash Bhutwala Date: Tue, 16 Sep 2025 16:47:09 -0400 Subject: [PATCH 4/9] feat(wallet-db): persist p2pk keys --- crates/cdk-common/src/database/wallet.rs | 25 ++---- crates/cdk-ffi/src/database.rs | 97 ++++++++++++++++++++++++ crates/cdk-ffi/src/types.rs | 27 +++++++ crates/cdk-redb/src/wallet/migrations.rs | 18 ++++- crates/cdk-redb/src/wallet/mod.rs | 88 ++++++++++++++++++++- crates/cdk-sql-common/src/wallet/mod.rs | 15 ++-- crates/cdk/src/wallet/mod.rs | 4 +- 7 files changed, 246 insertions(+), 28 deletions(-) diff --git a/crates/cdk-common/src/database/wallet.rs b/crates/cdk-common/src/database/wallet.rs index 4629a0d39b..89067382ed 100644 --- a/crates/cdk-common/src/database/wallet.rs +++ b/crates/cdk-common/src/database/wallet.rs @@ -120,21 +120,12 @@ pub trait Database: Debug { async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), Self::Err>; // --- P2PK signing key storage --- - /// Store a P2PK signing key. Implementations should upsert by derived pubkey. - /// Default no-op for backends that don't support it. - async fn add_p2pk_key(&self, _secret_key: SecretKey) -> Result<(), Self::Err> { - Ok(()) - } - /// Get a stored P2PK secret key by pubkey. Default returns None. - async fn get_p2pk_key(&self, _pubkey: PublicKey) -> Result, Self::Err> { - Ok(None) - } - /// List all stored P2PK signing keys. Default returns empty list. - async fn list_p2pk_keys(&self) -> Result, Self::Err> { - Ok(vec![]) - } - /// Remove a stored P2PK signing key by pubkey. Default no-op. - async fn remove_p2pk_key(&self, _pubkey: PublicKey) -> Result<(), Self::Err> { - Ok(()) - } + /// Store a P2PK signing key. Implementations must upsert by derived pubkey. + async fn add_p2pk_key(&self, secret_key: SecretKey) -> Result<(), Self::Err>; + /// Get a stored P2PK secret key by pubkey. + async fn get_p2pk_key(&self, pubkey: PublicKey) -> Result, Self::Err>; + /// List all stored P2PK signing keys. + async fn list_p2pk_keys(&self) -> Result, Self::Err>; + /// Remove a stored P2PK signing key by pubkey. + async fn remove_p2pk_key(&self, pubkey: PublicKey) -> Result<(), Self::Err>; } diff --git a/crates/cdk-ffi/src/database.rs b/crates/cdk-ffi/src/database.rs index b1110aed6f..2814a0ef26 100644 --- a/crates/cdk-ffi/src/database.rs +++ b/crates/cdk-ffi/src/database.rs @@ -91,6 +91,16 @@ pub trait WalletDatabase: Send + Sync { /// Remove Keys from storage async fn remove_keys(&self, id: Id) -> Result<(), FfiError>; + // P2PK signing key storage + /// Store a P2PK signing key + async fn add_p2pk_key(&self, secret_key: SecretKey) -> Result<(), FfiError>; + /// Fetch a P2PK signing key by public key + async fn get_p2pk_key(&self, pubkey: PublicKey) -> Result, FfiError>; + /// List stored P2PK signing keys + async fn list_p2pk_keys(&self) -> Result, FfiError>; + /// Remove a stored P2PK signing key by public key + async fn remove_p2pk_key(&self, pubkey: PublicKey) -> Result<(), FfiError>; + // Proof Management /// Update the proofs in storage by adding new proofs or removing proofs by their Y value async fn update_proofs( @@ -403,6 +413,56 @@ impl CdkWalletDatabase for WalletDatabaseBridge { .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) } + async fn add_p2pk_key(&self, secret_key: cdk_common::nuts::SecretKey) -> Result<(), Self::Err> { + let ffi_secret: SecretKey = secret_key.into(); + self.ffi_db + .add_p2pk_key(ffi_secret) + .await + .map_err(|e| cdk_common::database::Error::Database(e.to_string().into())) + } + + async fn get_p2pk_key( + &self, + pubkey: cdk_common::nuts::PublicKey, + ) -> Result, Self::Err> { + let ffi_pubkey: PublicKey = pubkey.into(); + let result = self + .ffi_db + .get_p2pk_key(ffi_pubkey) + .await + .map_err(|e| cdk_common::database::Error::Database(e.to_string().into()))?; + + Ok(result.map(Into::into)) + } + + async fn list_p2pk_keys( + &self, + ) -> Result, Self::Err> { + let result = self + .ffi_db + .list_p2pk_keys() + .await + .map_err(|e| cdk_common::database::Error::Database(e.to_string().into()))?; + + result + .into_iter() + .map(|entry| { + let (pubkey, secret) = entry.try_into().map_err(|e: FfiError| { + cdk_common::database::Error::Database(e.to_string().into()) + })?; + Ok((pubkey, secret)) + }) + .collect() + } + + async fn remove_p2pk_key(&self, pubkey: cdk_common::nuts::PublicKey) -> Result<(), Self::Err> { + let ffi_pubkey: PublicKey = pubkey.into(); + self.ffi_db + .remove_p2pk_key(ffi_pubkey) + .await + .map_err(|e| cdk_common::database::Error::Database(e.to_string().into())) + } + // Proof Management async fn update_proofs( &self, @@ -807,6 +867,43 @@ impl WalletDatabase for WalletSqliteDatabase { .map_err(|e| FfiError::Database { msg: e.to_string() }) } + async fn add_p2pk_key(&self, secret_key: SecretKey) -> Result<(), FfiError> { + let cdk_secret: cdk_common::nuts::SecretKey = secret_key.into(); + self.inner + .add_p2pk_key(cdk_secret) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_p2pk_key(&self, pubkey: PublicKey) -> Result, FfiError> { + let cdk_pubkey = pubkey.try_into()?; + let result = self + .inner + .get_p2pk_key(cdk_pubkey) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.map(Into::into)) + } + + async fn list_p2pk_keys(&self) -> Result, FfiError> { + let result = self + .inner + .list_p2pk_keys() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + async fn remove_p2pk_key(&self, pubkey: PublicKey) -> Result<(), FfiError> { + let cdk_pubkey = pubkey.try_into()?; + self.inner + .remove_p2pk_key(cdk_pubkey) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + // Proof Management async fn update_proofs( &self, diff --git a/crates/cdk-ffi/src/types.rs b/crates/cdk-ffi/src/types.rs index 1a6d1d4df7..d67c12b57c 100644 --- a/crates/cdk-ffi/src/types.rs +++ b/crates/cdk-ffi/src/types.rs @@ -460,6 +460,33 @@ impl From for SecretKey { } } +/// Stored P2PK signing key entry (pubkey + secret) +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct P2pkSigningKey { + pub pubkey: PublicKey, + pub secret_key: SecretKey, +} + +impl From<(cdk_common::nuts::PublicKey, cdk_common::nuts::SecretKey)> for P2pkSigningKey { + fn from(value: (cdk_common::nuts::PublicKey, cdk_common::nuts::SecretKey)) -> Self { + Self { + pubkey: value.0.into(), + secret_key: value.1.into(), + } + } +} + +impl TryFrom for (cdk_common::nuts::PublicKey, cdk_common::nuts::SecretKey) { + type Error = FfiError; + + fn try_from(value: P2pkSigningKey) -> Result { + let pubkey = value.pubkey.try_into()?; + let secret_key = value.secret_key.into(); + + Ok((pubkey, secret_key)) + } +} + /// FFI-compatible Receive options #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct ReceiveOptions { diff --git a/crates/cdk-redb/src/wallet/migrations.rs b/crates/cdk-redb/src/wallet/migrations.rs index 948e9ab1cc..f00982ef80 100644 --- a/crates/cdk-redb/src/wallet/migrations.rs +++ b/crates/cdk-redb/src/wallet/migrations.rs @@ -11,7 +11,9 @@ use redb::{ }; use super::Error; -use crate::wallet::{KEYSETS_TABLE, KEYSET_COUNTER, KEYSET_U32_MAPPING, MINT_KEYS_TABLE}; +use crate::wallet::{ + KEYSETS_TABLE, KEYSET_COUNTER, KEYSET_U32_MAPPING, MINT_KEYS_TABLE, P2PK_SIGNING_KEYS_TABLE, +}; // const MINTS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("mints_table"); @@ -200,3 +202,17 @@ pub(crate) fn migrate_03_to_04(db: Arc) -> Result { Ok(4) } + +pub(crate) fn migrate_04_to_05(db: Arc) -> Result { + let write_txn = db.begin_write().map_err(Error::from)?; + + { + let _ = write_txn + .open_table(P2PK_SIGNING_KEYS_TABLE) + .map_err(Error::from)?; + } + + write_txn.commit().map_err(Error::from)?; + + Ok(5) +} diff --git a/crates/cdk-redb/src/wallet/mod.rs b/crates/cdk-redb/src/wallet/mod.rs index 37862fc2ee..6d8e46433b 100644 --- a/crates/cdk-redb/src/wallet/mod.rs +++ b/crates/cdk-redb/src/wallet/mod.rs @@ -13,15 +13,17 @@ use cdk_common::mint_url::MintUrl; use cdk_common::util::unix_time; use cdk_common::wallet::{self, MintQuote, Transaction, TransactionDirection, TransactionId}; use cdk_common::{ - database, CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, PublicKey, SpendingConditions, - State, + database, CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, PublicKey, SecretKey, + SpendingConditions, State, }; use redb::{Database, MultimapTableDefinition, ReadableTable, TableDefinition}; use tracing::instrument; use super::error::Error; use crate::migrations::migrate_00_to_01; -use crate::wallet::migrations::{migrate_01_to_02, migrate_02_to_03, migrate_03_to_04}; +use crate::wallet::migrations::{ + migrate_01_to_02, migrate_02_to_03, migrate_03_to_04, migrate_04_to_05, +}; mod migrations; @@ -44,9 +46,12 @@ const KEYSET_COUNTER: TableDefinition<&str, u32> = TableDefinition::new("keyset_ // const TRANSACTIONS_TABLE: TableDefinition<&[u8], &str> = TableDefinition::new("transactions"); +const P2PK_SIGNING_KEYS_TABLE: TableDefinition<&[u8], &[u8]> = + TableDefinition::new("p2pk_signing_keys"); + const KEYSET_U32_MAPPING: TableDefinition = TableDefinition::new("keyset_u32_mapping"); -const DATABASE_VERSION: u32 = 4; +const DATABASE_VERSION: u32 = 5; /// Wallet Redb Database #[derive(Debug, Clone)] @@ -100,6 +105,10 @@ impl WalletRedbDatabase { current_file_version = migrate_03_to_04(Arc::clone(&db))?; } + if current_file_version == 4 { + current_file_version = migrate_04_to_05(Arc::clone(&db))?; + } + if current_file_version != DATABASE_VERSION { tracing::warn!( "Database upgrade did not complete at {} current is {}", @@ -147,6 +156,7 @@ impl WalletRedbDatabase { let _ = write_txn.open_table(KEYSET_COUNTER)?; let _ = write_txn.open_table(TRANSACTIONS_TABLE)?; let _ = write_txn.open_table(KEYSET_U32_MAPPING)?; + let _ = write_txn.open_table(P2PK_SIGNING_KEYS_TABLE)?; table.insert("db_version", DATABASE_VERSION.to_string().as_str())?; } @@ -883,4 +893,74 @@ impl WalletDatabase for WalletRedbDatabase { Ok(()) } + + async fn add_p2pk_key(&self, secret_key: SecretKey) -> Result<(), Self::Err> { + let pubkey_bytes = secret_key.public_key().to_bytes(); + let secret_bytes = secret_key.to_secret_bytes(); + + let write_txn = self.db.begin_write().map_err(Error::from)?; + + { + let mut table = write_txn + .open_table(P2PK_SIGNING_KEYS_TABLE) + .map_err(Error::from)?; + table + .insert(pubkey_bytes.as_slice(), secret_bytes.as_slice()) + .map_err(Error::from)?; + } + + write_txn.commit().map_err(Error::from)?; + + Ok(()) + } + + async fn get_p2pk_key(&self, pubkey: PublicKey) -> Result, Self::Err> { + let pubkey_bytes = pubkey.to_bytes(); + let read_txn = self.db.begin_read().map_err(Error::from)?; + let table = read_txn + .open_table(P2PK_SIGNING_KEYS_TABLE) + .map_err(Error::from)?; + + Ok(table + .get(pubkey_bytes.as_slice()) + .map_err(Error::from)? + .map(|value| SecretKey::from_slice(value.value()).map_err(Error::from)) + .transpose()?) + } + + async fn list_p2pk_keys(&self) -> Result, Self::Err> { + let read_txn = self.db.begin_read().map_err(Error::from)?; + let table = read_txn + .open_table(P2PK_SIGNING_KEYS_TABLE) + .map_err(Error::from)?; + + let keys = table + .iter() + .map_err(Error::from)? + .flatten() + .map(|(stored_pubkey, stored_secret)| { + let pubkey = PublicKey::from_slice(stored_pubkey.value()).map_err(Error::from)?; + let secret = SecretKey::from_slice(stored_secret.value()).map_err(Error::from)?; + Ok((pubkey, secret)) + }) + .collect::, Error>>()?; + + Ok(keys) + } + + async fn remove_p2pk_key(&self, pubkey: PublicKey) -> Result<(), Self::Err> { + let pubkey_bytes = pubkey.to_bytes(); + let write_txn = self.db.begin_write().map_err(Error::from)?; + + { + let mut table = write_txn + .open_table(P2PK_SIGNING_KEYS_TABLE) + .map_err(Error::from)?; + table.remove(pubkey_bytes.as_slice()).map_err(Error::from)?; + } + + write_txn.commit().map_err(Error::from)?; + + Ok(()) + } } diff --git a/crates/cdk-sql-common/src/wallet/mod.rs b/crates/cdk-sql-common/src/wallet/mod.rs index b0424259dc..99d055cd25 100644 --- a/crates/cdk-sql-common/src/wallet/mod.rs +++ b/crates/cdk-sql-common/src/wallet/mod.rs @@ -696,12 +696,12 @@ ON CONFLICT(pubkey) DO UPDATE SET .transpose()?) } - async fn list_p2pk_keys(&self) -> Result, Self::Err> { + async fn list_p2pk_keys(&self) -> Result, Self::Err> { let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; Ok(query( r#" - SELECT secret_key + SELECT pubkey, secret_key FROM p2pk_signing_key "#, )? @@ -709,10 +709,15 @@ ON CONFLICT(pubkey) DO UPDATE SET .await? .into_iter() .map(|row| { - let bytes = column_as_binary!(row.first().unwrap()); - SecretKey::from_slice(&bytes).map_err(Error::from) + let pubkey_bytes = column_as_binary!(&row[0]); + let secret_bytes = column_as_binary!(&row[1]); + + let pubkey = PublicKey::from_slice(&pubkey_bytes).map_err(Error::from)?; + let secret = SecretKey::from_slice(&secret_bytes).map_err(Error::from)?; + + Ok((pubkey, secret)) }) - .collect::, _>>()?) + .collect::, Error>>()?) } async fn remove_p2pk_key(&self, pubkey: PublicKey) -> Result<(), Self::Err> { diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index dc639f77c5..de40a2410e 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -688,7 +688,9 @@ impl Wallet { } /// List stored P2PK signing keys - pub async fn list_p2pk_signing_keys(&self) -> Result, Error> { + pub async fn list_p2pk_signing_keys( + &self, + ) -> Result, Error> { self.localstore.list_p2pk_keys().await.map_err(Into::into) } } From d8eab60649c0667731e1e17c7003632f17cec1cd Mon Sep 17 00:00:00 2001 From: Yash Bhutwala Date: Tue, 16 Sep 2025 16:47:21 -0400 Subject: [PATCH 5/9] perf(wallet): lazily load p2pk keys --- crates/cdk/src/wallet/receive.rs | 45 ++++++++++++++++---------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/crates/cdk/src/wallet/receive.rs b/crates/cdk/src/wallet/receive.rs index 8749f62b28..c2b2f87963 100644 --- a/crates/cdk/src/wallet/receive.rs +++ b/crates/cdk/src/wallet/receive.rs @@ -51,32 +51,17 @@ impl Wallet { }) .collect::, _>>()?; - // Build map of X-only pubkey -> SecretKey from stored keys and provided options - let mut merged_keys: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); + // Build map of X-only pubkey -> SecretKey using provided keys and lazy DB lookups + let mut p2pk_signing_keys: HashMap = HashMap::new(); + let mut missing_db_keys: HashSet = HashSet::new(); - // Add keys explicitly provided in options first for sk in &opts.p2pk_signing_keys { - let x = sk.x_only_public_key(&SECP256K1).0; - if seen.insert(x) { - merged_keys.push(sk.clone()); - } - } - - // Merge in any keys stored in the wallet database - let stored_keys = self.localstore.list_p2pk_keys().await.unwrap_or_default(); - for sk in stored_keys { - let x = sk.x_only_public_key(&SECP256K1).0; - if seen.insert(x) { - merged_keys.push(sk); - } + let (x_only, _) = sk.x_only_public_key(&SECP256K1); + p2pk_signing_keys + .entry(x_only) + .or_insert_with(|| sk.clone()); } - let p2pk_signing_keys: HashMap = merged_keys - .into_iter() - .map(|s| (s.x_only_public_key(&SECP256K1).0, s)) - .collect(); - for proof in &mut proofs { // Verify that proof DLEQ is valid if proof.dleq.is_some() { @@ -114,7 +99,21 @@ impl Wallet { } } for pubkey in pubkeys { - if let Some(signing) = p2pk_signing_keys.get(&pubkey.x_only_public_key()) { + let x_only = pubkey.x_only_public_key(); + + if !p2pk_signing_keys.contains_key(&x_only) + && !missing_db_keys.contains(&x_only) + { + if let Some(stored) = + self.localstore.get_p2pk_key(pubkey.clone()).await? + { + p2pk_signing_keys.insert(x_only, stored); + } else { + missing_db_keys.insert(x_only); + } + } + + if let Some(signing) = p2pk_signing_keys.get(&x_only) { proof.sign_p2pk(signing.clone())?; } } From d4eb63fce3270a0cf4164164c80f31d85d9cf68f Mon Sep 17 00:00:00 2001 From: Yash Bhutwala Date: Tue, 16 Sep 2025 16:47:35 -0400 Subject: [PATCH 6/9] test(wallet): run p2pk autosign in ci --- .../tests/integration_tests_pure.rs | 97 +++++++++++++++ .../tests/p2pk_autosign.rs | 115 ------------------ 2 files changed, 97 insertions(+), 115 deletions(-) delete mode 100644 crates/cdk-integration-tests/tests/p2pk_autosign.rs diff --git a/crates/cdk-integration-tests/tests/integration_tests_pure.rs b/crates/cdk-integration-tests/tests/integration_tests_pure.rs index 9ab1fb0030..0e59f4f38b 100644 --- a/crates/cdk-integration-tests/tests/integration_tests_pure.rs +++ b/crates/cdk-integration-tests/tests/integration_tests_pure.rs @@ -980,6 +980,103 @@ async fn test_concurrent_double_spend_melt() { } } +/// Auto-sign receive should succeed when the signing key is stored in the wallet DB +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_autosign_receive_with_sqlite_memory_db() { + setup_tracing(); + + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint) + .await + .expect("Failed to create test wallet"); + + fund_wallet(wallet.clone(), 100, Some(SplitTarget::default())) + .await + .expect("Failed to fund wallet"); + + let signing_sk = SecretKey::generate(); + let signing_pk = signing_sk.public_key(); + wallet + .add_p2pk_signing_key(signing_sk.clone()) + .await + .expect("Failed to store P2PK signing key"); + + let spending = SpendingConditions::new_p2pk(signing_pk, None); + let prepared = wallet + .prepare_send( + 10u64.into(), + SendOptions { + conditions: Some(spending), + include_fee: true, + ..Default::default() + }, + ) + .await + .expect("Failed to prepare send"); + let expected_received = 10u64 - u64::from(prepared.fee()); + let token = prepared + .confirm(None) + .await + .expect("Failed to finalize send"); + + let received = wallet + .receive(&token.to_string(), ReceiveOptions::default()) + .await + .expect("Receive should auto-sign and succeed"); + + assert_eq!(u64::from(received), expected_received); +} + +/// Ensure receiving fails when no signing key is available to satisfy the P2PK condition +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_receive_fails_without_signing_key() { + setup_tracing(); + + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint) + .await + .expect("Failed to create test wallet"); + + fund_wallet(wallet.clone(), 100, Some(SplitTarget::default())) + .await + .expect("Failed to fund wallet"); + + let locking_sk = SecretKey::generate(); + let spending = SpendingConditions::new_p2pk(locking_sk.public_key(), None); + + let prepared = wallet + .prepare_send( + 10u64.into(), + SendOptions { + conditions: Some(spending), + include_fee: true, + ..Default::default() + }, + ) + .await + .expect("Failed to prepare send"); + let token = prepared + .confirm(None) + .await + .expect("Failed to finalize send"); + + let res = wallet + .receive(&token.to_string(), ReceiveOptions::default()) + .await; + + match res { + Ok(_) => panic!("Receive unexpectedly succeeded without signing key"), + Err(e) => match e { + cdk::Error::NUT11(cdk::nuts::nut11::Error::SignaturesNotProvided) => (), + other => panic!("Unexpected error: {:?}", other), + }, + } +} + async fn get_keyset_id(mint: &Mint) -> Id { let keys = mint.pubkeys().keysets.first().unwrap().clone(); keys.verify_id() diff --git a/crates/cdk-integration-tests/tests/p2pk_autosign.rs b/crates/cdk-integration-tests/tests/p2pk_autosign.rs deleted file mode 100644 index 9e72d79acf..0000000000 --- a/crates/cdk-integration-tests/tests/p2pk_autosign.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! Focused test: auto-sign P2PK receive using SQLite memory wallet DB - -use cdk::amount::SplitTarget; -use cdk::nuts::{SecretKey, SpendingConditions}; -use cdk::wallet::{ReceiveOptions, SendOptions}; -use cdk_integration_tests::init_pure_tests::{ - create_and_start_test_mint, create_test_wallet_for_mint, fund_wallet, setup_tracing, -}; - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_autosign_receive_with_sqlite_memory_db() { - setup_tracing(); - - // Create in-process mint and wallet (wallet DB type provided via CDK_TEST_DB_TYPE) - let mint = create_and_start_test_mint() - .await - .expect("Failed to create test mint"); - let wallet = create_test_wallet_for_mint(mint) - .await - .expect("Failed to create test wallet"); - - // Fund wallet with some amount to create a P2PK-locked token to self - let _ = fund_wallet(wallet.clone(), 100, Some(SplitTarget::default())) - .await - .expect("Failed to fund wallet"); - - // Generate and store a P2PK signing key in the wallet DB - // Keep the secret locally to craft the spending condition - let signing_sk = SecretKey::generate(); - let signing_pk = signing_sk.public_key(); - wallet - .add_p2pk_signing_key(signing_sk.clone()) - .await - .expect("Failed to store P2PK signing key"); - - // Create a token locked to the stored P2PK public key - let spending = SpendingConditions::new_p2pk(signing_pk, None); - let prepared = wallet - .prepare_send( - 10u64.into(), - SendOptions { - conditions: Some(spending), - include_fee: true, - ..Default::default() - }, - ) - .await - .expect("Failed to prepare send"); - let expected_received = 10u64 - u64::from(prepared.fee()); - let token = prepared - .confirm(None) - .await - .expect("Failed to finalize send"); - - // Receive without providing any p2pk_signing_keys in options. - // This should auto-sign using the key stored in the wallet DB. - let received = wallet - .receive(&token.to_string(), ReceiveOptions::default()) - .await - .expect("Receive should auto-sign and succeed"); - - assert_eq!(u64::from(received), expected_received); -} - -/// Negative case: receive should fail when no signing key is provided or stored -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_receive_fails_without_signing_key() { - setup_tracing(); - - // Fresh mint and wallet; do NOT store any P2PK signing keys - let mint = create_and_start_test_mint() - .await - .expect("Failed to create test mint"); - let wallet = create_test_wallet_for_mint(mint) - .await - .expect("Failed to create test wallet"); - - // Fund wallet and create a token locked to a brand new key (unknown to wallet DB) - let _ = fund_wallet(wallet.clone(), 100, Some(SplitTarget::default())) - .await - .expect("Failed to fund wallet"); - - let locking_sk = SecretKey::generate(); - let spending = SpendingConditions::new_p2pk(locking_sk.public_key(), None); - - let prepared = wallet - .prepare_send( - 10u64.into(), - SendOptions { - conditions: Some(spending), - include_fee: true, - ..Default::default() - }, - ) - .await - .expect("Failed to prepare send"); - let token = prepared - .confirm(None) - .await - .expect("Failed to finalize send"); - - // Attempt to receive without providing any keys and none stored → should fail - let res = wallet - .receive(&token.to_string(), ReceiveOptions::default()) - .await; - - match res { - Ok(_) => panic!("Receive unexpectedly succeeded without signing key"), - Err(e) => match e { - // Expect signature-related error at the mint - cdk::Error::NUT11(cdk::nuts::nut11::Error::SignaturesNotProvided) => (), - other => panic!("Unexpected error: {:?}", other), - }, - } -} From 1c631954b7949d38c939b10d220f0b159800123f Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Mon, 29 Sep 2025 11:39:52 +0100 Subject: [PATCH 7/9] fix: error convert --- crates/cdk-redb/src/error.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/cdk-redb/src/error.rs b/crates/cdk-redb/src/error.rs index 195e024bf5..9f02d9b5b6 100644 --- a/crates/cdk-redb/src/error.rs +++ b/crates/cdk-redb/src/error.rs @@ -46,6 +46,9 @@ pub enum Error { /// NUT00 Error #[error(transparent)] CDKNUT00(#[from] cdk_common::nuts::nut00::Error), + /// NUT01 Error + #[error(transparent)] + CDKNUT01(#[from] cdk_common::nuts::nut01::Error), /// NUT02 Error #[error(transparent)] CDKNUT02(#[from] cdk_common::nuts::nut02::Error), From d80a97bc817acbc084be8b8bcf4c7d6a194f1ac9 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Mon, 6 Oct 2025 12:45:36 +0200 Subject: [PATCH 8/9] chore: remove refs to common --- crates/cdk-ffi/src/database.rs | 22 +++++++++++----------- crates/cdk-ffi/src/types.rs | 6 +++--- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/cdk-ffi/src/database.rs b/crates/cdk-ffi/src/database.rs index 2814a0ef26..4d952d100d 100644 --- a/crates/cdk-ffi/src/database.rs +++ b/crates/cdk-ffi/src/database.rs @@ -413,54 +413,54 @@ impl CdkWalletDatabase for WalletDatabaseBridge { .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) } - async fn add_p2pk_key(&self, secret_key: cdk_common::nuts::SecretKey) -> Result<(), Self::Err> { + async fn add_p2pk_key(&self, secret_key: cdk::nuts::SecretKey) -> Result<(), Self::Err> { let ffi_secret: SecretKey = secret_key.into(); self.ffi_db .add_p2pk_key(ffi_secret) .await - .map_err(|e| cdk_common::database::Error::Database(e.to_string().into())) + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) } async fn get_p2pk_key( &self, - pubkey: cdk_common::nuts::PublicKey, - ) -> Result, Self::Err> { + pubkey: cdk::nuts::PublicKey, + ) -> Result, Self::Err> { let ffi_pubkey: PublicKey = pubkey.into(); let result = self .ffi_db .get_p2pk_key(ffi_pubkey) .await - .map_err(|e| cdk_common::database::Error::Database(e.to_string().into()))?; + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; Ok(result.map(Into::into)) } async fn list_p2pk_keys( &self, - ) -> Result, Self::Err> { + ) -> Result, Self::Err> { let result = self .ffi_db .list_p2pk_keys() .await - .map_err(|e| cdk_common::database::Error::Database(e.to_string().into()))?; + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; result .into_iter() .map(|entry| { let (pubkey, secret) = entry.try_into().map_err(|e: FfiError| { - cdk_common::database::Error::Database(e.to_string().into()) + cdk::cdk_database::Error::Database(e.to_string().into()) })?; Ok((pubkey, secret)) }) .collect() } - async fn remove_p2pk_key(&self, pubkey: cdk_common::nuts::PublicKey) -> Result<(), Self::Err> { + async fn remove_p2pk_key(&self, pubkey: cdk::nuts::PublicKey) -> Result<(), Self::Err> { let ffi_pubkey: PublicKey = pubkey.into(); self.ffi_db .remove_p2pk_key(ffi_pubkey) .await - .map_err(|e| cdk_common::database::Error::Database(e.to_string().into())) + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) } // Proof Management @@ -868,7 +868,7 @@ impl WalletDatabase for WalletSqliteDatabase { } async fn add_p2pk_key(&self, secret_key: SecretKey) -> Result<(), FfiError> { - let cdk_secret: cdk_common::nuts::SecretKey = secret_key.into(); + let cdk_secret: cdk::nuts::SecretKey = secret_key.into(); self.inner .add_p2pk_key(cdk_secret) .await diff --git a/crates/cdk-ffi/src/types.rs b/crates/cdk-ffi/src/types.rs index d67c12b57c..d9fa14d537 100644 --- a/crates/cdk-ffi/src/types.rs +++ b/crates/cdk-ffi/src/types.rs @@ -467,8 +467,8 @@ pub struct P2pkSigningKey { pub secret_key: SecretKey, } -impl From<(cdk_common::nuts::PublicKey, cdk_common::nuts::SecretKey)> for P2pkSigningKey { - fn from(value: (cdk_common::nuts::PublicKey, cdk_common::nuts::SecretKey)) -> Self { +impl From<(cdk::nuts::PublicKey, cdk::nuts::SecretKey)> for P2pkSigningKey { + fn from(value: (cdk::nuts::PublicKey, cdk::nuts::SecretKey)) -> Self { Self { pubkey: value.0.into(), secret_key: value.1.into(), @@ -476,7 +476,7 @@ impl From<(cdk_common::nuts::PublicKey, cdk_common::nuts::SecretKey)> for P2pkSi } } -impl TryFrom for (cdk_common::nuts::PublicKey, cdk_common::nuts::SecretKey) { +impl TryFrom for (cdk::nuts::PublicKey, cdk::nuts::SecretKey) { type Error = FfiError; fn try_from(value: P2pkSigningKey) -> Result { From f22373740ca3a682970e023840709a5038291e34 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Mon, 6 Oct 2025 13:48:36 +0200 Subject: [PATCH 9/9] feat(cdk-ffi): improve secret key handling with secure memory management - Convert SecretKey from uniffi::Record to uniffi::Object for better encapsulation - Implement Drop trait to ensure secure cleanup via zeroize - Wrap secret keys in Arc across FFI boundary for safe sharing - Update WalletDatabase trait to use Arc and Arc - Add zeroize to workspace dependencies - Improve test coverage for secret key conversions and byte handling --- Cargo.toml | 1 + crates/cdk-ffi/Cargo.toml | 1 - crates/cdk-ffi/src/database.rs | 19 ++++-- crates/cdk-ffi/src/lib.rs | 62 ++++++++++++++------ crates/cdk-ffi/src/postgres.rs | 12 ++-- crates/cdk-ffi/src/sqlite.rs | 12 ++-- crates/cdk-ffi/src/types/keys.rs | 92 +++++++++++++++++++++--------- crates/cdk-ffi/src/types/wallet.rs | 17 ++++-- crates/cdk/Cargo.toml | 2 +- crates/cdk/src/wallet/receive.rs | 4 +- 10 files changed, 152 insertions(+), 70 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9b8a6afb3c..fe3e8674e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,6 +111,7 @@ strum = "0.27.1" strum_macros = "0.27.1" rustls = { version = "0.23.27", default-features = false, features = ["ring"] } prometheus = { version = "0.13.4", features = ["process"], default-features = false } +zeroize = "1" diff --git a/crates/cdk-ffi/Cargo.toml b/crates/cdk-ffi/Cargo.toml index b37b47b4ac..a2587fdc3e 100644 --- a/crates/cdk-ffi/Cargo.toml +++ b/crates/cdk-ffi/Cargo.toml @@ -30,7 +30,6 @@ uniffi = { version = "0.29", features = ["cli", "tokio"] } url = { workspace = true } uuid = { workspace = true, features = ["v4"] } - [features] default = ["postgres"] # Enable Postgres-backed wallet database support in FFI diff --git a/crates/cdk-ffi/src/database.rs b/crates/cdk-ffi/src/database.rs index 5d9b260ee0..8138114074 100644 --- a/crates/cdk-ffi/src/database.rs +++ b/crates/cdk-ffi/src/database.rs @@ -94,11 +94,11 @@ pub trait WalletDatabase: Send + Sync { // P2PK signing key storage /// Store a P2PK signing key - async fn add_p2pk_key(&self, secret_key: SecretKey) -> Result<(), FfiError>; + async fn add_p2pk_key(&self, secret_key: Arc) -> Result<(), FfiError>; /// Fetch a P2PK signing key by public key - async fn get_p2pk_key(&self, pubkey: PublicKey) -> Result, FfiError>; + async fn get_p2pk_key(&self, pubkey: PublicKey) -> Result>, FfiError>; /// List stored P2PK signing keys - async fn list_p2pk_keys(&self) -> Result, FfiError>; + async fn list_p2pk_keys(&self) -> Result>, FfiError>; /// Remove a stored P2PK signing key by public key async fn remove_p2pk_key(&self, pubkey: PublicKey) -> Result<(), FfiError>; @@ -422,7 +422,7 @@ impl CdkWalletDatabase for WalletDatabaseBridge { } async fn add_p2pk_key(&self, secret_key: cdk::nuts::SecretKey) -> Result<(), Self::Err> { - let ffi_secret: SecretKey = secret_key.into(); + let ffi_secret: Arc = Arc::new(secret_key.into()); self.ffi_db .add_p2pk_key(ffi_secret) .await @@ -440,7 +440,14 @@ impl CdkWalletDatabase for WalletDatabaseBridge { .await .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; - Ok(result.map(Into::into)) + result + .map(|sk| { + (*sk) + .clone() + .try_into() + .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into())) + }) + .transpose() } async fn list_p2pk_keys( @@ -455,7 +462,7 @@ impl CdkWalletDatabase for WalletDatabaseBridge { result .into_iter() .map(|entry| { - let (pubkey, secret) = entry.try_into().map_err(|e: FfiError| { + let (pubkey, secret) = (*entry).clone().try_into().map_err(|e: FfiError| { cdk::cdk_database::Error::Database(e.to_string().into()) })?; Ok((pubkey, secret)) diff --git a/crates/cdk-ffi/src/lib.rs b/crates/cdk-ffi/src/lib.rs index 57dbb8b4cd..033f791bce 100644 --- a/crates/cdk-ffi/src/lib.rs +++ b/crates/cdk-ffi/src/lib.rs @@ -123,10 +123,12 @@ mod tests { #[test] fn test_secret_key_from_hex() { // Test valid hex string (64 characters) - let valid_hex = "a".repeat(64); - let secret_key = SecretKey::from_hex(valid_hex.clone()); + let valid_hex = "0000000000000000000000000000000000000000000000000000000000000001"; + let secret_key = SecretKey::from_hex(valid_hex.to_string()); assert!(secret_key.is_ok()); - assert_eq!(secret_key.unwrap().hex, valid_hex); + let sk = secret_key.unwrap(); + assert_eq!(sk.to_bytes().len(), 32); + assert_eq!(sk.to_hex(), valid_hex); // Test invalid length let invalid_length = "a".repeat(32); // 32 chars instead of 64 @@ -140,18 +142,43 @@ mod tests { } #[test] - fn test_secret_key_random() { - let key1 = SecretKey::random(); - let key2 = SecretKey::random(); - - // Keys should be different - assert_ne!(key1.hex, key2.hex); - - // Keys should be valid hex (64 characters) - assert_eq!(key1.hex.len(), 64); - assert_eq!(key2.hex.len(), 64); - assert!(key1.hex.chars().all(|c| c.is_ascii_hexdigit())); - assert!(key2.hex.chars().all(|c| c.is_ascii_hexdigit())); + fn test_secret_key_from_bytes() { + // Test valid bytes (32 bytes) + let valid_bytes = vec![1u8; 32]; + let secret_key = SecretKey::from_bytes(valid_bytes.clone()); + assert!(secret_key.is_ok()); + let sk = secret_key.unwrap(); + assert_eq!(sk.to_bytes().len(), 32); + assert_eq!(sk.to_bytes(), valid_bytes); + + // Test invalid length + let invalid_length = vec![1u8; 16]; // 16 bytes instead of 32 + let secret_key = SecretKey::from_bytes(invalid_length); + assert!(secret_key.is_err()); + + // Test empty + let empty = vec![]; + let secret_key = SecretKey::from_bytes(empty); + assert!(secret_key.is_err()); + } + + #[test] + fn test_secret_key_conversions() { + // Test round-trip conversion + let cdk_secret = cdk::nuts::SecretKey::generate(); + let ffi_secret: SecretKey = cdk_secret.clone().into(); + let cdk_secret_back: cdk::nuts::SecretKey = ffi_secret.clone().try_into().unwrap(); + + // Should be equal + assert_eq!( + cdk_secret.to_secret_bytes(), + cdk_secret_back.to_secret_bytes() + ); + + // Test bytes match - Vec should be exactly 32 bytes + let bytes = ffi_secret.to_bytes(); + assert_eq!(bytes.len(), 32); + assert_eq!(bytes, cdk_secret.to_secret_bytes().to_vec()); } #[test] @@ -204,8 +231,9 @@ mod tests { #[test] fn test_receive_options_with_all_fields() { use std::collections::HashMap; + use std::sync::Arc; - let secret_key = SecretKey::random(); + let secret_key = cdk::nuts::SecretKey::generate(); let mut metadata = HashMap::new(); metadata.insert("key1".to_string(), "value1".to_string()); @@ -213,7 +241,7 @@ mod tests { amount_split_target: SplitTarget::Values { amounts: vec![Amount::new(100), Amount::new(200)], }, - p2pk_signing_keys: vec![secret_key], + p2pk_signing_keys: vec![Arc::new(secret_key.into())], preimages: vec!["preimage1".to_string(), "preimage2".to_string()], metadata, }; diff --git a/crates/cdk-ffi/src/postgres.rs b/crates/cdk-ffi/src/postgres.rs index bbe8fc1aab..b563f69fbf 100644 --- a/crates/cdk-ffi/src/postgres.rs +++ b/crates/cdk-ffi/src/postgres.rs @@ -227,15 +227,15 @@ impl WalletDatabase for WalletPostgresDatabase { } // P2PK Key Management - async fn add_p2pk_key(&self, secret_key: SecretKey) -> Result<(), FfiError> { - let cdk_secret: cdk::nuts::SecretKey = secret_key.into(); + async fn add_p2pk_key(&self, secret_key: Arc) -> Result<(), FfiError> { + let cdk_secret: cdk::nuts::SecretKey = (*secret_key).clone().try_into()?; self.inner .add_p2pk_key(cdk_secret) .await .map_err(|e| FfiError::Database { msg: e.to_string() }) } - async fn get_p2pk_key(&self, pubkey: PublicKey) -> Result, FfiError> { + async fn get_p2pk_key(&self, pubkey: PublicKey) -> Result>, FfiError> { let cdk_pubkey = pubkey.try_into()?; let result = self .inner @@ -243,17 +243,17 @@ impl WalletDatabase for WalletPostgresDatabase { .await .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(Into::into)) + Ok(result.map(|sk| Arc::new(sk.into()))) } - async fn list_p2pk_keys(&self) -> Result, FfiError> { + async fn list_p2pk_keys(&self) -> Result>, FfiError> { let result = self .inner .list_p2pk_keys() .await .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.into_iter().map(Into::into).collect()) + Ok(result.into_iter().map(|k| Arc::new(k.into())).collect()) } async fn remove_p2pk_key(&self, pubkey: PublicKey) -> Result<(), FfiError> { diff --git a/crates/cdk-ffi/src/sqlite.rs b/crates/cdk-ffi/src/sqlite.rs index 12c3de6e4d..09993655d1 100644 --- a/crates/cdk-ffi/src/sqlite.rs +++ b/crates/cdk-ffi/src/sqlite.rs @@ -262,15 +262,15 @@ impl WalletDatabase for WalletSqliteDatabase { } // P2PK Key Management - async fn add_p2pk_key(&self, secret_key: SecretKey) -> Result<(), FfiError> { - let cdk_secret: cdk::nuts::SecretKey = secret_key.into(); + async fn add_p2pk_key(&self, secret_key: Arc) -> Result<(), FfiError> { + let cdk_secret: cdk::nuts::SecretKey = (*secret_key).clone().try_into()?; self.inner .add_p2pk_key(cdk_secret) .await .map_err(|e| FfiError::Database { msg: e.to_string() }) } - async fn get_p2pk_key(&self, pubkey: PublicKey) -> Result, FfiError> { + async fn get_p2pk_key(&self, pubkey: PublicKey) -> Result>, FfiError> { let cdk_pubkey = pubkey.try_into()?; let result = self .inner @@ -278,17 +278,17 @@ impl WalletDatabase for WalletSqliteDatabase { .await .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.map(Into::into)) + Ok(result.map(|sk| Arc::new(sk.into()))) } - async fn list_p2pk_keys(&self) -> Result, FfiError> { + async fn list_p2pk_keys(&self) -> Result>, FfiError> { let result = self .inner .list_p2pk_keys() .await .map_err(|e| FfiError::Database { msg: e.to_string() })?; - Ok(result.into_iter().map(Into::into).collect()) + Ok(result.into_iter().map(|k| Arc::new(k.into())).collect()) } async fn remove_p2pk_key(&self, pubkey: PublicKey) -> Result<(), FfiError> { diff --git a/crates/cdk-ffi/src/types/keys.rs b/crates/cdk-ffi/src/types/keys.rs index 7b3c8c155b..bb8bf8f544 100644 --- a/crates/cdk-ffi/src/types/keys.rs +++ b/crates/cdk-ffi/src/types/keys.rs @@ -258,58 +258,98 @@ impl From for cdk::nuts::Id { } /// FFI-compatible SecretKey -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -#[serde(transparent)] +/// +/// Wraps the inner cdk::nuts::SecretKey to avoid copying secret data. +/// The inner type implements Drop with zeroize for secure memory cleanup. +#[derive(Debug, Clone, uniffi::Object)] pub struct SecretKey { - /// Hex-encoded secret key - pub hex: String, + pub(crate) inner: cdk::nuts::SecretKey, } +#[uniffi::export] impl SecretKey { /// Create a new SecretKey from hex string + #[uniffi::constructor] pub fn from_hex(hex: String) -> Result { - Ok(Self { hex }) + let inner = cdk::nuts::SecretKey::from_hex(&hex).map_err(|e| { + FfiError::InvalidCryptographicKey { + msg: format!("Invalid secret key hex: {}", e), + } + })?; + Ok(Self { inner }) + } + + /// Create a new SecretKey from bytes + #[uniffi::constructor] + pub fn from_bytes(bytes: Vec) -> Result { + if bytes.len() != 32 { + return Err(FfiError::InvalidCryptographicKey { + msg: format!("Secret key must be exactly 32 bytes, got {}", bytes.len()), + }); + } + let inner = cdk::nuts::SecretKey::from_slice(&bytes).map_err(|e| { + FfiError::InvalidCryptographicKey { + msg: format!("Invalid secret key bytes: {}", e), + } + })?; + Ok(Self { inner }) } /// Get the hex representation pub fn to_hex(&self) -> String { - self.hex.clone() + self.inner.to_secret_hex() + } + + /// Get the bytes representation + pub fn to_bytes(&self) -> Vec { + self.inner.to_secret_bytes().to_vec() } /// Get the public key for this secret key - pub fn public_key(&self) -> Result { - let cdk_secret: cdk::nuts::SecretKey = self.clone().into(); - Ok(cdk_secret.public_key().into()) + pub fn public_key(&self) -> PublicKey { + self.inner.public_key().into() } } -/// Generate a new random SecretKey -#[uniffi::export] -pub fn generate_secret_key() -> SecretKey { - use cdk::nuts::SecretKey as CdkSecretKey; - let secret_key = CdkSecretKey::generate(); - secret_key.into() -} +impl TryFrom for cdk::nuts::SecretKey { + type Error = FfiError; -impl From for cdk::nuts::SecretKey { - fn from(key: SecretKey) -> Self { - cdk::nuts::SecretKey::from_hex(&key.hex).expect("Invalid secret key hex") + fn try_from(key: SecretKey) -> Result { + Ok(key.inner.clone()) } } impl From for SecretKey { - fn from(key: cdk::nuts::SecretKey) -> Self { - Self { - hex: key.to_secret_hex(), - } + fn from(inner: cdk::nuts::SecretKey) -> Self { + Self { inner } + } +} + +impl Drop for SecretKey { + fn drop(&mut self) { + // The inner cdk::nuts::SecretKey already implements Drop with zeroize + // so this will be handled automatically when inner is dropped } } /// FFI-compatible P2PK signing key (public key + secret key pair) -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +#[derive(Debug, Clone, uniffi::Object)] pub struct P2pkSigningKey { pub pubkey: PublicKey, - pub secret_key: SecretKey, + pub(crate) secret_key: SecretKey, +} + +#[uniffi::export] +impl P2pkSigningKey { + /// Get the public key + pub fn pubkey(&self) -> PublicKey { + self.pubkey.clone() + } + + /// Get the secret key + pub fn secret_key(&self) -> SecretKey { + self.secret_key.clone() + } } impl From<(cdk::nuts::PublicKey, cdk::nuts::SecretKey)> for P2pkSigningKey { @@ -325,6 +365,6 @@ impl TryFrom for (cdk::nuts::PublicKey, cdk::nuts::SecretKey) { type Error = FfiError; fn try_from(value: P2pkSigningKey) -> Result { - Ok((value.pubkey.try_into()?, value.secret_key.into())) + Ok((value.pubkey.try_into()?, value.secret_key.try_into()?)) } } diff --git a/crates/cdk-ffi/src/types/wallet.rs b/crates/cdk-ffi/src/types/wallet.rs index 23b0ec405b..0af8adf05d 100644 --- a/crates/cdk-ffi/src/types/wallet.rs +++ b/crates/cdk-ffi/src/types/wallet.rs @@ -185,8 +185,9 @@ pub fn encode_send_options(options: SendOptions) -> Result { pub struct ReceiveOptions { /// Amount split target pub amount_split_target: SplitTarget, - /// P2PK signing keys - pub p2pk_signing_keys: Vec, + /// P2PK signing keys (wrapped in Arc for UniFFI Object compatibility) + #[serde(skip)] + pub p2pk_signing_keys: Vec>, /// Preimages for HTLC conditions pub preimages: Vec, /// Metadata @@ -208,7 +209,11 @@ impl From for cdk::wallet::ReceiveOptions { fn from(opts: ReceiveOptions) -> Self { cdk::wallet::ReceiveOptions { amount_split_target: opts.amount_split_target.into(), - p2pk_signing_keys: opts.p2pk_signing_keys.into_iter().map(Into::into).collect(), + p2pk_signing_keys: opts + .p2pk_signing_keys + .into_iter() + .filter_map(|sk| (*sk).clone().try_into().ok()) + .collect(), preimages: opts.preimages, metadata: opts.metadata, } @@ -219,7 +224,11 @@ impl From for ReceiveOptions { fn from(opts: cdk::wallet::ReceiveOptions) -> Self { Self { amount_split_target: opts.amount_split_target.into(), - p2pk_signing_keys: opts.p2pk_signing_keys.into_iter().map(Into::into).collect(), + p2pk_signing_keys: opts + .p2pk_signing_keys + .into_iter() + .map(|sk| std::sync::Arc::new(sk.into())) + .collect(), preimages: opts.preimages, metadata: opts.metadata, } diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index c21dc59d96..4b4d187c75 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -64,7 +64,7 @@ nostr-sdk = { optional = true, version = "0.43.0", default-features = false, fea ]} cdk-prometheus = {workspace = true, optional = true} web-time.workspace = true -zeroize = "1" +zeroize.workspace = true tokio-util.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/cdk/src/wallet/receive.rs b/crates/cdk/src/wallet/receive.rs index c2b2f87963..78c441f32b 100644 --- a/crates/cdk/src/wallet/receive.rs +++ b/crates/cdk/src/wallet/receive.rs @@ -104,9 +104,7 @@ impl Wallet { if !p2pk_signing_keys.contains_key(&x_only) && !missing_db_keys.contains(&x_only) { - if let Some(stored) = - self.localstore.get_p2pk_key(pubkey.clone()).await? - { + if let Some(stored) = self.localstore.get_p2pk_key(pubkey).await? { p2pk_signing_keys.insert(x_only, stored); } else { missing_db_keys.insert(x_only);