From 368ec7958c01f8e3ccc305d271ee29083e06c6a4 Mon Sep 17 00:00:00 2001 From: Alfredo Garcia Date: Thu, 19 Mar 2026 08:01:39 -0300 Subject: [PATCH] rpc: Implement `z_importkey` and `z_exportkey` for Sapling spending keys Adds two new RPC methods: - `z_importkey`: Imports a Sapling extended spending key into the wallet. The key is encrypted with age and stored in the keystore. A UFVK is derived and imported into the wallet database so the sync engine can track transactions. The rescan parameter controls whether historical blocks are scanned ("yes", "no", or "whenkeyisnew"), and start_height sets the birthday for the imported account. Real treestates are fetched from the chain indexer for non-zero start heights. - `z_exportkey`: Exports the spending key for a Sapling payment address. Requires the wallet to be unlocked. Looks up the key by iterating standalone sapling DFVKs and using decrypt_diversifier to match the address. Its documentation warns that the exported key is not a complete backup, as Orchard funds under the same spending authority are not represented. Also adds `fetch_account_birthday` to `json_rpc::utils` as a shared helper for building an AccountBirthday from a chain treestate. Includes unit tests for parameter validation, key encoding/decoding roundtrips, and address parsing, and a CHANGELOG entry for both methods. Co-Authored-By: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 + zallet/src/components/json_rpc/methods.rs | 65 ++++ .../components/json_rpc/methods/export_key.rs | 111 +++++++ .../components/json_rpc/methods/import_key.rs | 298 ++++++++++++++++++ zallet/src/components/json_rpc/utils.rs | 84 ++++- zallet/src/components/keystore.rs | 95 +++++- 6 files changed, 644 insertions(+), 11 deletions(-) create mode 100644 zallet/src/components/json_rpc/methods/export_key.rs create mode 100644 zallet/src/components/json_rpc/methods/import_key.rs 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],