diff --git a/Cargo.toml b/Cargo.toml index 9b8a6afb3..fe3e8674e 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-common/src/database/wallet.rs b/crates/cdk-common/src/database/wallet.rs index 8889b4710..b55ef6d7f 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, @@ -125,4 +125,14 @@ 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 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/Cargo.toml b/crates/cdk-ffi/Cargo.toml index b37b47b4a..a2587fdc3 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 cdf6eb572..813811407 100644 --- a/crates/cdk-ffi/src/database.rs +++ b/crates/cdk-ffi/src/database.rs @@ -92,6 +92,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: Arc) -> 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( @@ -411,6 +421,63 @@ 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::nuts::SecretKey) -> Result<(), Self::Err> { + let ffi_secret: Arc = Arc::new(secret_key.into()); + self.ffi_db + .add_p2pk_key(ffi_secret) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + async fn get_p2pk_key( + &self, + 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::cdk_database::Error::Database(e.to_string().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( + &self, + ) -> Result, Self::Err> { + let result = self + .ffi_db + .list_p2pk_keys() + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; + + result + .into_iter() + .map(|entry| { + let (pubkey, secret) = (*entry).clone().try_into().map_err(|e: FfiError| { + cdk::cdk_database::Error::Database(e.to_string().into()) + })?; + Ok((pubkey, secret)) + }) + .collect() + } + + 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::cdk_database::Error::Database(e.to_string().into())) + } + // Proof Management async fn update_proofs( &self, diff --git a/crates/cdk-ffi/src/lib.rs b/crates/cdk-ffi/src/lib.rs index 57dbb8b4c..033f791bc 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 4c46a69bb..b563f69fb 100644 --- a/crates/cdk-ffi/src/postgres.rs +++ b/crates/cdk-ffi/src/postgres.rs @@ -8,8 +8,8 @@ use cdk_postgres::WalletPgDatabase as CdkWalletPgDatabase; use crate::{ CurrencyUnit, FfiError, Id, KeySet, KeySetInfo, Keys, MeltQuote, MintInfo, MintQuote, MintUrl, - ProofInfo, ProofState, PublicKey, SpendingConditions, Transaction, TransactionDirection, - TransactionId, WalletDatabase, + P2pkSigningKey, ProofInfo, ProofState, PublicKey, SecretKey, SpendingConditions, Transaction, + TransactionDirection, TransactionId, WalletDatabase, }; #[derive(uniffi::Object)] @@ -226,6 +226,44 @@ impl WalletDatabase for WalletPostgresDatabase { .map_err(|e| FfiError::Database { msg: e.to_string() }) } + // P2PK Key Management + 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> { + 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(|sk| Arc::new(sk.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(|k| Arc::new(k.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/sqlite.rs b/crates/cdk-ffi/src/sqlite.rs index b48bb36aa..09993655d 100644 --- a/crates/cdk-ffi/src/sqlite.rs +++ b/crates/cdk-ffi/src/sqlite.rs @@ -5,8 +5,8 @@ use cdk_sqlite::wallet::WalletSqliteDatabase as CdkWalletSqliteDatabase; use crate::{ CurrencyUnit, FfiError, Id, KeySet, KeySetInfo, Keys, MeltQuote, MintInfo, MintQuote, MintUrl, - ProofInfo, ProofState, PublicKey, SpendingConditions, Transaction, TransactionDirection, - TransactionId, WalletDatabase, + P2pkSigningKey, ProofInfo, ProofState, PublicKey, SecretKey, SpendingConditions, Transaction, + TransactionDirection, TransactionId, WalletDatabase, }; /// FFI-compatible WalletSqliteDatabase implementation that implements the WalletDatabase trait @@ -261,6 +261,44 @@ impl WalletDatabase for WalletSqliteDatabase { .map_err(|e| FfiError::Database { msg: e.to_string() }) } + // P2PK Key Management + 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> { + 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(|sk| Arc::new(sk.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(|k| Arc::new(k.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/keys.rs b/crates/cdk-ffi/src/types/keys.rs index 57ef47617..bb8bf8f54 100644 --- a/crates/cdk-ffi/src/types/keys.rs +++ b/crates/cdk-ffi/src/types/keys.rs @@ -256,3 +256,115 @@ impl From for cdk::nuts::Id { Self::from_str(&id.hex).unwrap() } } + +/// FFI-compatible SecretKey +/// +/// 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 { + 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 { + 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.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) -> PublicKey { + self.inner.public_key().into() + } +} + +impl TryFrom for cdk::nuts::SecretKey { + type Error = FfiError; + + fn try_from(key: SecretKey) -> Result { + Ok(key.inner.clone()) + } +} + +impl From for SecretKey { + 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, uniffi::Object)] +pub struct P2pkSigningKey { + pub pubkey: PublicKey, + 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 { + fn from(value: (cdk::nuts::PublicKey, cdk::nuts::SecretKey)) -> Self { + Self { + pubkey: value.0.into(), + secret_key: value.1.into(), + } + } +} + +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.try_into()?)) + } +} diff --git a/crates/cdk-ffi/src/types/wallet.rs b/crates/cdk-ffi/src/types/wallet.rs index 336ca7f51..0af8adf05 100644 --- a/crates/cdk-ffi/src/types/wallet.rs +++ b/crates/cdk-ffi/src/types/wallet.rs @@ -6,6 +6,7 @@ use std::sync::Mutex; use serde::{Deserialize, Serialize}; use super::amount::{Amount, SplitTarget}; +use super::keys::SecretKey; use super::proof::{Proofs, SpendingConditions}; use crate::error::FfiError; use crate::token::Token; @@ -179,66 +180,14 @@ pub fn encode_send_options(options: SendOptions) -> Result { Ok(serde_json::to_string(&options)?) } -/// FFI-compatible SecretKey -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -#[serde(transparent)] -pub struct SecretKey { - /// Hex-encoded secret key (64 characters) - pub hex: String, -} - -impl SecretKey { - /// Create a new SecretKey from hex string - pub fn from_hex(hex: String) -> Result { - // Validate hex string length (should be 64 characters for 32 bytes) - if hex.len() != 64 { - return Err(FfiError::InvalidHex { - msg: "Secret key hex must be exactly 64 characters (32 bytes)".to_string(), - }); - } - - // Validate hex format - if !hex.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(FfiError::InvalidHex { - msg: "Secret key hex contains invalid characters".to_string(), - }); - } - - Ok(Self { hex }) - } - - /// Generate a random secret key - pub fn random() -> Self { - use cdk::nuts::SecretKey as CdkSecretKey; - let secret_key = CdkSecretKey::generate(); - Self { - hex: secret_key.to_secret_hex(), - } - } -} - -impl From for cdk::nuts::SecretKey { - fn from(key: SecretKey) -> Self { - // This will panic if hex is invalid, but we validate in from_hex() - cdk::nuts::SecretKey::from_hex(&key.hex).expect("Invalid secret key hex") - } -} - -impl From for SecretKey { - fn from(key: cdk::nuts::SecretKey) -> Self { - Self { - hex: key.to_secret_hex(), - } - } -} - /// FFI-compatible Receive options #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] 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 @@ -260,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, } @@ -271,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-integration-tests/tests/integration_tests_pure.rs b/crates/cdk-integration-tests/tests/integration_tests_pure.rs index 9ab1fb003..0e59f4f38 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-redb/src/error.rs b/crates/cdk-redb/src/error.rs index 195e024bf..9f02d9b5b 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), diff --git a/crates/cdk-redb/src/wallet/migrations.rs b/crates/cdk-redb/src/wallet/migrations.rs index 948e9ab1c..f00982ef8 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 f25c712e2..2161f0d46 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())?; } @@ -895,4 +905,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/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 000000000..c31dbb1ff --- /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 000000000..d98364c1f --- /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 643716136..6dc1275b0 100644 --- a/crates/cdk-sql-common/src/wallet/mod.rs +++ b/crates/cdk-sql-common/src/wallet/mod.rs @@ -652,6 +652,85 @@ 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 pubkey, secret_key + FROM p2pk_signing_key + "#, + )? + .fetch_all(&*conn) + .await? + .into_iter() + .map(|row| { + 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::, Error>>()?) + } + + 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/Cargo.toml b/crates/cdk/Cargo.toml index c21dc59d9..4b4d187c7 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/mod.rs b/crates/cdk/src/wallet/mod.rs index 5f2319f25..af49d36f0 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -671,6 +671,30 @@ 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 2d0334b4f..78c441f32 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,11 +51,16 @@ impl Wallet { }) .collect::, _>>()?; - let p2pk_signing_keys: HashMap = opts - .p2pk_signing_keys - .iter() - .map(|s| (s.x_only_public_key(&SECP256K1).0, s)) - .collect(); + // 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(); + + for sk in &opts.p2pk_signing_keys { + let (x_only, _) = sk.x_only_public_key(&SECP256K1); + p2pk_signing_keys + .entry(x_only) + .or_insert_with(|| sk.clone()); + } for proof in &mut proofs { // Verify that proof DLEQ is valid @@ -94,8 +99,20 @@ 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())?; + 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).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())?; } } @@ -123,7 +140,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())? } } }