diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json index 16263fdd9..2cb9e1ba9 100644 --- a/crates/sage-api/endpoints.json +++ b/crates/sage-api/endpoints.json @@ -98,5 +98,6 @@ "update_nft_collection": true, "redownload_nft": true, "increase_derivation_index": true, - "is_asset_owned": true + "is_asset_owned": true, + "change_password": false } diff --git a/crates/sage-api/src/requests/action_system.rs b/crates/sage-api/src/requests/action_system.rs index dee2a403d..1af5ac3ef 100644 --- a/crates/sage-api/src/requests/action_system.rs +++ b/crates/sage-api/src/requests/action_system.rs @@ -23,6 +23,10 @@ pub struct CreateTransaction { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/sage-api/src/requests/actions.rs b/crates/sage-api/src/requests/actions.rs index 06e29c222..c1f19e2eb 100644 --- a/crates/sage-api/src/requests/actions.rs +++ b/crates/sage-api/src/requests/actions.rs @@ -192,7 +192,7 @@ pub struct RedownloadNftResponse {} description = "Increase the derivation index to generate more addresses for the wallet." ) )] -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct IncreaseDerivationIndex { @@ -205,6 +205,10 @@ pub struct IncreaseDerivationIndex { /// The target derivation index to increase to #[cfg_attr(feature = "openapi", schema(example = 100))] pub index: u32, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response after increasing the derivation index diff --git a/crates/sage-api/src/requests/keys.rs b/crates/sage-api/src/requests/keys.rs index 573bf53c4..c748bb5be 100644 --- a/crates/sage-api/src/requests/keys.rs +++ b/crates/sage-api/src/requests/keys.rs @@ -177,6 +177,10 @@ pub struct ImportKey { #[serde(default)] #[cfg_attr(feature = "openapi", schema(nullable = true))] pub emoji: Option, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } fn yes() -> bool { @@ -375,13 +379,17 @@ pub struct GetKeyResponse { description = "Retrieve the secret key (mnemonic) for a wallet. Requires authentication." ) )] -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct GetSecretKey { /// Wallet fingerprint #[cfg_attr(feature = "openapi", schema(example = 1_234_567_890))] pub fingerprint: u32, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response with secret key information @@ -398,6 +406,36 @@ pub struct GetSecretKeyResponse { pub secrets: Option, } +/// Change the password for a wallet's secret key +#[cfg_attr( + feature = "openapi", + crate::openapi_attr( + tag = "Authentication & Keys", + description = "Change the password used to encrypt a wallet's secret key." + ) +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct ChangePassword { + /// Wallet fingerprint + pub fingerprint: u32, + /// Current password (empty string if no password is set) + pub old_password: String, + /// New password (empty string to remove password protection) + pub new_password: String, +} + +/// Response after changing the password +#[cfg_attr( + feature = "openapi", + crate::openapi_attr(tag = "Authentication & Keys") +)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct ChangePasswordResponse {} + /// List all custom theme NFTs #[cfg_attr( feature = "openapi", diff --git a/crates/sage-api/src/requests/offers.rs b/crates/sage-api/src/requests/offers.rs index bfdf89886..ab15b8cf1 100644 --- a/crates/sage-api/src/requests/offers.rs +++ b/crates/sage-api/src/requests/offers.rs @@ -40,6 +40,10 @@ pub struct MakeOffer { #[serde(default)] #[cfg_attr(feature = "openapi", schema(nullable = true))] pub coin_ids: Option>, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Asset amount in an offer @@ -92,6 +96,10 @@ pub struct TakeOffer { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response with accepted offer details @@ -307,6 +315,10 @@ pub struct CancelOffer { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } pub type CancelOfferResponse = TransactionResponse; @@ -332,6 +344,10 @@ pub struct CancelOffers { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } pub type CancelOffersResponse = TransactionResponse; diff --git a/crates/sage-api/src/requests/transactions.rs b/crates/sage-api/src/requests/transactions.rs index 50576f91d..06d281208 100644 --- a/crates/sage-api/src/requests/transactions.rs +++ b/crates/sage-api/src/requests/transactions.rs @@ -33,6 +33,10 @@ pub struct SendXch { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Send XCH to multiple addresses @@ -61,6 +65,10 @@ pub struct BulkSendXch { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Combine multiple coins into one @@ -84,6 +92,10 @@ pub struct Combine { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Split coins into multiple smaller coins @@ -109,6 +121,10 @@ pub struct Split { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Automatically combine XCH coins @@ -134,6 +150,10 @@ pub struct AutoCombineXch { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response for auto-combine XCH @@ -175,6 +195,10 @@ pub struct AutoCombineCat { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response for auto-combine CAT @@ -216,6 +240,10 @@ pub struct IssueCat { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Send CAT tokens to an address @@ -254,6 +282,10 @@ pub struct SendCat { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Send CAT tokens to multiple addresses @@ -288,6 +320,10 @@ pub struct BulkSendCat { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } fn yes() -> bool { @@ -315,6 +351,10 @@ pub struct MultiSend { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Individual payment in a multi-send transaction @@ -357,6 +397,10 @@ pub struct CreateDid { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Mint multiple NFTs in one transaction @@ -381,6 +425,10 @@ pub struct BulkMintNfts { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response for bulk NFT minting @@ -472,6 +520,10 @@ pub struct TransferNfts { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Add a URI to an NFT @@ -499,6 +551,10 @@ pub struct AddNftUri { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Type of NFT URI @@ -537,6 +593,10 @@ pub struct AssignNftsToDid { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Transfer DIDs to a new address @@ -566,6 +626,10 @@ pub struct TransferDids { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Normalize DIDs to latest state @@ -589,6 +653,10 @@ pub struct NormalizeDids { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Asset specification for options @@ -628,6 +696,10 @@ pub struct MintOption { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response for minting an option @@ -665,6 +737,10 @@ pub struct ExerciseOptions { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Transfer options to another address @@ -694,6 +770,10 @@ pub struct TransferOptions { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Send CAT tokens to an address @@ -717,6 +797,10 @@ pub struct FinalizeClawback { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub auto_submit: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Sign coin spends to create a transaction @@ -741,6 +825,10 @@ pub struct SignCoinSpends { #[serde(default)] #[cfg_attr(feature = "openapi", schema(default = false))] pub partial: bool, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response with signed spend bundle diff --git a/crates/sage-api/src/requests/wallet_connect.rs b/crates/sage-api/src/requests/wallet_connect.rs index 0e70825a9..b64cfcb72 100644 --- a/crates/sage-api/src/requests/wallet_connect.rs +++ b/crates/sage-api/src/requests/wallet_connect.rs @@ -147,6 +147,10 @@ pub struct SignMessageWithPublicKey { pub message: String, /// Public key to use for signing pub public_key: String, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response with message signature @@ -234,6 +238,10 @@ pub struct SignMessageByAddress { pub message: String, /// Address whose key to use pub address: String, + /// Password for signing (required if wallet is password-protected) + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub password: Option, } /// Response with signed message diff --git a/crates/sage-api/src/types/key_info.rs b/crates/sage-api/src/types/key_info.rs index 36dd3fead..6a8bf86e5 100644 --- a/crates/sage-api/src/types/key_info.rs +++ b/crates/sage-api/src/types/key_info.rs @@ -9,6 +9,7 @@ pub struct KeyInfo { pub public_key: String, pub kind: KeyKind, pub has_secrets: bool, + pub has_password: bool, pub network_id: String, pub emoji: Option, } diff --git a/crates/sage-config/src/old.rs b/crates/sage-config/src/old.rs index 98fb7bc8f..3ef1bda69 100644 --- a/crates/sage-config/src/old.rs +++ b/crates/sage-config/src/old.rs @@ -155,6 +155,7 @@ pub fn migrate_config(old: OldConfig) -> Result<(Config, WalletConfig), ParseInt delta_sync: None, emoji: None, change_address: None, + password_protected: false, }); } diff --git a/crates/sage-config/src/wallet.rs b/crates/sage-config/src/wallet.rs index a59a6b13d..ee022cae0 100644 --- a/crates/sage-config/src/wallet.rs +++ b/crates/sage-config/src/wallet.rs @@ -31,6 +31,9 @@ pub struct Wallet { pub emoji: Option, #[serde(skip_serializing_if = "Option::is_none")] pub change_address: Option, + #[serde(default)] + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub password_protected: bool, } impl Wallet { @@ -48,6 +51,7 @@ impl Default for Wallet { delta_sync: None, emoji: None, change_address: None, + password_protected: false, } } } @@ -68,6 +72,7 @@ mod tests { change_address: Some( "xch1dtfukqqka3ftqtdlhmc5spc5vd44h7ejrtnjcewxlueam5yrnnqqyczg8t".to_string(), ), + password_protected: false, } } diff --git a/crates/sage-keychain/src/error.rs b/crates/sage-keychain/src/error.rs index a40e0eb97..30d8fc150 100644 --- a/crates/sage-keychain/src/error.rs +++ b/crates/sage-keychain/src/error.rs @@ -22,4 +22,10 @@ pub enum KeychainError { #[error("Key already exists")] KeyExists, + + #[error("Key not found")] + KeyNotFound, + + #[error("No secret key")] + NoSecretKey, } diff --git a/crates/sage-keychain/src/keychain.rs b/crates/sage-keychain/src/keychain.rs index 235f143a8..b8e8da728 100644 --- a/crates/sage-keychain/src/keychain.rs +++ b/crates/sage-keychain/src/keychain.rs @@ -1,15 +1,14 @@ use std::collections::HashMap; -use bip39::Mnemonic; -use chia_wallet_sdk::prelude::*; -use rand::SeedableRng; -use rand_chacha::ChaCha20Rng; - use crate::{ KeychainError, encrypt::{decrypt, encrypt}, key_data::{KeyData, SecretKeyData}, }; +use bip39::Mnemonic; +use chia_wallet_sdk::prelude::*; +use rand::SeedableRng; +use rand_chacha::ChaCha20Rng; #[derive(Debug)] pub struct Keychain { @@ -28,7 +27,7 @@ impl Default for Keychain { impl Keychain { pub fn from_bytes(data: &[u8]) -> Result { - let keys = bincode::deserialize(data)?; + let keys: HashMap = bincode::deserialize(data)?; Ok(Self { rng: ChaCha20Rng::from_entropy(), keys, @@ -89,6 +88,17 @@ impl Keychain { } } + /// Probes whether the key is password-protected by attempting to + /// decrypt with an empty password. Returns `false` for public keys. + pub fn is_password_protected(&self, fingerprint: u32) -> bool { + match self.keys.get(&fingerprint) { + Some(KeyData::Secret { encrypted, .. }) => { + decrypt::(encrypted, b"").is_err() + } + _ => false, + } + } + pub fn has_secret_key(&self, fingerprint: u32) -> bool { let Some(key_data) = self.keys.get(&fingerprint) else { return false; @@ -175,4 +185,119 @@ impl Keychain { Ok(fingerprint) } + + pub fn change_password( + &mut self, + fingerprint: u32, + old_password: &[u8], + new_password: &[u8], + ) -> Result<(), KeychainError> { + let key_data = self + .keys + .get(&fingerprint) + .ok_or(KeychainError::KeyNotFound)?; + + let (entropy, master_pk, secret_data) = match key_data { + KeyData::Public { .. } => return Err(KeychainError::NoSecretKey), + KeyData::Secret { + entropy, + master_pk, + encrypted, + .. + } => { + let data = decrypt::(encrypted, old_password)?; + (*entropy, *master_pk, data) + } + }; + + let encrypted = encrypt(new_password, &mut self.rng, &secret_data)?; + + self.keys.insert( + fingerprint, + KeyData::Secret { + master_pk, + entropy, + encrypted, + }, + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bip39::Mnemonic; + + #[test] + fn test_change_password() { + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + let fingerprint = keychain.add_mnemonic(&mnemonic, b"").unwrap(); + + keychain + .change_password(fingerprint, b"", b"secret123") + .unwrap(); + assert!(keychain.extract_secrets(fingerprint, b"").is_err()); + + let (mnemonic_out, Some(_sk)) = + keychain.extract_secrets(fingerprint, b"secret123").unwrap() + else { + panic!("expected secret key"); + }; + assert!(mnemonic_out.is_some()); + + keychain + .change_password(fingerprint, b"secret123", b"newpass") + .unwrap(); + assert!(keychain.extract_secrets(fingerprint, b"secret123").is_err()); + let (_m, Some(_sk)) = keychain.extract_secrets(fingerprint, b"newpass").unwrap() else { + panic!("expected secret key"); + }; + + keychain + .change_password(fingerprint, b"newpass", b"") + .unwrap(); + let (_m, Some(_sk)) = keychain.extract_secrets(fingerprint, b"").unwrap() else { + panic!("expected secret key"); + }; + } + + #[test] + fn test_change_password_wrong_old_password() { + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + let fingerprint = keychain.add_mnemonic(&mnemonic, b"correct").unwrap(); + assert!( + keychain + .change_password(fingerprint, b"wrong", b"newpass") + .is_err() + ); + let (_m, Some(_sk)) = keychain.extract_secrets(fingerprint, b"correct").unwrap() else { + panic!("expected secret key"); + }; + } + + #[test] + fn test_change_password_public_key_fails() { + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + let master_sk = SecretKey::from_seed(&mnemonic.to_seed("")); + let master_pk = master_sk.public_key(); + let fingerprint = keychain.add_public_key(&master_pk).unwrap(); + assert!(keychain.change_password(fingerprint, b"", b"pass").is_err()); + } + + #[test] + fn test_serialization_roundtrip_with_password() { + let mut keychain = Keychain::default(); + let mnemonic = Mnemonic::from_entropy(&[0u8; 16]).unwrap(); + let fingerprint = keychain.add_mnemonic(&mnemonic, b"pass123").unwrap(); + let bytes = keychain.to_bytes().unwrap(); + let keychain2 = Keychain::from_bytes(&bytes).unwrap(); + let (_m, Some(_sk)) = keychain2.extract_secrets(fingerprint, b"pass123").unwrap() else { + panic!("expected secret key"); + }; + } } diff --git a/crates/sage-rpc/src/tests.rs b/crates/sage-rpc/src/tests.rs index a04617375..bbc1b1a6e 100644 --- a/crates/sage-rpc/src/tests.rs +++ b/crates/sage-rpc/src/tests.rs @@ -19,7 +19,10 @@ use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha8Rng; use rustls::crypto::aws_lc_rs::default_provider; use sage::Sage; -use sage_api::{Amount, GetKey, GetPeers, GetSyncStatus, GetVersion, ImportKey, Login, SendXch}; +use sage_api::{ + Amount, ChangePassword, GetKey, GetPeers, GetSecretKey, GetSyncStatus, GetVersion, ImportKey, + Login, SendXch, +}; use sage_api_macro::impl_endpoints; use sage_wallet::{SyncCommand, SyncEvent}; use serde::{Serialize, de::DeserializeOwned}; @@ -137,6 +140,7 @@ impl TestApp { save_secrets: true, login: true, emoji: None, + password: None, }) .await? .fingerprint; @@ -172,6 +176,46 @@ impl TestApp { self.consume_until(|event| matches!(event, SyncEvent::PuzzleBatchSynced)) .await; } + + async fn setup_bls_with_password(&mut self, balance: u64, password: &str) -> Result { + let mnemonic = Mnemonic::from_entropy(&self.rng.r#gen::<[u8; 16]>())?; + + if balance > 0 { + let master_sk = SecretKey::from_seed(&mnemonic.to_seed("")); + let p2_puzzle_hash = StandardArgs::curry_tree_hash( + master_to_wallet_unhardened(&master_sk, 0) + .public_key() + .derive_synthetic(), + ); + + self.sim.lock().await.create_block(); + + self.sim + .lock() + .await + .new_coin(p2_puzzle_hash.into(), balance); + } + + let fingerprint = self + .import_key(ImportKey { + name: "Alice".to_string(), + key: mnemonic.to_string(), + derivation_index: 0, + hardened: None, + unhardened: None, + save_secrets: true, + login: true, + emoji: None, + password: Some(password.to_string()), + }) + .await? + .fingerprint; + + self.consume_until(|event| matches!(event, SyncEvent::Subscribed)) + .await; + + Ok(fingerprint) + } } impl_endpoints! { @@ -255,6 +299,7 @@ async fn test_send_xch() -> Result<()> { memos: vec![], clawback: None, auto_submit: true, + password: None, }) .await?; @@ -280,3 +325,136 @@ async fn test_send_xch() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn test_change_password() -> Result<()> { + let mut app = TestApp::new().await?; + let fingerprint = app.setup_bls(0).await?; + + // Initially no password + let key = app + .get_key(GetKey { + fingerprint: Some(fingerprint), + }) + .await? + .key + .unwrap(); + assert!(!key.has_password); + + // Set password + app.change_password(ChangePassword { + fingerprint, + old_password: "".to_string(), + new_password: "secret".to_string(), + }) + .await?; + + // Now has_password should be true + let key = app + .get_key(GetKey { + fingerprint: Some(fingerprint), + }) + .await? + .key + .unwrap(); + assert!(key.has_password); + + // Getting secret without password should fail + let result = app + .get_secret_key(GetSecretKey { + fingerprint, + password: None, + }) + .await; + assert!(result.is_err()); + + // Getting secret with correct password should work + let result = app + .get_secret_key(GetSecretKey { + fingerprint, + password: Some("secret".to_string()), + }) + .await?; + assert!(result.secrets.is_some()); + + Ok(()) +} + +#[tokio::test] +async fn test_password_protected_send() -> Result<()> { + let mut app = TestApp::new().await?; + + let alice = app.setup_bls_with_password(1000, "secret").await?; + + let _bob = app.setup_bls(1000).await?; + let bob_address = app.get_sync_status(GetSyncStatus {}).await?.receive_address; + + app.login(Login { fingerprint: alice }).await?; + app.wait_for_coins().await; + + // Send without password should fail (auto_submit requires signing) + let result = app + .send_xch(SendXch { + address: bob_address.clone(), + amount: Amount::u64(100), + fee: Amount::u64(0), + memos: vec![], + clawback: None, + auto_submit: true, + password: None, + }) + .await; + assert!(result.is_err()); + + // Send with correct password should succeed + app.send_xch(SendXch { + address: bob_address, + amount: Amount::u64(100), + fee: Amount::u64(0), + memos: vec![], + clawback: None, + auto_submit: true, + password: Some("secret".to_string()), + }) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn test_password_protected_import() -> Result<()> { + let mut app = TestApp::new().await?; + + let fingerprint = app.setup_bls_with_password(0, "mypassword").await?; + + // Verify has_password is true + let key = app + .get_key(GetKey { + fingerprint: Some(fingerprint), + }) + .await? + .key + .unwrap(); + assert!(key.has_password); + assert!(key.has_secrets); + + // Getting secret with wrong password should fail + let result = app + .get_secret_key(GetSecretKey { + fingerprint, + password: Some("wrongpassword".to_string()), + }) + .await; + assert!(result.is_err()); + + // Getting secret with correct password should work + let result = app + .get_secret_key(GetSecretKey { + fingerprint, + password: Some("mypassword".to_string()), + }) + .await?; + assert!(result.secrets.is_some()); + + Ok(()) +} diff --git a/crates/sage/src/endpoints/action_system.rs b/crates/sage/src/endpoints/action_system.rs index 1b0b86326..9455a8342 100644 --- a/crates/sage/src/endpoints/action_system.rs +++ b/crates/sage/src/endpoints/action_system.rs @@ -15,6 +15,7 @@ use crate::{ impl Sage { pub async fn create_transaction(&self, req: CreateTransaction) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let sender_puzzle_hash = wallet.change_p2_puzzle_hash().await?; @@ -146,7 +147,8 @@ impl Sage { let coin_spends = ctx.take(); - self.transact_with(coin_spends, req.auto_submit, info).await + self.transact_with(coin_spends, req.auto_submit, info, &password) + .await } } diff --git a/crates/sage/src/endpoints/actions.rs b/crates/sage/src/endpoints/actions.rs index 3e333ade2..6a8e14b6d 100644 --- a/crates/sage/src/endpoints/actions.rs +++ b/crates/sage/src/endpoints/actions.rs @@ -189,6 +189,7 @@ impl Sage { &self, req: IncreaseDerivationIndex, ) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let hardened = req.hardened.is_none_or(|hardened| hardened); @@ -197,8 +198,9 @@ impl Sage { let mut derivations = Vec::new(); if hardened { - let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, b"")? + let (_mnemonic, Some(master_sk)) = self + .keychain + .extract_secrets(wallet.fingerprint, &password)? else { return Err(Error::NoSigningKey); }; diff --git a/crates/sage/src/endpoints/keys.rs b/crates/sage/src/endpoints/keys.rs index 7f7908195..a9ed00838 100644 --- a/crates/sage/src/endpoints/keys.rs +++ b/crates/sage/src/endpoints/keys.rs @@ -14,11 +14,11 @@ use chia_wallet_sdk::{ use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; use sage_api::{ - DeleteDatabase, DeleteDatabaseResponse, DeleteKey, DeleteKeyResponse, GenerateMnemonic, - GenerateMnemonicResponse, GetKey, GetKeyResponse, GetKeys, GetKeysResponse, GetSecretKey, - GetSecretKeyResponse, ImportKey, ImportKeyResponse, KeyInfo, KeyKind, Login, LoginResponse, - Logout, LogoutResponse, RenameKey, RenameKeyResponse, Resync, ResyncResponse, SecretKeyInfo, - SetWalletEmoji, SetWalletEmojiResponse, + ChangePassword, ChangePasswordResponse, DeleteDatabase, DeleteDatabaseResponse, DeleteKey, + DeleteKeyResponse, GenerateMnemonic, GenerateMnemonicResponse, GetKey, GetKeyResponse, GetKeys, + GetKeysResponse, GetSecretKey, GetSecretKeyResponse, ImportKey, ImportKeyResponse, KeyInfo, + KeyKind, Login, LoginResponse, Logout, LogoutResponse, RenameKey, RenameKeyResponse, Resync, + ResyncResponse, SecretKeyInfo, SetWalletEmoji, SetWalletEmojiResponse, }; use sage_config::Wallet; use sage_database::{Database, Derivation}; @@ -122,6 +122,7 @@ impl Sage { } pub async fn import_key(&mut self, req: ImportKey) -> Result { + let password_bytes = req.password.unwrap_or_default().into_bytes(); let mut key_hex = req.key.as_str(); if key_hex.starts_with("0x") || key_hex.starts_with("0X") { @@ -138,7 +139,7 @@ impl Sage { let master_pk = master_sk.public_key(); let fingerprint = if req.save_secrets { - self.keychain.add_secret_key(&master_sk, b"")? + self.keychain.add_secret_key(&master_sk, &password_bytes)? } else { self.keychain.add_public_key(&master_pk)? }; @@ -175,7 +176,7 @@ impl Sage { let master_sk = SecretKey::from_seed(&mnemonic.to_seed("")); let master_pk = master_sk.public_key(); let fingerprint = if req.save_secrets { - self.keychain.add_mnemonic(&mnemonic, b"")? + self.keychain.add_mnemonic(&mnemonic, &password_bytes)? } else { self.keychain.add_public_key(&master_pk)? }; @@ -187,6 +188,7 @@ impl Sage { name: req.name, fingerprint, emoji: req.emoji, + password_protected: !password_bytes.is_empty(), ..Default::default() }); self.config.global.fingerprint = Some(fingerprint); @@ -343,6 +345,7 @@ impl Sage { public_key: hex::encode(master_pk.to_bytes()), kind: KeyKind::Bls, has_secrets: self.keychain.has_secret_key(fingerprint), + has_password: wallet_config.password_protected, network_id, emoji: wallet_config.emoji, }), @@ -350,7 +353,9 @@ impl Sage { } pub fn get_secret_key(&self, req: GetSecretKey) -> Result { - let (mnemonic, Some(secret_key)) = self.keychain.extract_secrets(req.fingerprint, b"")? + let password = req.password.unwrap_or_default().into_bytes(); + let (mnemonic, Some(secret_key)) = + self.keychain.extract_secrets(req.fingerprint, &password)? else { return Ok(GetSecretKeyResponse { secrets: None }); }; @@ -363,6 +368,16 @@ impl Sage { }) } + pub fn change_password(&mut self, req: ChangePassword) -> Result { + let old_password = req.old_password.into_bytes(); + let new_password = req.new_password.into_bytes(); + self.keychain + .change_password(req.fingerprint, &old_password, &new_password)?; + self.save_keychain()?; + self.set_password_protected(req.fingerprint, !new_password.is_empty())?; + Ok(ChangePasswordResponse {}) + } + pub fn get_keys(&self, _req: GetKeys) -> Result { let mut keys = Vec::new(); @@ -377,6 +392,7 @@ impl Sage { public_key: hex::encode(master_pk.to_bytes()), kind: KeyKind::Bls, has_secrets: self.keychain.has_secret_key(wallet.fingerprint), + has_password: wallet.password_protected, network_id: wallet.network.clone().unwrap_or_else(|| self.network_id()), emoji: wallet.emoji.clone(), }); diff --git a/crates/sage/src/endpoints/offers.rs b/crates/sage/src/endpoints/offers.rs index 974898e5c..1b652cc61 100644 --- a/crates/sage/src/endpoints/offers.rs +++ b/crates/sage/src/endpoints/offers.rs @@ -39,6 +39,7 @@ struct AssetToOffer { impl Sage { pub async fn make_offer(&self, req: MakeOffer) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let selected_coin_ids = parse_coin_ids(req.coin_ids.unwrap_or_default())?; @@ -166,8 +167,9 @@ impl Sage { .make_offer(offered, requested, req.expires_at_second) .await?; - let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, b"")? + let (_mnemonic, Some(master_sk)) = self + .keychain + .extract_secrets(wallet.fingerprint, &password)? else { return Err(Error::NoSigningKey); }; @@ -197,6 +199,7 @@ impl Sage { } pub async fn take_offer(&self, req: TakeOffer) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let offer = decode_offer(&req.offer)?; @@ -204,8 +207,9 @@ impl Sage { let unsigned = wallet.take_offer(offer, fee).await?; - let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, b"")? + let (_mnemonic, Some(master_sk)) = self + .keychain + .extract_secrets(wallet.fingerprint, &password)? else { return Err(Error::NoSigningKey); }; @@ -699,6 +703,7 @@ impl Sage { } pub async fn cancel_offer(&self, req: CancelOffer) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let offer_id = parse_offer_id(req.offer_id)?; let fee = parse_amount(req.fee)?; @@ -710,10 +715,11 @@ impl Sage { let offer = decode_offer(&row.encoded_offer)?; let coin_spends = wallet.cancel_offer(offer, fee).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn cancel_offers(&self, req: CancelOffers) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; @@ -735,6 +741,6 @@ impl Sage { coin_spends.extend(spends); } - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } } diff --git a/crates/sage/src/endpoints/transactions.rs b/crates/sage/src/endpoints/transactions.rs index 69a78315f..aef8b645a 100644 --- a/crates/sage/src/endpoints/transactions.rs +++ b/crates/sage/src/endpoints/transactions.rs @@ -28,6 +28,7 @@ use crate::{ impl Sage { pub async fn send_xch(&self, req: SendXch) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let puzzle_hash = self.parse_address(req.address)?; let amount = parse_amount(req.amount)?; @@ -37,10 +38,11 @@ impl Sage { let coin_spends = wallet .send_xch(vec![(puzzle_hash, amount)], fee, memos, req.clawback) .await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn bulk_send_xch(&self, req: BulkSendXch) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let amount = parse_amount(req.amount)?; @@ -55,19 +57,21 @@ impl Sage { let memos = parse_memos(req.memos)?; let coin_spends = wallet.send_xch(amounts, fee, memos, None).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn combine(&self, req: Combine) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; let coin_ids = parse_coin_ids(req.coin_ids)?; let coin_spends = wallet.combine(coin_ids, fee).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn auto_combine_xch(&self, req: AutoCombineXch) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; let max_amount = req.max_coin_amount.map(parse_amount).transpose()?; @@ -95,7 +99,9 @@ impl Sage { let coin_spends = wallet .combine(coins.iter().map(Coin::coin_id).collect(), fee) .await?; - let response = self.transact(coin_spends, req.auto_submit).await?; + let response = self + .transact(coin_spends, req.auto_submit, &password) + .await?; Ok(AutoCombineXchResponse { coin_ids, @@ -105,6 +111,7 @@ impl Sage { } pub async fn split(&self, req: Split) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; let coin_ids = parse_coin_ids(req.coin_ids)?; @@ -112,10 +119,11 @@ impl Sage { let coin_spends = wallet .split(coin_ids, req.output_count as usize, fee) .await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn auto_combine_cat(&self, req: AutoCombineCat) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; let asset_id = parse_asset_id(req.asset_id)?; @@ -144,7 +152,9 @@ impl Sage { let coin_spends = wallet .combine(cats.iter().map(|row| row.coin.coin_id()).collect(), fee) .await?; - let response = self.transact(coin_spends, req.auto_submit).await?; + let response = self + .transact(coin_spends, req.auto_submit, &password) + .await?; Ok(AutoCombineCatResponse { coin_ids, @@ -154,6 +164,7 @@ impl Sage { } pub async fn issue_cat(&self, req: IssueCat) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let amount = parse_amount(req.amount)?; let fee = parse_amount(req.fee)?; @@ -176,10 +187,11 @@ impl Sage { .await?; tx.commit().await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn send_cat(&self, req: SendCat) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let asset_id = parse_asset_id(req.asset_id)?; let puzzle_hash = self.parse_address(req.address)?; @@ -197,10 +209,11 @@ impl Sage { req.clawback, ) .await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn bulk_send_cat(&self, req: BulkSendCat) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let asset_id = parse_asset_id(req.asset_id)?; @@ -218,10 +231,11 @@ impl Sage { let coin_spends = wallet .send_cat(asset_id, amounts, fee, req.include_hint, memos, None) .await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn multi_send(&self, req: MultiSend) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let mut payments = Vec::with_capacity(req.payments.len()); @@ -247,10 +261,11 @@ impl Sage { let fee = parse_amount(req.fee)?; let coin_spends = wallet.multi_send(payments, fee).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn create_did(&self, req: CreateDid) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; @@ -272,11 +287,17 @@ impl Sage { }) .await?; - self.transact_with(coin_spends, req.auto_submit, ConfirmationInfo::default()) - .await + self.transact_with( + coin_spends, + req.auto_submit, + ConfirmationInfo::default(), + &password, + ) + .await } pub async fn bulk_mint_nfts(&self, req: BulkMintNfts) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; let did_id = parse_did_id(req.did_id)?; @@ -297,7 +318,7 @@ impl Sage { } let response = self - .transact_with(coin_spends, req.auto_submit, info) + .transact_with(coin_spends, req.auto_submit, info, &password) .await?; Ok(BulkMintNftsResponse { @@ -308,6 +329,7 @@ impl Sage { } pub async fn transfer_nfts(&self, req: TransferNfts) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let nft_ids = req .nft_ids @@ -320,10 +342,11 @@ impl Sage { let coin_spends = wallet .transfer_nfts(nft_ids, puzzle_hash, fee, req.clawback) .await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn add_nft_uri(&self, req: AddNftUri) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let nft_id = parse_nft_id(req.nft_id)?; let fee = parse_amount(req.fee)?; @@ -344,10 +367,11 @@ impl Sage { }; let coin_spends = wallet.add_nft_uri(nft_id, fee, uri).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn assign_nfts_to_did(&self, req: AssignNftsToDid) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let nft_ids = req .nft_ids @@ -358,10 +382,11 @@ impl Sage { let fee = parse_amount(req.fee)?; let coin_spends = wallet.assign_nfts(nft_ids, did_id, fee).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn transfer_dids(&self, req: TransferDids) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let did_ids = req .did_ids @@ -374,10 +399,11 @@ impl Sage { let coin_spends = wallet .transfer_dids(did_ids, puzzle_hash, fee, req.clawback) .await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn normalize_dids(&self, req: NormalizeDids) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let did_ids = req .did_ids @@ -387,10 +413,11 @@ impl Sage { let fee = parse_amount(req.fee)?; let coin_spends = wallet.normalize_dids(did_ids, fee).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn mint_option(&self, req: MintOption) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let fee = parse_amount(req.fee)?; @@ -408,7 +435,9 @@ impl Sage { ) .await?; - let response = self.transact(coin_spends, req.auto_submit).await?; + let response = self + .transact(coin_spends, req.auto_submit, &password) + .await?; Ok(MintOptionResponse { option_id: Address::new(option.info.launcher_id, "option".to_string()).encode()?, @@ -451,6 +480,7 @@ impl Sage { } pub async fn transfer_options(&self, req: TransferOptions) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let option_ids = req .option_ids @@ -463,10 +493,11 @@ impl Sage { let coin_spends = wallet .transfer_options(option_ids, puzzle_hash, fee, req.clawback) .await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn exercise_options(&self, req: ExerciseOptions) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let option_ids = req .option_ids @@ -476,25 +507,27 @@ impl Sage { let fee = parse_amount(req.fee)?; let coin_spends = wallet.exercise_options(option_ids, fee).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn finalize_clawback(&self, req: FinalizeClawback) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let coin_ids = parse_coin_ids(req.coin_ids)?; let fee = parse_amount(req.fee)?; let coin_spends = wallet.finalize_clawback(coin_ids, fee).await?; - self.transact(coin_spends, req.auto_submit).await + self.transact(coin_spends, req.auto_submit, &password).await } pub async fn sign_coin_spends(&self, req: SignCoinSpends) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let coin_spends = req .coin_spends .into_iter() .map(rust_spend) .collect::>>()?; - let spend_bundle = self.sign(coin_spends, req.partial).await?; + let spend_bundle = self.sign(coin_spends, req.partial, &password).await?; let json_bundle = json_bundle(&spend_bundle); if req.auto_submit { @@ -534,9 +567,15 @@ impl Sage { &self, coin_spends: Vec, auto_submit: bool, + password: &[u8], ) -> Result { - self.transact_with(coin_spends, auto_submit, ConfirmationInfo::default()) - .await + self.transact_with( + coin_spends, + auto_submit, + ConfirmationInfo::default(), + password, + ) + .await } pub(crate) async fn transact_with( @@ -544,9 +583,10 @@ impl Sage { coin_spends: Vec, auto_submit: bool, info: ConfirmationInfo, + password: &[u8], ) -> Result { if auto_submit { - let spend_bundle = self.sign(coin_spends.clone(), false).await?; + let spend_bundle = self.sign(coin_spends.clone(), false, password).await?; self.submit(spend_bundle).await?; } diff --git a/crates/sage/src/endpoints/wallet_connect.rs b/crates/sage/src/endpoints/wallet_connect.rs index 24c2aedde..0f2c899f3 100644 --- a/crates/sage/src/endpoints/wallet_connect.rs +++ b/crates/sage/src/endpoints/wallet_connect.rs @@ -170,6 +170,7 @@ impl Sage { &self, req: SignMessageWithPublicKey, ) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let public_key = parse_public_key(req.public_key)?; @@ -177,8 +178,9 @@ impl Sage { return Err(Error::InvalidKey); }; - let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, b"")? + let (_mnemonic, Some(master_sk)) = self + .keychain + .extract_secrets(wallet.fingerprint, &password)? else { return Err(Error::NoSigningKey); }; @@ -205,6 +207,7 @@ impl Sage { &self, req: SignMessageByAddress, ) -> Result { + let password = req.password.unwrap_or_default().into_bytes(); let wallet = self.wallet()?; let p2_puzzle_hash = self.parse_address(req.address)?; @@ -216,8 +219,9 @@ impl Sage { return Err(Error::InvalidKey); }; - let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, b"")? + let (_mnemonic, Some(master_sk)) = self + .keychain + .extract_secrets(wallet.fingerprint, &password)? else { return Err(Error::NoSigningKey); }; diff --git a/crates/sage/src/error.rs b/crates/sage/src/error.rs index 043f7e0eb..052b1fb01 100644 --- a/crates/sage/src/error.rs +++ b/crates/sage/src/error.rs @@ -247,6 +247,7 @@ impl Error { | KeychainError::Bls(..) | KeychainError::Bip39(..) | KeychainError::Argon2(..) => ErrorKind::Internal, + KeychainError::KeyNotFound | KeychainError::NoSecretKey => ErrorKind::Unauthorized, }, Self::SqlxMigration(..) | Self::DatabaseVersionTooOld => ErrorKind::DatabaseMigration, Self::Send(..) diff --git a/crates/sage/src/sage.rs b/crates/sage/src/sage.rs index eca15d84a..7a0cde7cb 100644 --- a/crates/sage/src/sage.rs +++ b/crates/sage/src/sage.rs @@ -294,6 +294,12 @@ impl Sage { return Err(Error::UnknownFingerprint); }; + // Correct the password_protected flag if it drifted from actual state + if self.keychain.has_secret_key(fingerprint) { + let actual = self.keychain.is_password_protected(fingerprint); + self.set_password_protected(fingerprint, actual)?; + } + let intermediate_pk = master_to_wallet_unhardened_intermediate(&master_pk); let pool = self.connect_to_database(fingerprint).await?; @@ -529,6 +535,27 @@ impl Sage { Ok(()) } + /// Updates the wallet config's `password_protected` flag if it doesn't + /// match the actual state, and persists the change. + pub fn set_password_protected( + &mut self, + fingerprint: u32, + password_protected: bool, + ) -> Result<()> { + let wallet = self + .wallet_config + .wallets + .iter_mut() + .find(|w| w.fingerprint == fingerprint) + .ok_or(Error::UnknownFingerprint)?; + + if wallet.password_protected != password_protected { + wallet.password_protected = password_protected; + self.save_config()?; + } + Ok(()) + } + pub fn save_keychain(&self) -> Result<()> { fs::write(self.path.join("keys.bin"), self.keychain.to_bytes()?)?; Ok(()) diff --git a/crates/sage/src/utils/spends.rs b/crates/sage/src/utils/spends.rs index d9b19ee7f..a9c1b4237 100644 --- a/crates/sage/src/utils/spends.rs +++ b/crates/sage/src/utils/spends.rs @@ -8,11 +8,13 @@ impl Sage { &self, coin_spends: Vec, partial: bool, + password: &[u8], ) -> Result { let wallet = self.wallet()?; - let (_mnemonic, Some(master_sk)) = - self.keychain.extract_secrets(wallet.fingerprint, b"")? + let (_mnemonic, Some(master_sk)) = self + .keychain + .extract_secrets(wallet.fingerprint, password)? else { return Err(Error::NoSigningKey); }; diff --git a/docs/superpowers/specs/2026-03-15-password-protection-design.md b/docs/superpowers/specs/2026-03-15-password-protection-design.md new file mode 100644 index 000000000..8b5e9c6cd --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-password-protection-design.md @@ -0,0 +1,311 @@ +# Password Protection for Sage Wallet + +**Issue:** [xch-dev/sage#206](https://github.com/xch-dev/sage/issues/206) +**Date:** 2026-03-15 +**Status:** Implemented + +## Overview + +Add opt-in password protection to Sage wallet, requiring authentication for three categories of sensitive operations: displaying secrets, signing transactions/offers, and generating hardened keys. Biometric unlock (Touch ID, Face ID) is available as a standalone gate for wallets without passwords. Biometric and password are mutually exclusive — password takes precedence. + +## Design Decisions + +- **Per-operation authentication** — every protected operation prompts for the password. No session caching. +- **Opt-in** — existing wallets continue working without a password. Users can enable protection via "Set Password." +- **Per-key passwords** — each key in the keychain has its own password (or no password). This follows from the existing data model where each `KeyData::Secret` has its own `Encrypted` struct with its own salt. The frontend should use the active wallet's fingerprint to determine which key's password to prompt for. +- **Biometric is mutually exclusive with password** — biometric is a standalone gate for no-password wallets. If a wallet has a password, the password dialog is always shown regardless of biometric settings. The two never interact. + +## Architecture + +### Password Is Never Stored (Backend) + +The password is a transient input, not persisted state. The existing encryption infrastructure in `sage-keychain` handles everything: + +1. At key import (or password set): Argon2 derives a 256-bit AES key from `password + random 32-byte salt` +2. The wallet secret (mnemonic entropy or raw secret key) is encrypted with AES-256-GCM +3. `keys.bin` stores only `{ciphertext, nonce, salt}` — no password, no derived key +4. On each protected operation: user provides password, Argon2 re-derives the key, AES-GCM decrypts. Wrong password fails AES-GCM authentication. +5. Argon2 default parameters provide computational cost that mitigates brute-force attempts against the encrypted data at rest. + +### Password Sentinel Convention + +The empty byte string `b""` is the "no password" sentinel. This is what existing keys are encrypted with today. The convention is: + +- `Option` in request structs: `None` and `Some("")` both map to `b""` at the backend via `req.password.unwrap_or_default().into_bytes()` +- `ChangePassword` uses `String` (not `Option`): an empty `old_password` means the key currently has no password; an empty `new_password` removes password protection + +### Has-Password Indicator + +Add a `has_password: bool` field to the `KeyInfo` struct returned by `get_key()` / `get_keys()`. Determined by attempting a trial decryption with `b""` at key load time and caching the result, or by adding a `password_protected: bool` field to `KeyData::Secret`. + +Preferred approach: add `password_hint: Option` or a simple `password_protected: bool` to `KeyData::Secret`. This avoids trial decryption and is serialized into `keys.bin`. Set to `true` when a non-empty password is used at encryption time. Exposed via `KeyInfo` to the frontend. + +### Biometric Gate (Mobile) + +Biometric is a frontend-only concern, mutually exclusive with password. It serves as a standalone gate for wallets that do not have a password set. + +**Global setting:** Biometric unlock is a single global toggle (the existing `useLocalStorage('biometric', false)` flag). It is only visible on mobile when biometric hardware is available and enrolled. + +**Mutual exclusivity rule:** If a wallet has a password, the password dialog is always shown — biometric is irrelevant. Biometric only applies when `hasPassword` is false. + +**No keychain storage:** Passwords are never stored on device. Each password-protected operation prompts via the password dialog. There is no keychain bridge between biometric and password. + +**Biometric caching:** Standalone biometric prompts use a 5-minute cache (`performance.now()` monotonic clock) to avoid prompting repeatedly for rapid successive operations. + +## Protected Operations + +There are 7 code points where `b""` is passed to `extract_secrets` or `add_mnemonic`/`add_secret_key`, plus 2 encrypt-at-creation sites. However, because `sign()` is called through `transact()` and `transact_with()`, the password must flow through a much larger API surface. + +### 1. Display mnemonic/secret key (1 site) + +| Call site | Function | +| --------------------------------------- | ------------------ | +| `crates/sage/src/endpoints/keys.rs:353` | `get_secret_key()` | + +### 2. Sign transactions and offers + +The central signing path is: + +```text +endpoint method → transact() / transact_with() → sign() → extract_secrets() +``` + +**Direct `extract_secrets` call sites** (5 sites): + +| Call site | Function | +| ------------------------------------------------- | --------------------------------------------------------------- | +| `crates/sage/src/utils/spends.rs:15` | `sign()` — called by `transact_with()` and `sign_coin_spends()` | +| `crates/sage/src/endpoints/offers.rs:170` | `make_offer()` — calls `extract_secrets` directly | +| `crates/sage/src/endpoints/offers.rs:208` | `take_offer()` — calls `extract_secrets` directly | +| `crates/sage/src/endpoints/wallet_connect.rs:181` | `sign_message_with_public_key()` | +| `crates/sage/src/endpoints/wallet_connect.rs:220` | `sign_message_by_address()` | + +**Transaction endpoints that flow through `transact()` → `sign()`** (21 endpoints): + +`send_xch`, `bulk_send_xch`, `combine`, `auto_combine_xch`, `split`, `auto_combine_cat`, `issue_cat`, `send_cat`, `bulk_send_cat`, `multi_send`, `create_did`, `bulk_mint_nfts`, `transfer_nfts`, `add_nft_uri`, `assign_nfts_to_did`, `transfer_dids`, `normalize_dids`, `mint_option`, `transfer_options`, `exercise_options`, `finalize_clawback` + +Plus `cancel_offer`, `cancel_offers`, and `create_transaction` (action system) which also flow through `transact()` / `transact_with()`. + +### 3. Delete wallet key + +Password-protected wallets require password verification before deletion. Since the Rust `delete_key()` endpoint does not accept a password, verification is performed on the frontend by calling `get_secret_key()` first — if decryption fails, the delete is blocked. + +| Call site | Function | +| ------------------- | --------------------------------------------------------------- | +| `WalletCard.tsx:81` | `deleteSelf()` — verifies via `getSecretKey` before `deleteKey` | + +### 4. Generate hardened keys (1 site) + +| Call site | Function | +| ------------------------------------------ | ----------------------------- | +| `crates/sage/src/endpoints/actions.rs:201` | `increase_derivation_index()` | + +### 4. Key import — encrypt at creation (2 sites) + +| Call site | Function | +| --------------------------------------- | -------------------------------- | +| `crates/sage/src/endpoints/keys.rs:141` | `import_key()` — secret key path | +| `crates/sage/src/endpoints/keys.rs:178` | `import_key()` — mnemonic path | + +Note: `import_key()` also generates hardened derivations using the in-memory master key during import. This does NOT need the password since the key is already decrypted at that point. + +## Changes + +### `sage-keychain` crate + +**`keychain.rs`** — Add one new method: + +```rust +pub fn change_password( + &mut self, + fingerprint: u32, + old_password: &[u8], + new_password: &[u8], +) -> Result<(), KeychainError> +``` + +Decrypts with old password, re-encrypts with new password, replaces the `KeyData::Secret` entry. + +**`key_data.rs`** — Add `password_protected: bool` to `KeyData::Secret`: + +```rust +Secret { + master_pk: [u8; 48], + entropy: bool, + encrypted: Encrypted, + password_protected: bool, // new +} +``` + +Note: this changes the `keys.bin` serialization format. Existing files will fail to deserialize. Handle with a versioned deserialization fallback: try deserializing the new format first, fall back to the old format (defaulting `password_protected` to `false`). + +### `sage-api` crate (request structs) + +Add `password: Option` to **all request structs that trigger signing, secret access, or key import**: + +**Direct secret access:** + +- `ImportKey` +- `GetSecretKey` + +**Signing via `transact()` path — all transaction request structs:** + +- `SendXch`, `BulkSendXch`, `Combine`, `AutoCombineXch`, `Split`, `AutoCombineCat`, `IssueCat`, `SendCat`, `BulkSendCat`, `MultiSend`, `CreateDid`, `BulkMintNfts`, `TransferNfts`, `AddNftUri`, `AssignNftsToDid`, `TransferDids`, `NormalizeDids`, `MintOption`, `TransferOptions`, `ExerciseOptions`, `FinalizeClawback` + +**Signing via direct `extract_secrets` or `sign()`:** + +- `SignCoinSpends`, `MakeOffer`, `TakeOffer`, `CancelOffer`, `CancelOffers`, `CreateTransaction` + +**Hardened derivation:** + +- `IncreaseDerivationIndex` + +**WalletConnect signing:** + +- `SignMessageWithPublicKey`, `SignMessageByAddress` + +**New request/response pair:** + +- `ChangePassword { fingerprint: u32, old_password: String, new_password: String }` +- `ChangePasswordResponse {}` + +**`KeyInfo`** — add `has_password: bool` field. + +### `sage` crate (endpoints) + +**`spends.rs`**: `sign()` takes `password: &[u8]` parameter, passes to `extract_secrets`. + +**`transactions.rs`**: `transact()` and `transact_with()` take `password: &[u8]` parameter, pass to `sign()`. Every transaction endpoint extracts password from its request struct via `req.password.unwrap_or_default().into_bytes()` and passes to `transact()`. + +**`keys.rs`**: `import_key()` passes password to `add_mnemonic()`/`add_secret_key()`. `get_secret_key()` passes password to `extract_secrets()`. `get_key()`/`get_keys()` populate `has_password` from `KeyData`. + +**`offers.rs`**: `make_offer()`, `take_offer()` pass password to `extract_secrets()`. `cancel_offer()`, `cancel_offers()` pass password to `transact()`. + +**`actions.rs`**: `increase_derivation_index()` passes password to `extract_secrets()`. + +**`wallet_connect.rs`**: Both signing methods pass password to `extract_secrets()`. + +New `change_password()` endpoint. + +### Frontend (TypeScript/React) + +#### PasswordContext (`src/contexts/PasswordContext.tsx`) + +A React context provider that serves as the **single entry point for all operation authentication** — password or biometric (never both). Provides: + +```typescript +requestPassword(hasPassword: boolean, fingerprint?: number): Promise +``` + +**Return values:** + +- `string` → use this password (typed by user via dialog) +- `null` → no password needed, all auth passed (biometric gate passed or no auth required) +- `undefined` → auth cancelled or failed, abort the operation + +**Internal decision tree (mutually exclusive):** + +```text +hasPassword=true → show password dialog (password always takes precedence) +hasPassword=false, biometric enabled → biometric prompt with 5-min cache, return null on success, undefined on fail +hasPassword=false, biometric not enabled → return null (no auth needed) +cancelled at any point → return undefined +``` + +On desktop (no biometric available), the biometric path is skipped — behaves as if biometric is not enabled. + +Uses a `useRef`-based pending promise pattern to bridge the dialog UI with the async call site. + +**Provider placement:** Inside `I18nProvider` and `WalletProvider`. Wraps `WalletConnectProvider` and all downstream providers, so `usePassword()` is available everywhere. + +Provider tree: `BiometricProvider` → `I18nProvider` → `WalletProvider` → `PasswordProvider` → `PeerProvider` → `WalletConnectProvider` → `PriceProvider` → `RouterProvider` + +#### PasswordDialog (`src/components/dialogs/PasswordDialog.tsx`) + +A reusable modal dialog rendered by `PasswordProvider`. Features: + +- Auto-focuses the password input on open +- Clears password state on open/close +- Supports Enter key to submit +- Cancel closes the dialog and resolves the promise with `undefined` (auth cancelled) + +#### usePassword hook (`src/hooks/usePassword.ts`) + +Thin wrapper around `PasswordContext` with a guard that throws if used outside `PasswordProvider`. + +#### Call site pattern + +Every protected operation follows the same unified pattern — a single call that handles password or biometric: + +```typescript +const password = await requestPassword(wallet?.has_password ?? false); +if (password === undefined) return; // auth cancelled or failed +``` + +The separate `promptIfEnabled()` biometric call is removed from all call sites. `requestPassword` is now the sole auth gate. Password is then passed to the backend command. Call sites that were updated: + +| File | Operations | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `ConfirmationDialog.tsx` | `signCoinSpends` (Sign Transaction button, Submit button) | +| `WalletCard.tsx` | `getSecretKey` (View Details dialog), `deleteKey` (password verified via `getSecretKey` before deletion) | +| `Settings.tsx` | `increaseDerivationIndex` (when hardened keys enabled) | +| `Offers.tsx` | `cancelOffers` (Cancel All Active) | +| `OfferRowCard.tsx` | `cancelOffer` (individual offer cancel) | +| `useOfferProcessor.ts` | `makeOffer` (create offer flow) | +| `Offer.tsx` | `takeOffer` (take offer flow) | +| `WalletConnectContext.tsx` | All WC command handling via `HandlerContext` | +| WalletConnect commands | `signCoinSpends`, `signMessage`, `signMessageByAddress`, `send`, `createOffer`, `takeOffer`, `cancelOffer`, `bulkMintNfts` | + +#### WalletConnect integration + +The `HandlerContext` interface was extended with `requestPassword` and `hasPassword`. WalletConnect command handlers prompt for the password before executing protected operations, using the same pattern as direct UI call sites. + +#### Password management in Settings + +A new **Security** section in Wallet Settings (only shown for hot wallets with `has_secrets`): + +- **Set Password** — shown when `has_password` is `false`. Opens a dialog with New Password + Confirm Password fields. +- **Change Password** — shown when `has_password` is `true`. Opens a dialog with Current Password + New Password + Confirm Password fields. +- **Remove Password** — shown when `has_password` is `true`. Opens a dialog with Current Password field. Uses destructive button variant. + +All three operations call `commands.changePassword()` with appropriate `old_password`/`new_password` values (empty string = no password). On success, refreshes `KeyInfo` via `commands.getKey()` and shows a success toast. + +#### Error feedback + +Wrong password errors (`ErrorKind::Unauthorized` with reason containing "decrypt") are surfaced as a toast notification "Incorrect password" via the global `ErrorContext.addError` handler. This provides consistent feedback across all password-protected operations without requiring per-call-site error handling. Other unauthorized errors (e.g., wallet transition race conditions) continue to be silently discarded. + +#### Settings UI changes + +The biometric toggle remains in the **Preferences** section of Global Settings (not per-wallet Security) because it is a global setting that applies to all wallets. It is only visible on mobile when biometric hardware is available and enrolled. + +#### Design decisions + +- **No password at import time** — users set a password later via Settings. Simpler UX, same security outcome. +- **No session caching** — every protected operation prompts independently. Passwords are never stored on device. +- **Single dialog instance** — `PasswordProvider` renders one `PasswordDialog` at the provider level, avoiding duplicate dialog instances across components. +- **Unified auth entry point** — `requestPassword` subsumes the standalone `promptIfEnabled()` biometric check. Call sites make one auth call instead of two. The `BiometricContext` continues to exist for state management (`enabled`, `available`) but `promptIfEnabled()` is no longer called directly at operation sites. +- **Mutual exclusivity** — biometric and password are mutually exclusive. Password takes precedence. If a wallet has a password, the password dialog is always shown regardless of biometric settings. Biometric is a standalone gate for no-password wallets only. +- **Global biometric setting** — one toggle applies to all wallets. No per-wallet biometric configuration needed. + +## Error Handling + +- **Wrong password**: AES-GCM authentication fails → `KeychainError::Decrypt` → frontend shows "Incorrect password" toast. +- **Public-key-only wallets**: `extract_secrets` returns `(None, None)` — no prompt needed. Frontend checks `has_secret_key` and `has_password` to decide. +- **Lost password**: No recovery. AES-256-GCM + Argon2 is irreversible without the password. UI warns at password-set time. Matches industry standard (Chia reference wallet, MetaMask). +- **Biometric lockout**: After too many failed OS-level biometric attempts, the OS locks biometric temporarily. Only affects no-password wallets using the biometric gate. +- **App backgrounded during biometric**: OS may cancel the biometric prompt. Treated as cancellation → `requestPassword` returns `undefined`. + +## Migration + +Existing keys encrypted with `b""` continue to work — the user simply never gets prompted. To add protection, the user triggers "Set Password" which calls `change_password(fingerprint, b"", new_password)`. + +The `keys.bin` format change (adding `password_protected` to `KeyData::Secret`) requires a deserialization fallback: try new format first, fall back to old format with `password_protected: false`. On next save, the file is written in the new format. + +## What's NOT Changing + +- `encrypt.rs` — AES-256-GCM + Argon2 implementation is already correct +- `keys.bin` encryption scheme — same Argon2 + AES-256-GCM, just with real passwords instead of `b""` +- Any sync, peer, or database logic +- `SendTransactionImmediately`, `SubmitTransaction`, `ViewCoinSpends` — these operate on pre-signed spend bundles or read-only data and do not call `extract_secrets()` or `sign()` +- Backend — no backend changes for the biometric gate. It's entirely frontend. +- Biometric — remains as a standalone gate for no-password wallets. `BiometricContext` provides `enabled`/`available` state; `PasswordContext` handles the actual biometric prompt internally. diff --git a/docs/superpowers/specs/2026-03-16-protection-matrix.md b/docs/superpowers/specs/2026-03-16-protection-matrix.md new file mode 100644 index 000000000..964e16775 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-protection-matrix.md @@ -0,0 +1,90 @@ +# Operation Protection Matrix + +Analysis of biometric vs. password protection across all protected operations in Sage wallet. + +## Architecture + +Password enforcement happens at two distinct layers, depending on `auto_submit`: + +1. **ConfirmationDialog (centralized)** — When `auto_submit: false` (the default for all UI operations), the Rust `transact()` function ignores the password entirely and returns unsigned coin spends. The user reviews the transaction in `ConfirmationDialog`, whose Submit button calls `requestPassword` → `signCoinSpends` → `submitTransaction`. This is the single enforcement point for all normal UI transaction flows. + +2. **Direct `requestPassword` (per-call-site)** — WalletConnect handlers set `auto_submit: true`, which means signing happens inside the Rust command itself. These call `requestPassword` and pass the password to the command directly. + +Password and biometric are mutually exclusive. `requestPassword(hasPassword)` routes to password dialog OR biometric prompt, never both. A wallet with a password set never triggers biometric. + +## Matrix — UI Operations + +Legend: ✅ = protected, 🔄 = redundant double-prompt, ⚠️ = bug, ❌ = not protected + +| Operation | Protected | Mechanism | Status | Call site | +| --------------------------------------------------- | :-------: | ----------------------------------------- | :----: | -------------------------------------------------------------------- | +| **Transaction Operations (via ConfirmationDialog)** | | | | | +| Send XCH | ✅ | ConfirmationDialog | OK | `Send.tsx:188` | +| Send CAT | ✅ | ConfirmationDialog | OK | `Send.tsx:206` | +| Bulk send XCH | ✅ | ConfirmationDialog | OK | `Send.tsx:181` | +| Bulk send CAT | ✅ | ConfirmationDialog | OK | `Send.tsx:198` | +| Combine coins | ✅ | ConfirmationDialog (via Token.tsx) | OK | `OwnedCoinsCard.tsx:261` | +| Split coins | ✅ | ConfirmationDialog (via Token.tsx) | OK | `OwnedCoinsCard.tsx:310` | +| Auto-combine XCH/CAT | ✅ | ConfirmationDialog (via Token.tsx) | OK | `OwnedCoinsCard.tsx:363` | +| Issue CAT | ✅ | ConfirmationDialog | OK | `IssueToken.tsx:48` | +| Multi-send | — | No frontend binding | N/A | Rust-only; no TypeScript binding or UI | +| Sign coin spends (Sign button) | ✅ | Direct `requestPassword` | OK | `ConfirmationDialog.tsx:520` | +| Sign coin spends (Submit button) | ✅ | Direct `requestPassword` | OK | `ConfirmationDialog.tsx:627` | +| **NFTs / DIDs** | | | | | +| Bulk mint NFTs | ✅ | ConfirmationDialog | OK | `MintNft.tsx:140` | +| Transfer NFTs | ✅ | ConfirmationDialog | OK | `MultiSelectActions.tsx:135` | +| Burn NFTs | ✅ | ConfirmationDialog | OK | `MultiSelectActions.tsx:168` | +| Add NFT URI | ✅ | ConfirmationDialog | OK | `NftCard.tsx:236` | +| Assign NFTs to DID | ✅ | ConfirmationDialog | OK | `MultiSelectActions.tsx:152` | +| Create DID | ✅ | ConfirmationDialog | OK | `CreateProfile.tsx:46` | +| Transfer DIDs | ✅ | ConfirmationDialog | OK | `DidList.tsx:166` | +| Burn DIDs | ✅ | ConfirmationDialog | OK | `DidList.tsx:182` | +| Normalize DIDs | ✅ | ConfirmationDialog | OK | `DidList.tsx:198` | +| **Options** | | | | | +| Mint option | ✅ | ConfirmationDialog | OK | `MintOption.tsx:91` | +| Transfer options | ✅ | ConfirmationDialog | OK | `useOptionActions.tsx:63` | +| Exercise options | ✅ | ConfirmationDialog | OK | `useOptionActions.tsx:43` | +| Burn options | ✅ | ConfirmationDialog | OK | `useOptionActions.tsx:83` | +| **Clawback** | | | | | +| Claw back coins | ✅ | ConfirmationDialog (via Token.tsx) | OK | `ClawbackCoinsCard.tsx:215` | +| Finalize clawback | ✅ | ConfirmationDialog (via Token.tsx) | OK | `ClawbackCoinsCard.tsx:260` | +| **Offers** | | | | | +| Make offer (split-NFT path) | ✅ | Direct `requestPassword` | OK | `useOfferProcessor.ts:116` — password forwarded | +| Make offer (single/non-split) | ✅ | Direct `requestPassword` | OK | `useOfferProcessor.ts:160` — fixed: password now forwarded | +| Take offer | ✅ | ConfirmationDialog | OK | `Offer.tsx` — fixed: removed redundant pre-prompt | +| Cancel offer | ✅ | ConfirmationDialog | OK | `OfferRowCard.tsx` — fixed: removed redundant pre-prompt | +| Cancel all offers | ✅ | ConfirmationDialog | OK | `Offers.tsx` — fixed: removed redundant pre-prompt | +| **Secrets / Key Management** | | | | | +| View mnemonic / secret key | ✅ | Direct `requestPassword` | OK | `WalletCard.tsx:194` | +| Delete wallet key | ✅ | `requestPassword` + `getSecretKey` verify | OK | `WalletCard.tsx:82` — password verified via decryption before delete | +| Import key (secret/mnemonic) | ✅ | Password set at import time | OK | Encrypt-at-import only | +| Set / Change / Remove password | ✅ | Inline form (not `requestPassword`) | OK | `Settings.tsx:1238` | +| **Key Derivation** | | | | | +| Increase derivation (hardened) | ✅ | Direct `requestPassword` | OK | `Settings.tsx:1269` | +| Increase derivation (unhardened) | ❌ | None | OK | No private key needed | +| **Unprotected (by design)** | | | | | +| Enable/disable biometric toggle | ❌ | None | OK | No-op on password-protected wallets (mutual exclusivity) | +| View balances / addresses / NFTs | ❌ | None | OK | Read-only | +| Submit pre-signed transaction | ❌ | None | OK | No key access needed | +| Login / logout wallet | ❌ | None | OK | No secret access | +| Rename / resync / emoji | ❌ | None | OK | Metadata only | + +## Matrix — WalletConnect Operations + +All WC handlers use `auto_submit: true` (except `signCoinSpends`), so password is required at the call site. All are correctly wired via `HandlerContext.requestPassword`. + +| Operation | Protected | auto_submit | Status | Call site | +| ------------------------------------- | :-------: | :---------: | :----: | --------------------- | +| `chia_send` (XCH/CAT) | ✅ | `true` | OK | `high-level.ts:54/64` | +| `chia_bulkMintNfts` | ✅ | `true` | OK | `high-level.ts:100` | +| `chia_createOffer` | ✅ | not set | OK | `offers.ts:12` | +| `chia_takeOffer` | ✅ | `true` | OK | `offers.ts:41` | +| `chia_cancelOffer` | ✅ | `true` | OK | `offers.ts:58` | +| `chip0002_signCoinSpends` | ✅ | `false` | OK | `chip0002.ts:81` | +| `chip0002_signMessage` | ✅ | N/A | OK | `chip0002.ts:105` | +| `chia_signMessageByAddress` | ✅ | N/A | OK | `high-level.ts` | +| WC read-only (connect, chainId, etc.) | ❌ | N/A | OK | No signing | + +## Remaining Design Considerations + +1. **Session caching asymmetry** — biometric caches for 5 minutes; password never caches. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9aef00af..21d66fca9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,7 +212,7 @@ importers: version: 5.10.1(@lingui/core@5.9.0(@lingui/babel-plugin-lingui-macro@5.9.0(babel-plugin-macros@3.1.0)(typescript@5.9.3))(babel-plugin-macros@3.1.0)) '@lingui/vite-plugin': specifier: ^5.9.0 - version: 5.9.0(babel-plugin-macros@3.1.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1)) + version: 5.9.0(babel-plugin-macros@3.1.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1)) '@tauri-apps/cli': specifier: ^2.10.0 version: 2.10.0 @@ -233,7 +233,7 @@ importers: version: 7.18.0(eslint@8.57.1)(typescript@5.9.3) '@vitejs/plugin-react-swc': specifier: ^3.11.0 - version: 3.11.0(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1)) + version: 3.11.0(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1)) autoprefixer: specifier: ^10.4.24 version: 10.4.24(postcss@8.5.6) @@ -269,7 +269,7 @@ importers: version: 8.54.0(eslint@8.57.1)(typescript@5.9.3) vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1) + version: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1) packages: @@ -792,6 +792,9 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -2113,6 +2116,9 @@ packages: bs58@6.0.0: resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -2206,6 +2212,9 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -3448,6 +3457,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -3528,6 +3544,11 @@ packages: tauri-plugin-sage-api@file:tauri-plugin-sage: resolution: {directory: tauri-plugin-sage, type: directory} + terser@5.46.0: + resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==} + engines: {node: '>=10'} + hasBin: true + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -4257,6 +4278,12 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.31': @@ -4359,11 +4386,11 @@ snapshots: dependencies: '@lingui/core': 5.9.0(@lingui/babel-plugin-lingui-macro@5.9.0(babel-plugin-macros@3.1.0)(typescript@5.9.3))(babel-plugin-macros@3.1.0) - '@lingui/vite-plugin@5.9.0(babel-plugin-macros@3.1.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1))': + '@lingui/vite-plugin@5.9.0(babel-plugin-macros@3.1.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1))': dependencies: '@lingui/cli': 5.9.0(babel-plugin-macros@3.1.0)(typescript@5.9.3) '@lingui/conf': 5.9.0(typescript@5.9.3) - vite: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1) + vite: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -5348,11 +5375,11 @@ snapshots: '@use-gesture/core': 10.3.1 react: 18.3.1 - '@vitejs/plugin-react-swc@3.11.0(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1))': + '@vitejs/plugin-react-swc@3.11.0(vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 '@swc/core': 1.15.11 - vite: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1) + vite: 7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1) transitivePeerDependencies: - '@swc/helpers' @@ -5782,6 +5809,9 @@ snapshots: dependencies: base-x: 5.0.1 + buffer-from@1.1.2: + optional: true + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -5887,6 +5917,9 @@ snapshots: commander@10.0.1: {} + commander@2.20.3: + optional: true + commander@4.1.1: {} concat-map@0.0.1: {} @@ -7307,6 +7340,15 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + optional: true + + source-map@0.6.1: + optional: true + source-map@0.7.6: {} split2@4.2.0: {} @@ -7434,6 +7476,14 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + terser@5.46.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + optional: true + text-table@0.2.0: {} theme-o-rama@0.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -7611,7 +7661,7 @@ snapshots: util-deprecate@1.0.2: {} - vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(yaml@2.5.1): + vite@7.3.1(@types/node@22.19.10)(jiti@1.21.7)(terser@5.46.0)(yaml@2.5.1): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -7623,6 +7673,7 @@ snapshots: '@types/node': 22.19.10 fsevents: 2.3.3 jiti: 1.21.7 + terser: 5.46.0 yaml: 2.5.1 wcwidth@1.0.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..7c326294a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +onlyBuiltDependencies: + - '@swc/core' + - esbuild diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index d9d5222e2..15161cc2b 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -11,7 +12,8 @@ android:roundIcon="@mipmap/ic_launcher_round" android:label="@string/app_name" android:theme="@style/Theme.sage_tauri" - android:usesCleartextTraffic="${usesCleartextTraffic}"> + android:usesCleartextTraffic="${usesCleartextTraffic}" + tools:replace="android:theme"> NSPhotoLibraryAddUsageDescription This app needs access to save images to your photo library. - + \ No newline at end of file diff --git a/src-tauri/gen/apple/sage-tauri_iOS/sage-tauri_iOS.entitlements b/src-tauri/gen/apple/sage-tauri_iOS/sage-tauri_iOS.entitlements index b1c4bd3a1..cab3c8853 100644 --- a/src-tauri/gen/apple/sage-tauri_iOS/sage-tauri_iOS.entitlements +++ b/src-tauri/gen/apple/sage-tauri_iOS/sage-tauri_iOS.entitlements @@ -9,4 +9,4 @@ com.apple.developer.group-session - + \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a4f2bd12a..e37d25550 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -141,6 +141,7 @@ pub fn run() { commands::download_cni_offercode, commands::get_logs, commands::is_asset_owned, + commands::change_password, ]) .events(collect_events![SyncEvent]); diff --git a/src/App.tsx b/src/App.tsx index 6fefeeff2..9df1f16bd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { ThemeProvider, useTheme } from 'theme-o-rama'; import { useLocalStorage } from 'usehooks-ts'; import { BiometricProvider } from './contexts/BiometricContext'; import { ErrorProvider } from './contexts/ErrorContext'; +import { PasswordProvider } from './contexts/PasswordContext'; import { getBrowserLanguage, LanguageProvider, @@ -190,13 +191,15 @@ function AppInner() { isLocaleInitialized && ( - - - - - - - + + + + + + + + + ) diff --git a/src/bindings.ts b/src/bindings.ts index 45ad7bbf5..516233c5f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -358,6 +358,9 @@ async getLogs() : Promise { }, async isAssetOwned(req: IsAssetOwned) : Promise { return await TAURI_INVOKE("is_asset_owned", { req }); +}, +async changePassword(req: ChangePassword) : Promise { + return await TAURI_INVOKE("change_password", { req }); } } @@ -400,7 +403,11 @@ kind: NftUriKind; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Add a new peer to connect to */ @@ -436,7 +443,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Automatically combine CAT coins */ @@ -460,7 +471,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response for auto-combine CAT */ @@ -496,7 +511,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response for auto-combine XCH */ @@ -532,7 +551,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response for bulk NFT minting */ @@ -580,7 +603,11 @@ memos?: string[]; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Send XCH to multiple addresses */ @@ -604,7 +631,11 @@ memos?: string[]; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Cancel an offer on-chain */ @@ -620,7 +651,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Cancel multiple offers */ @@ -636,7 +671,31 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } +/** + * Change the password for a wallet's secret key + */ +export type ChangePassword = { +/** + * Wallet fingerprint + */ +fingerprint: number; +/** + * Current password (empty string if no password is set) + */ +old_password: string; +/** + * New password (empty string to remove password protection) + */ +new_password: string } +/** + * Response after changing the password + */ +export type ChangePasswordResponse = Record /** * Validate and check an address */ @@ -705,7 +764,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Combine multiple offers */ @@ -737,7 +800,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } export type CreateTransaction = { /** * Pre-selected coins to use in the transaction prior to coin selection @@ -750,7 +817,11 @@ actions: Action[]; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Delete a wallet database */ @@ -820,7 +891,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } export type FeeAction = { /** * The fee amount, in mojos @@ -857,7 +932,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Generate a new mnemonic phrase for wallet creation */ @@ -1425,7 +1504,11 @@ export type GetSecretKey = { /** * Wallet fingerprint */ -fingerprint: number } +fingerprint: number; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response with secret key information */ @@ -1648,7 +1731,11 @@ login?: boolean; /** * Optional emoji identifier */ -emoji?: string | null } +emoji?: string | null; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response with imported key fingerprint */ @@ -1688,7 +1775,11 @@ unhardened?: boolean | null; /** * The target derivation index to increase to */ -index: number } +index: number; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response after increasing the derivation index */ @@ -1733,8 +1824,12 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } -export type KeyInfo = { name: string; fingerprint: number; public_key: string; kind: KeyKind; has_secrets: boolean; network_id: string; emoji: string | null } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } +export type KeyInfo = { name: string; fingerprint: number; public_key: string; kind: KeyKind; has_secrets: boolean; has_password: boolean; network_id: string; emoji: string | null } export type KeyKind = "bls" /** * Lineage proof for CAT coins @@ -1804,7 +1899,11 @@ auto_import?: boolean; /** * Optional specific coin IDs to use for the offer instead of auto-selecting */ -coin_ids?: string[] | null } +coin_ids?: string[] | null; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response with created offer */ @@ -1885,7 +1984,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response for minting an option */ @@ -1993,7 +2096,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Asset amount in an offer */ @@ -2221,7 +2328,11 @@ clawback?: number | null; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Send a transaction immediately */ @@ -2269,7 +2380,11 @@ clawback?: number | null; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Set the change address for transactions */ @@ -2369,7 +2484,11 @@ auto_submit?: boolean; /** * Whether to partially sign (for multi-signature) */ -partial?: boolean } +partial?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response with signed spend bundle */ @@ -2389,7 +2508,11 @@ message: string; /** * Address whose key to use */ -address: string } +address: string; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response with signed message */ @@ -2413,7 +2536,11 @@ message: string; /** * Public key to use for signing */ -publicKey: string } +publicKey: string; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response with message signature */ @@ -2482,7 +2609,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Submit a transaction to the network */ @@ -2511,7 +2642,11 @@ fee: Amount; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Response with accepted offer details */ @@ -2569,7 +2704,11 @@ clawback?: number | null; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Transfer NFTs to a new owner */ @@ -2593,7 +2732,11 @@ clawback?: number | null; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } /** * Transfer options to another address */ @@ -2617,7 +2760,11 @@ clawback?: number | null; /** * Whether to automatically submit the transaction */ -auto_submit?: boolean } +auto_submit?: boolean; +/** + * Password for signing (required if wallet is password-protected) + */ +password?: string | null } export type Unit = { ticker: string; precision: number } /** * Update a `CAT` token's metadata and visibility @@ -2748,7 +2895,7 @@ offer: OfferSummary; * Offer status */ status: OfferRecordStatus } -export type Wallet = { name: string; fingerprint: number; network?: string | null; delta_sync: boolean | null; emoji?: string | null; change_address?: string | null } +export type Wallet = { name: string; fingerprint: number; network?: string | null; delta_sync: boolean | null; emoji?: string | null; change_address?: string | null; password_protected: boolean } export type WalletDefaults = { delta_sync: boolean } /** tauri-specta globals **/ diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx index c2f7de3bd..da433fd85 100644 --- a/src/components/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog.tsx @@ -8,8 +8,9 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { LoadingButton } from '@/components/ui/loading-button'; -import { useBiometric } from '@/hooks/useBiometric'; import { useErrors } from '@/hooks/useErrors'; +import { usePassword } from '@/hooks/usePassword'; +import { useWallet } from '@/contexts/WalletContext'; import { fromMojos } from '@/lib/utils'; import { useWalletState } from '@/state'; import { t } from '@lingui/core/macro'; @@ -64,7 +65,8 @@ export default function ConfirmationDialog({ const ticker = walletState.sync.unit.ticker; const { addError } = useErrors(); - const { promptIfEnabled } = useBiometric(); + const { requestPassword } = usePassword(); + const { wallet } = useWallet(); const [pending, setPending] = useState(false); const [signature, setSignature] = useState(null); @@ -515,24 +517,26 @@ export default function ConfirmationDialog({ + + + + + ); +} diff --git a/src/components/dialogs/PasswordManagementDialog.tsx b/src/components/dialogs/PasswordManagementDialog.tsx new file mode 100644 index 000000000..6daae870a --- /dev/null +++ b/src/components/dialogs/PasswordManagementDialog.tsx @@ -0,0 +1,316 @@ +import { CustomError } from '@/contexts/ErrorContext'; +import { commands } from '@/bindings'; +import { t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { AlertTriangleIcon, LoaderCircleIcon } from 'lucide-react'; +import { useState } from 'react'; +import { Alert, AlertDescription, AlertTitle } from '../ui/alert'; +import { Button } from '../ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../ui/dialog'; +import { Input } from '../ui/input'; +import { Label } from '../ui/label'; +import { Textarea } from '../ui/textarea'; + +export type PasswordDialogMode = 'set' | 'change' | 'remove'; + +export interface PasswordManagementDialogProps { + open: boolean; + mode: PasswordDialogMode; + fingerprint: number; + onClose: () => void; + onSuccess: () => void; +} + +function normalizeKey(key: string): string { + return key + .trim() + .replace(/[^a-z]/gi, ' ') + .split(/\s+/) + .filter((item) => item.length > 0) + .join(' ') + .toLowerCase(); +} + +function normalizeHex(hex: string): string { + let h = hex.trim().toLowerCase(); + if (h.startsWith('0x')) { + h = h.slice(2); + } + return h; +} + +export function PasswordManagementDialog({ + open, + mode, + fingerprint, + onClose, + onSuccess, +}: PasswordManagementDialogProps) { + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [verificationKey, setVerificationKey] = useState(''); + const [pending, setPending] = useState(false); + const [verifying, setVerifying] = useState(false); + const [error, setError] = useState(null); + + const resetState = () => { + setOldPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setVerificationKey(''); + setError(null); + }; + + const handleClose = () => { + resetState(); + onClose(); + }; + + const verifyBackupKey = async (): Promise => { + setVerifying(true); + try { + const response = await commands.getSecretKey({ + fingerprint, + }); + + if (!response.secrets) { + setError(t`Unable to retrieve wallet secrets for verification.`); + return false; + } + + const input = verificationKey.trim(); + const { mnemonic, secret_key } = response.secrets; + + // Check if input matches the mnemonic + if (mnemonic && normalizeKey(input) === normalizeKey(mnemonic)) { + return true; + } + + // Check if input matches the secret key + if (normalizeHex(input) === normalizeHex(secret_key)) { + return true; + } + + setError( + t`The seed phrase or secret key you entered does not match this wallet. Please verify and try again.`, + ); + return false; + } catch (err) { + const customErr = err as CustomError; + setError(customErr.reason || t`Failed to verify key. Please try again.`); + return false; + } finally { + setVerifying(false); + } + }; + + const handleSubmit = async () => { + setError(null); + + if (mode !== 'remove' && newPassword !== confirmPassword) { + setError(t`Passwords do not match`); + return; + } + + if (mode !== 'remove' && newPassword.length === 0) { + setError(t`Password cannot be empty`); + return; + } + + if (mode === 'set') { + if (verificationKey.trim().length === 0) { + setError( + t`You must enter your seed phrase or secret key to verify you have a backup before setting a password.`, + ); + return; + } + + const verified = await verifyBackupKey(); + if (!verified) return; + } + + setPending(true); + try { + await commands.changePassword({ + fingerprint, + old_password: mode === 'set' ? '' : oldPassword, + new_password: mode === 'remove' ? '' : newPassword, + }); + + resetState(); + onSuccess(); + } catch (err) { + const customErr = err as CustomError; + setError(customErr.reason || t`Incorrect password`); + } finally { + setPending(false); + } + }; + + return ( + !isOpen && handleClose()}> + + + + {mode === 'set' && Set Password} + {mode === 'change' && Change Password} + {mode === 'remove' && Remove Password} + + + {mode === 'set' && ( + + Set a password to protect transaction signing and secret key + access. + + )} + {mode === 'change' && ( + Enter your current password and choose a new one. + )} + {mode === 'remove' && ( + + Enter your current password to remove password protection. Your + wallet secrets will no longer require a password. + + )} + + +
+ {mode === 'set' && ( + + + + No password recovery + + + + If you lose or forget this password, your wallet and its keys + cannot be recovered without your seed phrase or secret key. + Make sure they are safely stored before continuing. + + + + )} + + {mode !== 'set' && ( +
+ + setOldPassword(e.target.value)} + placeholder={t`Enter current password`} + onKeyDown={(e) => { + if (e.key === 'Enter' && mode === 'remove') { + e.preventDefault(); + handleSubmit(); + } + }} + /> +
+ )} + {mode !== 'remove' && ( + <> +
+ + setNewPassword(e.target.value)} + placeholder={t`Enter password`} + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder={t`Confirm password`} + onKeyDown={(e) => { + if (e.key === 'Enter' && mode !== 'set') { + e.preventDefault(); + handleSubmit(); + } + }} + /> +
+ + )} + + {mode === 'set' && ( +
+ +