diff --git a/CHANGELOG.md b/CHANGELOG.md index d5500d95..c5e44dbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,9 @@ be considered breaking changes. - `decodescript` - `verifymessage` - `z_converttex` + - `z_exportkey` (Sapling extended spending keys only) - `z_importaddress` + - `z_importkey` (Sapling extended spending keys only) - `z_shieldcoinbase` ### Changed diff --git a/zallet/src/components/json_rpc/methods.rs b/zallet/src/components/json_rpc/methods.rs index 87bc059f..a3376992 100644 --- a/zallet/src/components/json_rpc/methods.rs +++ b/zallet/src/components/json_rpc/methods.rs @@ -21,6 +21,8 @@ use { mod convert_tex; mod decode_raw_transaction; mod decode_script; +#[cfg(zallet_build = "wallet")] +mod export_key; mod get_account; mod get_address_for_account; #[cfg(zallet_build = "wallet")] @@ -36,6 +38,8 @@ mod get_raw_transaction; mod get_wallet_info; #[cfg(zallet_build = "wallet")] mod help; +#[cfg(zallet_build = "wallet")] +mod import_key; mod list_accounts; mod list_addresses; #[cfg(zallet_build = "wallet")] @@ -495,6 +499,46 @@ pub(crate) trait WalletRpc { as_of_height: Option, ) -> get_notes_count::Response; + /// Exports the spending key for a Sapling payment address. + /// + /// The wallet must be unlocked to use this method. + /// + /// # Warning + /// + /// This exports **only** the Sapling spending key. It is **not** a complete backup of + /// the funds reachable from this account's root of spending authority: in particular, + /// any Orchard funds derived from the same seed are **not** represented by the exported + /// key. Do not rely on `z_exportkey` as a wallet backup — use a full seed/wallet backup + /// instead, or you may lose access to funds. + /// + /// # Arguments + /// + /// - `address` (string, required) The Sapling payment address corresponding to the + /// spending key to export. + #[method(name = "z_exportkey")] + async fn export_key(&self, address: &str) -> export_key::Response; + + /// Imports a spending key into the wallet. + /// + /// Only Sapling extended spending keys are supported. + /// + /// # Arguments + /// + /// - `key` (string, required) The spending key to import. + /// - `rescan` (string, optional, default="whenkeyisnew") Whether to rescan the + /// blockchain for transactions ("yes", "no", or "whenkeyisnew"). When rescan is + /// enabled, the wallet's background sync engine will scan for historical + /// transactions from the given start height. + /// - `startHeight` (numeric, optional, default=0) Block height from which to begin + /// the rescan. Only used when rescan is "yes" or "whenkeyisnew" (for a new key). + #[method(name = "z_importkey")] + async fn import_key( + &self, + key: &str, + rescan: Option<&str>, + start_height: Option, + ) -> import_key::Response; + /// Send a transaction with multiple recipients. /// /// This is an async operation; it returns an operation ID string that you can pass to @@ -945,6 +989,27 @@ impl WalletRpcServer for WalletRpcImpl { get_notes_count::call(self.wallet().await?.as_ref(), minconf, as_of_height) } + async fn export_key(&self, address: &str) -> export_key::Response { + export_key::call(self.wallet().await?.as_ref(), &self.keystore, address).await + } + + async fn import_key( + &self, + key: &str, + rescan: Option<&str>, + start_height: Option, + ) -> import_key::Response { + import_key::call( + self.wallet().await?.as_mut(), + &self.keystore, + self.chain().await?, + key, + rescan, + start_height, + ) + .await + } + async fn z_send_many( &self, fromaddress: String, diff --git a/zallet/src/components/json_rpc/methods/export_key.rs b/zallet/src/components/json_rpc/methods/export_key.rs new file mode 100644 index 00000000..f5e5c0ee --- /dev/null +++ b/zallet/src/components/json_rpc/methods/export_key.rs @@ -0,0 +1,111 @@ +use documented::Documented; +use jsonrpsee::core::RpcResult; +use schemars::JsonSchema; +use serde::Serialize; +use zcash_keys::encoding::{decode_payment_address, encode_extended_spending_key}; +use zcash_protocol::consensus::NetworkConstants; + +use crate::components::{ + database::DbConnection, + json_rpc::{server::LegacyCode, utils::ensure_wallet_is_unlocked}, + keystore::KeyStore, +}; + +/// Response to a `z_exportkey` RPC request. +pub(crate) type Response = RpcResult; + +/// The exported Sapling extended spending key, encoded as a Bech32 string. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +#[serde(transparent)] +pub(crate) struct ResultType(String); + +pub(super) const PARAM_ADDRESS_DESC: &str = + "The Sapling payment address corresponding to the spending key to export."; + +pub(crate) async fn call(wallet: &DbConnection, keystore: &KeyStore, address: &str) -> Response { + ensure_wallet_is_unlocked(keystore).await?; + + // Decode the Sapling payment address. + let payment_address = decode_payment_address( + wallet.params().hrp_sapling_payment_address(), + address, + ) + .map_err(|e| { + LegacyCode::InvalidAddressOrKey.with_message(format!("Invalid Sapling address: {e}")) + })?; + + // Look up and decrypt the standalone spending key for this address. + let extsk = keystore + .decrypt_standalone_sapling_key(&payment_address) + .await + .map_err(|e| LegacyCode::Wallet.with_message(e.to_string()))? + .ok_or_else(|| { + LegacyCode::InvalidAddressOrKey + .with_static("Wallet does not hold the spending key for this Sapling address") + })?; + + let encoded = + encode_extended_spending_key(wallet.params().hrp_sapling_extended_spending_key(), &extsk); + + Ok(ResultType(encoded)) +} + +#[cfg(test)] +mod tests { + use zcash_keys::encoding::{decode_payment_address, encode_payment_address}; + use zcash_protocol::constants; + + #[test] + fn decode_valid_mainnet_sapling_address() { + // From zcash_keys::encoding tests — address derived from seed [0; 32]. + let addr = decode_payment_address( + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + "zs1qqqqqqqqqqqqqqqqqqcguyvaw2vjk4sdyeg0lc970u659lvhqq7t0np6hlup5lusxle75c8v35z", + ); + assert!(addr.is_ok()); + } + + #[test] + fn decode_valid_testnet_sapling_address() { + let addr = decode_payment_address( + constants::testnet::HRP_SAPLING_PAYMENT_ADDRESS, + "ztestsapling1qqqqqqqqqqqqqqqqqqcguyvaw2vjk4sdyeg0lc970u659lvhqq7t0np6hlup5lusxle75ss7jnk", + ); + assert!(addr.is_ok()); + } + + #[test] + fn decode_invalid_address() { + let addr = decode_payment_address( + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + "not-a-valid-address", + ); + assert!(addr.is_err()); + } + + #[test] + fn decode_wrong_network_address() { + // Testnet address decoded with mainnet HRP should fail. + let addr = decode_payment_address( + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + "ztestsapling1qqqqqqqqqqqqqqqqqqcguyvaw2vjk4sdyeg0lc970u659lvhqq7t0np6hlup5lusxle75ss7jnk", + ); + assert!(addr.is_err()); + } + + #[test] + fn address_encode_decode_roundtrip() { + let addr = decode_payment_address( + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + "zs1qqqqqqqqqqqqqqqqqqcguyvaw2vjk4sdyeg0lc970u659lvhqq7t0np6hlup5lusxle75c8v35z", + ) + .unwrap(); + + let re_encoded = + encode_payment_address(constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, &addr); + assert_eq!( + re_encoded, + "zs1qqqqqqqqqqqqqqqqqqcguyvaw2vjk4sdyeg0lc970u659lvhqq7t0np6hlup5lusxle75c8v35z" + ); + } +} diff --git a/zallet/src/components/json_rpc/methods/import_key.rs b/zallet/src/components/json_rpc/methods/import_key.rs new file mode 100644 index 00000000..f85a8685 --- /dev/null +++ b/zallet/src/components/json_rpc/methods/import_key.rs @@ -0,0 +1,298 @@ +use documented::Documented; +use jsonrpsee::core::RpcResult; +use schemars::JsonSchema; +use serde::Serialize; +use zaino_state::FetchServiceSubscriber; +use zcash_client_backend::data_api::{AccountPurpose, WalletRead, WalletWrite}; +use zcash_keys::{encoding::decode_extended_spending_key, keys::UnifiedFullViewingKey}; +use zcash_protocol::consensus::{BlockHeight, NetworkConstants}; + +use crate::components::{ + database::DbConnection, + json_rpc::{server::LegacyCode, utils::fetch_account_birthday}, + keystore::KeyStore, +}; + +/// Response to a `z_importkey` RPC request. +pub(crate) type Response = RpcResult; + +/// Result of importing a spending key. +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] +pub(crate) struct ResultType { + /// The type of the imported address (always "sapling"). + address_type: String, + + /// The Sapling payment address corresponding to the imported key. + address: String, +} + +pub(super) const PARAM_KEY_DESC: &str = + "The spending key (only Sapling extended spending keys are supported)."; +pub(super) const PARAM_RESCAN_DESC: &str = "Whether to rescan the blockchain for transactions (\"yes\", \"no\", or \"whenkeyisnew\"; default is \"whenkeyisnew\"). When rescan is enabled, the wallet's background sync engine will scan for historical transactions from the given start height."; +pub(super) const PARAM_START_HEIGHT_DESC: &str = "Block height from which to begin the rescan (default is 0). Only used when rescan is \"yes\" or \"whenkeyisnew\" (for a new key)."; + +/// Validates the `rescan` parameter. +/// +/// Returns the validated rescan value, or an RPC error if the value is invalid. +fn validate_rescan(rescan: Option<&str>) -> RpcResult<&str> { + match rescan { + None | Some("whenkeyisnew") => Ok("whenkeyisnew"), + Some("yes") => Ok("yes"), + Some("no") => Ok("no"), + Some(_) => Err(LegacyCode::InvalidParameter + .with_static("Invalid rescan value. Must be \"yes\", \"no\", or \"whenkeyisnew\".")), + } +} + +/// Decodes a Sapling extended spending key and derives the default payment address. +/// +/// Returns the decoded key and the encoded payment address string. +fn decode_key_and_address( + hrp_spending_key: &str, + hrp_payment_address: &str, + key: &str, +) -> RpcResult<(sapling::zip32::ExtendedSpendingKey, String)> { + let extsk = decode_extended_spending_key(hrp_spending_key, key).map_err(|e| { + LegacyCode::InvalidAddressOrKey.with_message(format!("Invalid spending key: {e}")) + })?; + + let (_, payment_address) = extsk.default_address(); + + let address = + zcash_keys::encoding::encode_payment_address(hrp_payment_address, &payment_address); + + Ok((extsk, address)) +} + +pub(crate) async fn call( + wallet: &mut DbConnection, + keystore: &KeyStore, + chain: FetchServiceSubscriber, + key: &str, + rescan: Option<&str>, + start_height: Option, +) -> Response { + let rescan = validate_rescan(rescan)?; + + // Resolve and validate start_height, defaulting to 0 (genesis). + let start_height = BlockHeight::from_u32( + u32::try_from(start_height.unwrap_or(0)) + .map_err(|_| LegacyCode::InvalidParameter.with_static("Block height out of range."))?, + ); + + let chain_tip = wallet + .chain_height() + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))?; + + // `start_height` is only used as the rescan start when rescanning; for rescan="no" + // it is ignored entirely, so there is no point validating it against the chain tip. + if rescan != "no" { + if let Some(tip) = chain_tip { + if start_height > tip { + return Err(LegacyCode::InvalidParameter.with_static("Block height out of range.")); + } + } + } + + let hrp = wallet.params().hrp_sapling_extended_spending_key(); + let hrp_addr = wallet.params().hrp_sapling_payment_address(); + let (extsk, address) = decode_key_and_address(hrp, hrp_addr, key)?; + + // Store the encrypted spending key in the keystore. + keystore + .encrypt_and_store_standalone_sapling_key(&extsk) + .await + .map_err(|e| LegacyCode::Wallet.with_message(e.to_string()))?; + + // Import the UFVK derived from the spending key into the wallet database so the + // wallet can track transactions to/from this key's addresses. + #[allow(deprecated)] + let extfvk = extsk.to_extended_full_viewing_key(); + let ufvk = UnifiedFullViewingKey::from_sapling_extended_full_viewing_key(extfvk) + .map_err(|e| LegacyCode::Wallet.with_message(e.to_string()))?; + + // Check if the key is already known to the wallet. + let is_new_key = wallet + .get_account_for_ufvk(&ufvk) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .is_none(); + + if is_new_key { + // Determine the birthday height based on the rescan parameter: + // - "yes" or "whenkeyisnew" → use start_height so the sync engine scans + // historical blocks from that point. + // - "no" → use the current chain tip so the sync engine only tracks new + // transactions going forward. + // + // TODO: When rescan is "yes" and the key already exists, zcashd would force a + // rescan from start_height. `WalletWrite::rewind_to_height` could now drive this, + // but it rewinds the *entire* wallet (every account) rather than just this key, so + // we defer wiring it up until that global side effect is the desired behaviour. + let effective_height = match rescan { + "yes" | "whenkeyisnew" => start_height, + "no" => chain_tip.unwrap_or_else(|| { + tracing::warn!( + "z_importkey with rescan=\"no\" but no chain tip is known yet; \ + using genesis (height 0) as the imported key's birthday" + ); + BlockHeight::from_u32(0) + }), + _ => unreachable!(), + }; + + let birthday = fetch_account_birthday(wallet, &chain, effective_height).await?; + + wallet + .import_account_ufvk( + &format!("Imported Sapling key {}", &address[..16]), + &ufvk, + &birthday, + AccountPurpose::Spending { derivation: None }, + None, + ) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))?; + } + + Ok(ResultType { + address_type: "sapling".to_string(), + address, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use zcash_keys::encoding::encode_extended_spending_key; + use zcash_protocol::constants; + + // Test vector: ExtendedSpendingKey derived from master key with seed [0; 32]. + // From zcash_keys::encoding tests. + const MAINNET_ENCODED_EXTSK: &str = "secret-extended-key-main1qqqqqqqqqqqqqq8n3zjjmvhhr854uy3qhpda3ml34haf0x388z5r7h4st4kpsf6qysqws3xh6qmha7gna72fs2n4clnc9zgyd22s658f65pex4exe56qjk5pqj9vfdq7dfdhjc2rs9jdwq0zl99uwycyrxzp86705rk687spn44e2uhm7h0hsagfvkk4n7n6nfer6u57v9cac84t7nl2zth0xpyfeg0w2p2wv2yn6jn923aaz0vdaml07l60ahapk6efchyxwysrvjs87qvlj"; + const TESTNET_ENCODED_EXTSK: &str = "secret-extended-key-test1qqqqqqqqqqqqqq8n3zjjmvhhr854uy3qhpda3ml34haf0x388z5r7h4st4kpsf6qysqws3xh6qmha7gna72fs2n4clnc9zgyd22s658f65pex4exe56qjk5pqj9vfdq7dfdhjc2rs9jdwq0zl99uwycyrxzp86705rk687spn44e2uhm7h0hsagfvkk4n7n6nfer6u57v9cac84t7nl2zth0xpyfeg0w2p2wv2yn6jn923aaz0vdaml07l60ahapk6efchyxwysrvjsvzyw8j"; + + // -- validate_rescan tests -- + + #[test] + fn rescan_none_defaults_to_whenkeyisnew() { + assert_eq!(validate_rescan(None).unwrap(), "whenkeyisnew"); + } + + #[test] + fn rescan_whenkeyisnew() { + assert_eq!( + validate_rescan(Some("whenkeyisnew")).unwrap(), + "whenkeyisnew" + ); + } + + #[test] + fn rescan_yes() { + assert_eq!(validate_rescan(Some("yes")).unwrap(), "yes"); + } + + #[test] + fn rescan_no() { + assert_eq!(validate_rescan(Some("no")).unwrap(), "no"); + } + + #[test] + fn rescan_invalid_value() { + assert!(validate_rescan(Some("always")).is_err()); + assert!(validate_rescan(Some("")).is_err()); + assert!(validate_rescan(Some("true")).is_err()); + } + + // -- decode_key_and_address tests -- + + #[test] + fn decode_valid_mainnet_key() { + let (_, address) = decode_key_and_address( + constants::mainnet::HRP_SAPLING_EXTENDED_SPENDING_KEY, + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + MAINNET_ENCODED_EXTSK, + ) + .unwrap(); + + // The address should be a valid Sapling address starting with "zs1". + assert!(address.starts_with("zs1")); + } + + #[test] + fn decode_valid_testnet_key() { + let (_, address) = decode_key_and_address( + constants::testnet::HRP_SAPLING_EXTENDED_SPENDING_KEY, + constants::testnet::HRP_SAPLING_PAYMENT_ADDRESS, + TESTNET_ENCODED_EXTSK, + ) + .unwrap(); + + // Testnet Sapling addresses start with "ztestsapling1". + assert!(address.starts_with("ztestsapling1")); + } + + #[test] + fn decode_same_key_produces_same_address_across_calls() { + let (_, addr1) = decode_key_and_address( + constants::mainnet::HRP_SAPLING_EXTENDED_SPENDING_KEY, + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + MAINNET_ENCODED_EXTSK, + ) + .unwrap(); + + let (_, addr2) = decode_key_and_address( + constants::mainnet::HRP_SAPLING_EXTENDED_SPENDING_KEY, + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + MAINNET_ENCODED_EXTSK, + ) + .unwrap(); + + assert_eq!(addr1, addr2); + } + + #[test] + fn decode_roundtrip_mainnet() { + let (extsk, _) = decode_key_and_address( + constants::mainnet::HRP_SAPLING_EXTENDED_SPENDING_KEY, + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + MAINNET_ENCODED_EXTSK, + ) + .unwrap(); + + let re_encoded = encode_extended_spending_key( + constants::mainnet::HRP_SAPLING_EXTENDED_SPENDING_KEY, + &extsk, + ); + assert_eq!(re_encoded, MAINNET_ENCODED_EXTSK); + } + + #[test] + fn decode_invalid_key() { + let result = decode_key_and_address( + constants::mainnet::HRP_SAPLING_EXTENDED_SPENDING_KEY, + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + "not-a-valid-key", + ); + assert!(result.is_err()); + } + + #[test] + fn decode_wrong_network_key() { + // Try to decode a testnet key with mainnet HRP — should fail. + let result = decode_key_and_address( + constants::mainnet::HRP_SAPLING_EXTENDED_SPENDING_KEY, + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + TESTNET_ENCODED_EXTSK, + ); + assert!(result.is_err()); + } + + #[test] + fn decode_empty_key() { + let result = decode_key_and_address( + constants::mainnet::HRP_SAPLING_EXTENDED_SPENDING_KEY, + constants::mainnet::HRP_SAPLING_PAYMENT_ADDRESS, + "", + ); + assert!(result.is_err()); + } +} diff --git a/zallet/src/components/json_rpc/utils.rs b/zallet/src/components/json_rpc/utils.rs index fa302052..31d2b2fa 100644 --- a/zallet/src/components/json_rpc/utils.rs +++ b/zallet/src/components/json_rpc/utils.rs @@ -2,7 +2,7 @@ use std::fmt; use jsonrpsee::{ core::{JsonValue, RpcResult}, - types::ErrorCode as RpcErrorCode, + types::{ErrorCode as RpcErrorCode, ErrorObjectOwned}, }; use rust_decimal::Decimal; use schemars::{JsonSchema, json_schema}; @@ -22,8 +22,16 @@ use super::server::LegacyCode; use { crate::components::{database::DbConnection, keystore::KeyStore}, std::collections::HashSet, - zcash_client_backend::data_api::{Account, WalletRead}, - zcash_protocol::value::BalanceError, + zaino_state::{FetchServiceSubscriber, ZcashIndexer}, + zcash_client_backend::{ + data_api::{Account, AccountBirthday, WalletRead, chain::ChainState}, + proto::service::TreeState, + }, + zcash_primitives::block::BlockHash, + zcash_protocol::{ + consensus::{NetworkType, Parameters}, + value::BalanceError, + }, zip32::fingerprint::SeedFingerprint, }; @@ -115,6 +123,76 @@ pub(super) async fn collect_standalone_transparent_keys( Ok(keys) } +/// Builds an [`AccountBirthday`] by fetching the treestate at the given height. +/// +/// For height 0 (genesis), returns a birthday with an empty chain state since the +/// commitment trees are empty. For non-zero heights, fetches the real treestate from +/// the chain indexer so the sync engine can validate note commitment tree continuity. +#[cfg(zallet_build = "wallet")] +pub(super) async fn fetch_account_birthday( + wallet: &DbConnection, + chain: &FetchServiceSubscriber, + height: BlockHeight, +) -> RpcResult { + if height == BlockHeight::from_u32(0) { + // The chain state is the state of the note commitment trees *as of the block + // preceding* the birthday. There is no block before genesis, and the conventional + // hash of genesis's (nonexistent) parent is all-zeros, so an empty chain state with + // a zero block hash is the correct anchor here. (Using the real genesis block hash + // would be wrong: that is the hash of block 0 itself, not of the block before it.) + return Ok(AccountBirthday::from_parts( + ChainState::empty(BlockHeight::from_u32(0), BlockHash([0; 32])), + None, + )); + } + + let treestate_height = height.saturating_sub(1); + let treestate = chain + .z_get_treestate(treestate_height.to_string()) + .await + .map_err(|e| { + // A failure to fetch the treestate from the chain indexer is an internal + // error, not a problem with the caller's parameters. + ErrorObjectOwned::owned( + RpcErrorCode::InternalError.code(), + format!("Failed to get treestate at height {treestate_height}: {e}"), + None::<()>, + ) + })?; + + let (hash, height, time, sapling, orchard) = ( + treestate.hash(), + treestate.height(), + treestate.time(), + treestate.sapling(), + treestate.orchard(), + ); + let treestate = TreeState { + network: match wallet.params().network_type() { + NetworkType::Main => "main".into(), + NetworkType::Test => "test".into(), + NetworkType::Regtest => "regtest".into(), + }, + height: height.0.into(), + hash: hash.to_string(), + time, + sapling_tree: sapling + .commitments() + .final_state() + .as_ref() + .map(hex::encode) + .unwrap_or_default(), + orchard_tree: orchard + .commitments() + .final_state() + .as_ref() + .map(hex::encode) + .unwrap_or_default(), + }; + + AccountBirthday::from_treestate(treestate, None).map_err(|_| RpcErrorCode::InternalError.into()) +} + pub(crate) fn parse_txid(txid_str: &str) -> RpcResult { ReverseHex::decode(txid_str) .map(TxId::from_bytes) diff --git a/zallet/src/components/keystore.rs b/zallet/src/components/keystore.rs index bc1be596..ca06a59f 100644 --- a/zallet/src/components/keystore.rs +++ b/zallet/src/components/keystore.rs @@ -135,12 +135,10 @@ use super::database::Database; use crate::fl; +use sapling::zip32::{DiversifiableFullViewingKey, ExtendedSpendingKey}; + #[cfg(feature = "zcashd-import")] -use { - sapling::zip32::{DiversifiableFullViewingKey, ExtendedSpendingKey}, - transparent::address::TransparentAddress, - zcash_keys::address::Address, -}; +use {transparent::address::TransparentAddress, zcash_keys::address::Address}; pub(super) mod db; @@ -585,7 +583,6 @@ impl KeyStore { Ok(legacy_seed_fp) } - #[cfg(feature = "zcashd-import")] pub(crate) async fn encrypt_and_store_standalone_sapling_key( &self, sapling_key: &ExtendedSpendingKey, @@ -737,6 +734,62 @@ impl KeyStore { Ok(encrypted_mnemonic) } + /// Decrypts the standalone Sapling spending key corresponding to the given payment + /// address, if one exists in the keystore. + /// + /// Unlike transparent keys (which can be looked up by address via a SQL join), Sapling + /// keys require loading all standalone DFVKs and using `decrypt_diversifier` to find + /// the one that matches the given payment address. This is because the DB schema only + /// stores the DFVK, not the derived payment addresses. + pub(crate) async fn decrypt_standalone_sapling_key( + &self, + address: &sapling::PaymentAddress, + ) -> Result, Error> { + // Acquire a read lock on the identities for decryption. + let identities = self.identities.read().await; + if identities.is_empty() { + return Err(ErrorKind::Generic.context(fl!("err-wallet-locked")).into()); + } + + // Query all standalone sapling keys and find the one matching the address. + let rows = self + .with_db(|conn, _| { + let mut stmt = conn + .prepare( + "SELECT dfvk, encrypted_sapling_extsk + FROM ext_zallet_keystore_standalone_sapling_keys", + ) + .map_err(|e| ErrorKind::Generic.context(e))?; + + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, Vec>(0)?, row.get::<_, Vec>(1)?)) + }) + .map_err(|e| ErrorKind::Generic.context(e))? + .collect::, _>>() + .map_err(|e| ErrorKind::Generic.context(e))?; + + Ok(rows) + }) + .await?; + + for (dfvk_bytes, encrypted_extsk) in rows { + let dfvk_array: [u8; 128] = match dfvk_bytes.try_into() { + Ok(arr) => arr, + Err(_) => continue, + }; + let dfvk = DiversifiableFullViewingKey::from_bytes(&dfvk_array); + if let Some(dfvk) = dfvk { + if dfvk.decrypt_diversifier(address).is_some() { + let extsk = decrypt_standalone_sapling_extsk(&identities, &encrypted_extsk)?; + return Ok(Some(extsk)); + } + } + } + + Ok(None) + } + #[cfg(feature = "zcashd-import")] pub(crate) async fn decrypt_standalone_transparent_key( &self, @@ -840,7 +893,6 @@ impl Encryptor { encrypt_secret(&self.recipients, seed) } - #[cfg(feature = "zcashd-import")] fn encrypt_standalone_sapling_key( &self, key: &ExtendedSpendingKey, @@ -915,7 +967,6 @@ fn decrypt_string( Ok(mnemonic) } -#[cfg(any(feature = "transparent-key-import", feature = "zcashd-import"))] fn encrypt_secret( recipients: &[Box], secret: &SecretVec, @@ -930,6 +981,34 @@ fn encrypt_secret( Ok(ciphertext) } +fn decrypt_standalone_sapling_extsk( + identities: &[Box], + ciphertext: &[u8], +) -> Result { + let decryptor = age::Decryptor::new(ciphertext).map_err(|e| ErrorKind::Generic.context(e))?; + + // The plaintext is always shorter than the ciphertext. Over-allocating the initial + // buffer ensures that no internal re-allocations occur that might leave plaintext + // bytes strewn around the heap. + let mut buf = Vec::with_capacity(ciphertext.len()); + let res = decryptor + .decrypt(identities.iter().map(|i| i.as_ref() as _)) + .map_err(|e| ErrorKind::Generic.context(e))? + .read_to_end(&mut buf); + + // We intentionally do not use `?` on the decryption expression because doing so in + // the case of a partial failure could result in part of the secret data being read + // into `buf`, which would not then be properly zeroized. Instead, we take ownership + // of the buffer in construction of a `SecretVec` to ensure that the memory is + // zeroed out when we raise the error on the following line. + let buf_secret = SecretVec::new(buf); + res.map_err(|e| ErrorKind::Generic.context(e))?; + let extsk = ExtendedSpendingKey::from_bytes(buf_secret.expose_secret()) + .map_err(|_| ErrorKind::Generic.context("Invalid Sapling extended spending key"))?; + + Ok(extsk) +} + #[cfg(feature = "transparent-key-import")] fn decrypt_standalone_transparent_privkey( identities: &[Box],