diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 4888c663..87608701 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -79,7 +79,7 @@ jobs: strategy: fail-fast: false matrix: - config: [default, orchard, transparent, transparent-import, sqlite, enhance-wallet, enhance, transparent-pir, transparent-pir-sqlite, transparent-pir-testing] + config: [default, orchard, transparent, transparent-import, one-time-ivk, sqlite, enhance-wallet, enhance, transparent-pir, transparent-pir-sqlite, transparent-pir-testing] env: WALLET_LIB_BUILD_ROOT: ${{ github.workspace }}/target/dev-lanes steps: diff --git a/Cargo.lock b/Cargo.lock index 8fbe3e2b..706f9c5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7843,6 +7843,7 @@ dependencies = [ "zakura-bls12-381", "zakura-jubjub", "zakura-keys", + "zakura-one-time-ivk", "zakura-orchard", "zakura-pasta-curves", "zakura-pczt", diff --git a/docs/development.md b/docs/development.md index f9f40bff..532c36d7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -22,6 +22,7 @@ python3 scripts/dev.py verify | `orchard` | Backend + SQLite; `orchard,test-dependencies` | | `transparent` | Backend + SQLite; `orchard,transparent-inputs,test-dependencies,unstable` | | `transparent-import` | Backend + SQLite; transparent features plus `transparent-key-import` | +| `one-time-ivk` | Backend; `one-time-ivk,test-dependencies` | | `sqlite` | SQLite; `test-dependencies`, without Orchard | | `enhance-wallet` | Enhance PIR; `wallet` | | `enhance` | Enhance PIR; default features | diff --git a/librustzcash/zcash_client_backend/CHANGELOG.md b/librustzcash/zcash_client_backend/CHANGELOG.md index f2f3eea5..17d69a4c 100644 --- a/librustzcash/zcash_client_backend/CHANGELOG.md +++ b/librustzcash/zcash_client_backend/CHANGELOG.md @@ -32,6 +32,28 @@ workspace. alone contributed, so a publication's facts do not outlive the policy that admitted them. This is a new required trait method: external implementors of `TransparentLedgerWrite` must add it. +- One-time IVKs (with the new `one-time-ivk` feature, which requires `orchard`): Ironwood + receiving keys that keep an account's spending authority with their own incoming viewing keys + (see `zakura-one-time-ivk`), which the wallet trial-decrypts as a changing set. `orchard` + alone builds without them. `scanning::one_time_ivk::OneTimeScanningKey` is one such key, and + received notes and outputs report theirs through `one_time_key_id`. A store implements + `data_api::one_time_ivk::{OneTimeIvkRead, OneTimeIvkWrite}` for keys and scanning. + `chain::scan_cached_blocks_with_one_time_ivks` also trial-decrypts every key from + `get_one_time_scanning_keys` and passes that snapshot to `put_blocks_with_one_time_ivks`, so a + store can rescan blocks that a key activated mid-batch missed, and + `wallet::decrypt_and_store_transaction_with_one_time_ivks` also decrypts with + `get_one_time_transaction_keys` through `decrypt_transaction_with_one_time_ivks`, which + `DecryptedTransaction::one_time_ivks_applied` records, + so a store can refuse transactions decrypted without them; no one-time key may open between + that decryption and storage. The restore protocol is separate, in + `data_api::one_time_ivk::restore::{OneTimeIvkRestoreRead, OneTimeIvkRestoreWrite}`: its sweep + steps look keys recovered from the seed up in a receiver directory, and + `restore::recovery::EncryptedNote` authenticates the notes it returns as `RecoveredNote`s; a + step that must wait returns a `SweepDeferral`. `one_time_keys_to_recheck` and + `record_paid_filter_check` sweep a closed key without a known payment when a newer + publication's paid filter holds it. + Transaction construction spends a one-time-key note with its derived viewing key and sends + change to the ordinary account key. - `transparent_ledger::{TransparentDetailRead, TransparentDetailWrite}` (prototype): durable transparent txid enhancement work (`transparent_detail_work`, returning the requests with the mode and policy generation of the same snapshot as `TransparentDetailWork`, the due ones among @@ -78,6 +100,10 @@ workspace. - `TransactionHistoryDetails::inferred_outgoing` documents the Activity default that the account funded a recovered send and its fee unless recovered evidence identifies another contributor. This display assumption does not establish payment or fee attribution. +- `ll::wallet::put_blocks` and `ll::wallet::put_blocks_rows` take + `ironwood_nullifier_retention` (with `one-time-ivk`): a height from which Ironwood nullifiers + stay tracked, for a store that finds Ironwood notes after scanning past them. Pass `None` + to keep the previous behaviour. - `enhance_pir::storage::validate_and_apply_records` captures the stored-metadata snapshot for has-transparent records too, so storage can compare it before keeping such a record's authenticated memo and agreeing fee without public authority. A `PrivateDetailsUnsupported` diff --git a/librustzcash/zcash_client_backend/Cargo.toml b/librustzcash/zcash_client_backend/Cargo.toml index 836f2e6e..8a82d37c 100644 --- a/librustzcash/zcash_client_backend/Cargo.toml +++ b/librustzcash/zcash_client_backend/Cargo.toml @@ -26,6 +26,7 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] zakura-pir-enhance-types = { version = "=0.0.1-rc0", path = "../../zakura/pir-enhance-types" } +zakura-one-time-ivk = { version = "=0.0.1-rc0", path = "../../zakura/one-time-ivk", optional = true } zcash_address.workspace = true zcash_encoding.workspace = true zcash_keys = { workspace = true, features = ["std", "sapling"] } @@ -209,6 +210,10 @@ spend-index = ["transparent-inputs"] ## Orchard note-handling code, not the crate linkage. orchard = ["dep:pasta_curves", "zcash_keys/orchard"] +## Enables one-time IVKs, derived Ironwood receiving keys (see +## `data_api::one_time_ivk`). +one-time-ivk = ["orchard", "dep:zakura-one-time-ivk"] + ## Enables compatibility with legacy zcashd wallet data zcashd-compat = ["zcash_keys/zcashd-compat"] diff --git a/librustzcash/zcash_client_backend/src/data_api.rs b/librustzcash/zcash_client_backend/src/data_api.rs index 2f4c2b8d..eef87476 100644 --- a/librustzcash/zcash_client_backend/src/data_api.rs +++ b/librustzcash/zcash_client_backend/src/data_api.rs @@ -107,6 +107,8 @@ use crate::{ }; pub mod enhance_pir; +#[cfg(feature = "one-time-ivk")] +pub mod one_time_ivk; pub mod status; pub mod transparent_ledger; @@ -2976,6 +2978,8 @@ pub struct DecryptedTransaction<'a, Tx: DecryptableTransaction, Accou orchard_outputs: Vec, #[cfg(feature = "orchard")] ironwood_outputs: Vec, + #[cfg(feature = "one-time-ivk")] + one_time_ivks_applied: bool, } impl<'a, Tx: DecryptableTransaction, AccountId> DecryptedTransaction<'a, Tx, AccountId> { @@ -2998,6 +3002,8 @@ impl<'a, Tx: DecryptableTransaction, AccountId> DecryptedTransaction< orchard_outputs, #[cfg(feature = "orchard")] ironwood_outputs, + #[cfg(feature = "one-time-ivk")] + one_time_ivks_applied: false, } } @@ -3027,6 +3033,23 @@ impl<'a, Tx: DecryptableTransaction, AccountId> DecryptedTransaction< &self.ironwood_outputs } + /// Whether the wallet's one-time keys were tried, through + /// [`decrypt_transaction_with_one_time_ivks`]. While a one-time key is open, a + /// store may refuse a transaction decrypted without them. + /// + /// [`decrypt_transaction_with_one_time_ivks`]: crate::decrypt_transaction_with_one_time_ivks + #[cfg(feature = "one-time-ivk")] + pub fn one_time_ivks_applied(&self) -> bool { + self.one_time_ivks_applied + } + + /// Records that the wallet's one-time keys were tried. + #[cfg(feature = "one-time-ivk")] + pub(crate) fn with_one_time_ivks_applied(mut self) -> Self { + self.one_time_ivks_applied = true; + self + } + /// Returns whether the transaction has decrypted outputs pub fn has_decrypted_outputs(&self) -> bool { let has_sapling = !self.sapling_outputs.is_empty(); diff --git a/librustzcash/zcash_client_backend/src/data_api/chain.rs b/librustzcash/zcash_client_backend/src/data_api/chain.rs index a2200067..6e839f94 100644 --- a/librustzcash/zcash_client_backend/src/data_api/chain.rs +++ b/librustzcash/zcash_client_backend/src/data_api/chain.rs @@ -159,7 +159,7 @@ use zcash_primitives::block::BlockHash; use zcash_protocol::consensus::{self, BlockHeight}; use crate::{ - data_api::WalletWrite, + data_api::{ScannedBlock, WalletWrite}, proto::compact_formats::CompactBlock, scanning::{ Nullifiers, ScanningKeys, @@ -172,6 +172,9 @@ use { super::scanning::ScanPriority, crate::data_api::scanning::ScanRange, async_trait::async_trait, }; +#[cfg(feature = "one-time-ivk")] +use super::one_time_ivk::OneTimeIvkWrite; + pub mod error; use error::Error; @@ -639,7 +642,99 @@ where .get_unified_full_viewing_keys() .map_err(Error::Wallet)?; let scanning_keys = ScanningKeys::from_account_ufvks(account_ufvks); - let mut runners = BatchRunners::<_, (), (), ()>::for_keys(100, &scanning_keys); + let (scan_summary, scanned_blocks) = scan_blocks( + params, + block_source, + data_db, + from_height, + limit, + &scanning_keys, + )?; + + data_db + .put_blocks(from_state, scanned_blocks) + .map_err(Error::Wallet)?; + Ok(scan_summary) +} + +/// [`scan_cached_blocks`], also trial-decrypting with the wallet's one-time keys. +/// +/// The keys come from [`OneTimeIvkRead::get_one_time_scanning_keys`], and the blocks are +/// stored with [`OneTimeIvkWrite::put_blocks_with_one_time_ivks`]. A wallet with open +/// one-time keys must be scanned with this function rather than [`scan_cached_blocks`]. +/// +/// [`OneTimeIvkRead::get_one_time_scanning_keys`]: super::one_time_ivk::OneTimeIvkRead::get_one_time_scanning_keys +#[cfg(feature = "one-time-ivk")] +#[tracing::instrument(skip(params, block_source, data_db, from_state))] +#[allow(clippy::type_complexity)] +pub fn scan_cached_blocks_with_one_time_ivks( + params: &ParamsT, + block_source: &BlockSourceT, + data_db: &mut DbT, + from_height: BlockHeight, + from_state: &ChainState, + limit: usize, +) -> Result::Error, BlockSourceT::Error>> +where + ParamsT: consensus::Parameters + Send + 'static, + BlockSourceT: BlockSource, + DbT: OneTimeIvkWrite, + ::AccountId: ConditionallySelectable + Default + Send + Sync + 'static, +{ + assert_eq!(from_height, from_state.block_height + 1); + + let account_ufvks = data_db + .get_unified_full_viewing_keys() + .map_err(Error::Wallet)?; + let one_time_keys = data_db + .get_one_time_scanning_keys() + .map_err(Error::Wallet)?; + let one_time_key_ids = one_time_keys + .iter() + .map(|key| (*key.account_id(), key.key_id())) + .collect::>(); + let scanning_keys = + ScanningKeys::from_account_ufvks(account_ufvks).with_one_time_ivks(one_time_keys); + let (scan_summary, scanned_blocks) = scan_blocks( + params, + block_source, + data_db, + from_height, + limit, + &scanning_keys, + )?; + + data_db + .put_blocks_with_one_time_ivks(from_state, scanned_blocks, &one_time_key_ids) + .map_err(Error::Wallet)?; + Ok(scan_summary) +} + +/// Scans at most `limit` blocks from `from_height` with `scanning_keys`, for +/// [`scan_cached_blocks`] and its one-time IVK twin, returning the blocks to store. +#[allow(clippy::type_complexity)] +fn scan_blocks( + params: &ParamsT, + block_source: &BlockSourceT, + data_db: &DbT, + from_height: BlockHeight, + limit: usize, + scanning_keys: &ScanningKeys<::AccountId, IvkTag>, +) -> Result< + ( + ScanSummary, + Vec::AccountId>>, + ), + Error<::Error, BlockSourceT::Error>, +> +where + ParamsT: consensus::Parameters + Send + 'static, + BlockSourceT: BlockSource, + DbT: WalletRead, + ::AccountId: ConditionallySelectable + Default + Send + Sync + 'static, + IvkTag: Copy + std::hash::Hash + Eq + Send + 'static, +{ + let mut runners = BatchRunners::<_, (), (), ()>::for_keys(100, scanning_keys); block_source.with_blocks::<_, ::Error>( Some(from_height), @@ -669,13 +764,14 @@ where let scanned_block = scan_block_with_runners::<_, _, _, (), (), ()>( params, block, - &scanning_keys, + scanning_keys, &nullifiers, prior_block_metadata.as_ref(), Some(&mut runners), ) .map_err(Error::Scan)?; + // ScanSummary omits ordinary and one-time Ironwood notes; adding Ironwood counters is separate work. for wtx in &scanned_block.transactions { scan_summary.spent_sapling_note_count += wtx.sapling_spends().len(); scan_summary.received_sapling_note_count += wtx.sapling_outputs().len(); @@ -694,10 +790,7 @@ where }, )?; - data_db - .put_blocks(from_state, scanned_blocks) - .map_err(Error::Wallet)?; - Ok(scan_summary) + Ok((scan_summary, scanned_blocks)) } #[cfg(feature = "test-dependencies")] diff --git a/librustzcash/zcash_client_backend/src/data_api/enhance_pir/storage.rs b/librustzcash/zcash_client_backend/src/data_api/enhance_pir/storage.rs index cb35b1ea..aeb6cd7c 100644 --- a/librustzcash/zcash_client_backend/src/data_api/enhance_pir/storage.rs +++ b/librustzcash/zcash_client_backend/src/data_api/enhance_pir/storage.rs @@ -257,10 +257,11 @@ struct FullOutput { enc_ciphertext: [u8; 580], } -fn stitched_enc_ciphertext(prefix: &[u8; 52], record: &EnhanceRecord) -> [u8; 580] { +/// Joins a compact ciphertext prefix and the remaining suffix into a note ciphertext. +pub(crate) fn stitched_enc_ciphertext(prefix: &[u8; 52], suffix: &[u8; 528]) -> [u8; 580] { let mut ciphertext = [0; 580]; ciphertext[..52].copy_from_slice(prefix); - ciphertext[52..].copy_from_slice(record.enc_ciphertext_suffix()); + ciphertext[52..].copy_from_slice(suffix); ciphertext } @@ -512,7 +513,10 @@ fn decrypt_memo( let output = FullOutput { cmx: cmx.to_bytes(), ephemeral_key: pending.ephemeral_key, - enc_ciphertext: stitched_enc_ciphertext(&pending.compact_ciphertext, record), + enc_ciphertext: stitched_enc_ciphertext( + &pending.compact_ciphertext, + record.enc_ciphertext_suffix(), + ), }; let (note, _, memo) = zcash_note_encryption::try_note_decryption( &IronwoodDomain::for_compact_action(&compact), @@ -543,7 +547,10 @@ fn recover_outgoing( let output = FullOutput { cmx: pending.cmx, ephemeral_key: pending.ephemeral_key, - enc_ciphertext: stitched_enc_ciphertext(&pending.compact_ciphertext, record), + enc_ciphertext: stitched_enc_ciphertext( + &pending.compact_ciphertext, + record.enc_ciphertext_suffix(), + ), }; try_output_recovery_with_ovk( &IronwoodDomain::for_compact_action(&compact), diff --git a/librustzcash/zcash_client_backend/src/data_api/ll.rs b/librustzcash/zcash_client_backend/src/data_api/ll.rs index 2560d619..c356b71a 100644 --- a/librustzcash/zcash_client_backend/src/data_api/ll.rs +++ b/librustzcash/zcash_client_backend/src/data_api/ll.rs @@ -541,6 +541,8 @@ pub trait LowLevelWalletWrite: LowLevelWalletRead { /// `(fully_scanned_height - pruning_depth)`, where `fully_scanned_height` is the height /// of the wallet's fully scanned chain state. These nullifiers are no longer needed because /// any notes they could have spent would have already been discovered during scanning. + /// A store that passes an `ironwood_nullifier_retention` height to + /// [`wallet::put_blocks_rows`] must keep Ironwood nullifiers recorded at or above it. /// /// # Parameters /// - `pruning_depth`: The number of blocks below the fully scanned height at which to @@ -735,6 +737,12 @@ pub trait ReceivedShieldedOutput { fn note_commitment_tree_position(&self) -> Option; /// Returns the HD derivation scope of the viewing key that decrypted the note, if known. fn recipient_key_scope(&self) -> Option; + + /// Derived Ironwood receiving key relative to the owning account. + #[cfg(feature = "one-time-ivk")] + fn one_time_key_id(&self) -> Option { + None + } } /// This trait provides a generalization over shielded Sapling output representations. @@ -843,6 +851,11 @@ impl ReceivedShieldedOutput for WalletOrchardOutput { + #[cfg(feature = "one-time-ivk")] + fn one_time_key_id(&self) -> Option { + self.one_time_key_id() + } + fn compact_encryption_fields(&self) -> Option { self.compact_ciphertext() .map(|ciphertext| crate::wallet::CompactEncryptionFields { @@ -899,6 +912,11 @@ impl ReceivedShieldedOutput for WalletOrchardOutput impl ReceivedShieldedOutput for DecryptedOutput<(::orchard::Note, ::orchard::ValuePool), AccountId> { + #[cfg(feature = "one-time-ivk")] + fn one_time_key_id(&self) -> Option { + self.one_time_key_id() + } + type AccountId = AccountId; type Note = ::orchard::Note; type Nullifier = ::orchard::note::Nullifier; diff --git a/librustzcash/zcash_client_backend/src/data_api/ll/wallet.rs b/librustzcash/zcash_client_backend/src/data_api/ll/wallet.rs index 9da3cd94..d5770bf4 100644 --- a/librustzcash/zcash_client_backend/src/data_api/ll/wallet.rs +++ b/librustzcash/zcash_client_backend/src/data_api/ll/wallet.rs @@ -289,6 +289,10 @@ pub struct PutBlocksRows { /// - `blocks`: The scanned block data to be added to the data store. This vector must contain /// data for blocks in sequentially increasing height order; /// [`PutBlocksError::NonSequentialBlocks`] will be returned if this invariant is violated. +/// - `ironwood_nullifier_retention` (with `one-time-ivk`): If `Some(height)`, Ironwood +/// nullifiers from `height` are tracked even below the floor described under "Nullifier +/// tracking", for a store that can find Ironwood notes after scanning past them. See +/// [`LowLevelWalletWrite::prune_tracked_nullifiers`] for the pruning contract. /// /// # Nullifier tracking /// @@ -303,6 +307,7 @@ pub struct PutBlocksRows { /// either received in an already-scanned block (so its spend is detected directly against /// the wallet's own nullifiers rather than the map) or is received later in this same /// ascending batch (so the spend is linked when the receiving transaction is processed). +/// `ironwood_nullifier_retention` can lower the Ironwood floor independently. /// For every out-of-order range — scanning after a gap, recent-first, or chain-tip /// pre-scans — the nullifiers of every block are tracked. pub fn put_blocks_rows( @@ -310,6 +315,7 @@ pub fn put_blocks_rows( #[cfg(feature = "transparent-inputs")] gap_limits: GapLimits, from_state: &ChainState, blocks: Vec::AccountId>>, + #[cfg(feature = "one-time-ivk")] ironwood_nullifier_retention: Option, ) -> Result> where DbT: PutBlocksRowsDbT::AccountRef>, @@ -349,6 +355,11 @@ where from_state.block_height(), blocks.last().map(|block| block.height()), ); + #[cfg(feature = "one-time-ivk")] + let ironwood_tracking_floor = nullifier_tracking_floor + .map(|floor| ironwood_nullifier_retention.map_or(floor, |height| floor.min(height))); + #[cfg(all(feature = "orchard", not(feature = "one-time-ivk")))] + let ironwood_tracking_floor = nullifier_tracking_floor; let mut sapling_commitments = vec![]; #[cfg(feature = "orchard")] @@ -518,8 +529,9 @@ where wallet_db .track_block_orchard_nullifiers(block.height(), block.orchard().nullifier_map()) .map_err(PutBlocksError::Storage)?; - - #[cfg(feature = "orchard")] + } + #[cfg(feature = "orchard")] + if should_track_nullifiers(ironwood_tracking_floor, block.height()) { wallet_db .track_block_ironwood_nullifiers(block.height(), block.ironwood().nullifier_map()) .map_err(PutBlocksError::Storage)?; @@ -629,12 +641,14 @@ where /// boundary block containing no shielded outputs in any pool would otherwise leave a permanent /// hole in the retained grid, and the anchor there could never be proved against. `None` /// disables anchor retention. +/// - `ironwood_nullifier_retention` (with `one-time-ivk`): See [`put_blocks_rows`]. pub fn put_blocks( wallet_db: &mut DbT, #[cfg(feature = "transparent-inputs")] gap_limits: GapLimits, from_state: &ChainState, blocks: Vec::AccountId>>, anchor_retention: Option<&AnchorRetention>, + #[cfg(feature = "one-time-ivk")] ironwood_nullifier_retention: Option, ) -> Result<(), PutBlocksError> where DbT: PutBlocksDbT::AccountRef>, @@ -646,6 +660,8 @@ where gap_limits, from_state, blocks, + #[cfg(feature = "one-time-ivk")] + ironwood_nullifier_retention, )?; let mut sapling_commitments = rows.sapling_commitments; diff --git a/librustzcash/zcash_client_backend/src/data_api/one_time_ivk.rs b/librustzcash/zcash_client_backend/src/data_api/one_time_ivk.rs new file mode 100644 index 00000000..477cd122 --- /dev/null +++ b/librustzcash/zcash_client_backend/src/data_api/one_time_ivk.rs @@ -0,0 +1,61 @@ +//! Storage-neutral APIs for one-time IVKs. +//! +//! A one-time key (see [`zakura_one_time_ivk`]) keeps its account's spending authority +//! with its own incoming viewing key, so the wallet trial-decrypts with a set of keys +//! that changes as keys open and close. A store that implements [`OneTimeIvkWrite`] is +//! scanned with +//! [`scan_cached_blocks_with_one_time_ivks`](super::chain::scan_cached_blocks_with_one_time_ivks) +//! and [`decrypt_and_store_transaction_with_one_time_ivks`], which try its open keys +//! alongside the account keys. Recovering keys from the seed after a restore is a +//! separate protocol, in [`restore`]. +//! +//! [`decrypt_and_store_transaction_with_one_time_ivks`]: super::wallet::decrypt_and_store_transaction_with_one_time_ivks + +use zakura_one_time_ivk::KeyId; +use zcash_primitives::transaction::TxId; +use zcash_protocol::consensus::BlockHeight; + +#[cfg(feature = "test-dependencies")] +use ambassador::delegatable_trait; + +use super::{ScannedBlock, WalletRead, WalletWrite, chain::ChainState}; +use crate::scanning::one_time_ivk::OneTimeScanningKey; + +pub mod restore; + +/// One-time IVK reads: the keys to scan and decrypt with. +#[cfg_attr(feature = "test-dependencies", delegatable_trait)] +pub trait OneTimeIvkRead: WalletRead { + /// Returns the one-time keys to trial-decrypt in every scan batch. + fn get_one_time_scanning_keys( + &self, + ) -> Result>, Self::Error>; + + /// Selects keys for full transaction decryption, retaining known-note ownership + /// even after a key stops scanning. `receivers` come from ordinary authenticated + /// decryption and preserve self-payments before compact scanning. + /// Stores without a transaction index can conservatively return every registered key. + fn get_one_time_transaction_keys( + &self, + txid: TxId, + height: Option, + receivers: &[orchard::Address], + ) -> Result>, Self::Error>; +} + +/// One-time IVK writes: storing blocks scanned with one-time keys. +#[cfg_attr(feature = "test-dependencies", delegatable_trait)] +pub trait OneTimeIvkWrite: WalletWrite + OneTimeIvkRead { + /// Persists scanned blocks together with the one-time keys that scanned them. + /// + /// `keys` must be the snapshot used for trial decryption, not a fresh lookup, so the + /// store can requeue blocks that a key activated mid-batch missed. While a one-time + /// key is open, a store may refuse blocks from [`WalletWrite::put_blocks`], which + /// were scanned without it. + fn put_blocks_with_one_time_ivks( + &mut self, + from_state: &ChainState, + blocks: Vec::AccountId>>, + keys: &[(::AccountId, KeyId)], + ) -> Result<(), ::Error>; +} diff --git a/librustzcash/zcash_client_backend/src/data_api/one_time_ivk/restore.rs b/librustzcash/zcash_client_backend/src/data_api/one_time_ivk/restore.rs new file mode 100644 index 00000000..1f04e452 --- /dev/null +++ b/librustzcash/zcash_client_backend/src/data_api/one_time_ivk/restore.rs @@ -0,0 +1,253 @@ +//! The one-time IVK restore protocol. +//! +//! A restored wallet cannot rescan public history for every key its seed may have +//! used, so it looks each recovered key up once in a receiver directory instead: a +//! restore sweep. A store that implements [`OneTimeIvkRestoreWrite`] runs one. +//! +//! A sweep takes each due [`DiscoveryWork`] item from +//! [`OneTimeIvkRestoreWrite::prepare_one_time_sweeps`] and leases it with +//! [`OneTimeIvkRestoreWrite::begin_one_time_sweep_attempt`]. Every attempt looks the +//! item's receiver up in the directory's current publication, then retrieves note +//! data for the positions [`OneTimeIvkRestoreRead::directory_note_data_needed`] +//! returns and queues it with [`OneTimeIvkRestoreWrite::queue_directory_lookup`], in +//! batches, until the lookup is done. Then it calls +//! [`OneTimeIvkRestoreWrite::apply_one_time_sweep`]. Each step commits atomically, so +//! a caller may stop between any two. + +use std::{collections::BTreeMap, num::NonZeroU32}; + +#[cfg(feature = "test-dependencies")] +use ambassador::delegatable_trait; +use zakura_one_time_ivk::KeyId; +use zcash_protocol::consensus::BlockHeight; + +use super::{OneTimeIvkRead, OneTimeIvkWrite}; +use crate::data_api::{WalletRead, transparent_ledger::ChainPoint}; + +pub mod recovery; + +/// A directory publication more than this many blocks behind the wallet's scanned tip +/// is stale: finishing sweeps at it would leave a long rescan behind. +pub const MAX_PUBLICATION_LAG: u32 = 100; + +/// One recovered key's due restore sweep. Public metadata is not note ownership. +#[derive(Clone, PartialEq, Eq)] +pub struct DiscoveryWork { + /// Derivation identity. Derive only when authenticating returned notes. + pub key: KeyId, + /// The key's receiver, as canonical address bytes. + pub receiver: [u8; 43], +} + +/// A payment the receiver directory reports for a receiver, from public block data. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DirectoryPayment { + /// Height of the block containing the payment. + pub height: u32, + /// Hash of that block. + pub block_hash: [u8; 32], + /// Transaction ID. + pub txid: [u8; 32], + /// Transaction index within the block. + pub tx_index: u32, + /// Action index within the transaction. + pub action_index: u32, + /// Note commitment tree position. + pub position: u64, + /// The action's input nullifier, not the received note's spend nullifier. + pub action_nullifier: [u8; 32], + /// Note commitment. + pub cmx: [u8; 32], + /// Ephemeral key. + pub ephemeral_key: [u8; 32], + /// The first 52 bytes of the note ciphertext. + pub ciphertext_prefix: [u8; 52], +} + +/// Why a restore sweep step must wait for more scanning or a newer publication. The +/// step changed nothing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SweepDeferral { + /// A block the step relies on is not on the wallet's scanned chain. + UnknownAnchor, + /// The publication is more than [`MAX_PUBLICATION_LAG`] blocks behind the wallet. + StalePublication, +} + +/// Why [`OneTimeIvkRestoreWrite::apply_one_time_sweep`] stopped. Incomplete work stays queued +/// and never contributes to wallet balance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaymentApplication { + /// The wallet must finish scanning its current accepted tip. + AwaitingScan, + /// No usable inclusion path exists at the accepted anchor. + AwaitingWitness, + /// Locally retained history cannot establish absence of a spend. + AwaitingSpendHistory, + /// The directory's claim failed a local check: the note's block or inclusion path, + /// or its transaction identity against stored data. The key's queued lookup is + /// discarded. + Rejected, + /// Note, witness, key identity, and any known spend were committed together, or the + /// wallet already stored the note under another transaction, or the note predates + /// the account's birthday and, as with any such note, is not tracked. No memo is + /// stored (see [`EncryptedNote::decrypt`]). + /// + /// [`EncryptedNote::decrypt`]: recovery::EncryptedNote::decrypt + Applied, +} + +/// One-time IVK restore reads: sweep and recheck state. +#[cfg_attr(feature = "test-dependencies", delegatable_trait)] +pub trait OneTimeIvkRestoreRead: OneTimeIvkRead { + /// The anchor of a directory publication covering blocks through `height`, as the + /// wallet's own chain records it. `through` is the wallet's fully scanned tip. Defers + /// a block the wallet has not scanned, and one more than [`MAX_PUBLICATION_LAG`] + /// blocks below `through`. + fn directory_publication_anchor( + &self, + height: BlockHeight, + through: ChainPoint, + ) -> Result, Self::Error>; + + /// The commitment tree positions of `payments`, the directory's lookup for `key`, + /// whose note data [`OneTimeIvkRestoreWrite::queue_directory_lookup`] needs. + /// Payments already imported or queued unchanged need none. A payment that + /// contradicts an imported output is an error; one that corrects a queued candidate + /// needs data. + fn directory_note_data_needed( + &self, + account: Self::AccountId, + key: KeyId, + payments: &[DirectoryPayment], + ) -> Result, Self::Error>; + + /// Whether `account` still has restore sweeps or queued payments through `through`. + /// Retry backoff never makes an unfinished restore appear complete. + fn one_time_history_pending( + &self, + account: Self::AccountId, + through: BlockHeight, + ) -> Result; + + /// `account`'s closed keys with no known payment that no paid filter at or above + /// `height` has checked, for [`OneTimeIvkRestoreWrite::record_paid_filter_check`]. + fn one_time_keys_to_recheck( + &self, + account: Self::AccountId, + height: BlockHeight, + ) -> Result, Self::Error>; +} + +/// One-time IVK restore writes: key recovery, restore sweeps (see the +/// [module documentation](self)), and release of the spend evidence they retain. +#[cfg_attr(feature = "test-dependencies", delegatable_trait)] +pub trait OneTimeIvkRestoreWrite: OneTimeIvkWrite + OneTimeIvkRestoreRead { + /// Keeps `account`'s one-time key recovery current. Call when each sync starts, + /// before planning scan work, and again once it reaches the tip. + /// + /// Retains Ironwood spend evidence from the account's birthday until + /// [`Self::finish_one_time_nullifier_recovery`] releases it; evidence pruned before + /// the first call cannot be recovered without a rescan. Once scanning reaches the + /// chain tip at or above Ironwood activation, it also registers refund keys from + /// confirmed funding memos and keeps incoming lookahead keys above the highest + /// restored index, each queued for one restore sweep. + fn maintain_one_time_ivks( + &mut self, + account: ::AccountId, + ) -> Result<(), ::Error>; + + /// Selects at most `limit` of `account`'s sweeps due at `now`, in seconds, through + /// `through`, the wallet's fully scanned tip, without reconstructing historical keys. + /// Attempts are leased separately just before I/O, so a stopped batch cannot starve + /// its tail. + fn prepare_one_time_sweeps( + &mut self, + account: ::AccountId, + through: ChainPoint, + now: i64, + limit: NonZeroU32, + ) -> Result, SweepDeferral>, ::Error>; + + /// Leases `key`'s sweep for an attempt at `now`, just before its network lookups. + /// Every attempt, including one that fails or the process abandons, backs off the + /// next. + /// + /// `seen` is whether a swap provider seen set in the current publication holds the + /// key's receiver: the provider was given it as a payout address, so the key is never + /// issued again and restore lookahead advances past it. A hit is stored with the + /// lease, before any lookup or payment work, and is never cleared; `false` changes + /// nothing. + fn begin_one_time_sweep_attempt( + &mut self, + account: ::AccountId, + key: KeyId, + now: i64, + seen: bool, + ) -> Result<(), ::Error>; + + /// Queues the new payments in `payments`, the directory's complete lookup of `key` at + /// `anchor` (from [`OneTimeIvkRestoreRead::directory_publication_anchor`]), that have note + /// data in `note_data`: the 528 ciphertext bytes after the directory's prefix, by + /// position. Nothing is credited yet. Returns whether the lookup is done, which it is + /// once no payment lacks note data; until then the next attempt looks the receiver + /// up again and asks only for the rest. Queued candidates that `payments` no longer + /// repeats unchanged, from an earlier answer the directory has since corrected, are + /// dropped. + fn queue_directory_lookup( + &mut self, + account: ::AccountId, + key: KeyId, + anchor: ChainPoint, + payments: &[DirectoryPayment], + note_data: &BTreeMap, + ) -> Result, ::Error>; + + /// Applies `key`'s queued payments with inclusion paths at `publication`, then + /// finishes its sweep at its lookup's anchor. `witness` returns the 32 sibling hashes + /// for a commitment tree position and note commitment, or `None` when the publication + /// has none. `through` is the wallet's fully scanned tip. Returns + /// [`PaymentApplication::Applied`] once the sweep is finished, or why a payment must + /// wait or was rejected; payments applied before it stay applied. A lookup a reorg + /// removed defers the sweep to run again. + /// + /// `recent` is whether a current, complete swap provider recent set holds the key's + /// receiver, so a swap may still pay it. A recent key then scans from the block after + /// the lookup, rescanning blocks scanned without it, until the store's restore watch + /// closes it. Any other key that was not already scanning closes at the lookup; a + /// later payment to it is found by [`Self::record_paid_filter_check`]. + fn apply_one_time_sweep( + &mut self, + account: ::AccountId, + key: KeyId, + through: ChainPoint, + publication: ChainPoint, + recent: bool, + witness: impl FnMut(u32, [u8; 32]) -> Option<[[u8; 32]; 32]>, + ) -> Result, ::Error>; + + /// Records a newer publication's paid filter against keys from + /// [`OneTimeIvkRestoreRead::one_time_keys_to_recheck`]: `checked` pairs each key with whether + /// the filter holds its receiver. A key it holds may have been paid after it stopped + /// scanning, so a sweep of it is queued; any other has no payment through + /// `publication`, which is recorded so the same publication is not checked again. + /// Does nothing unless `publication` is on the wallet's chain. + fn record_paid_filter_check( + &mut self, + account: ::AccountId, + publication: ChainPoint, + checked: &[(KeyId, bool)], + ) -> Result<(), ::Error>; + + /// Releases retained Ironwood nullifiers at `through`, a canonical scanned tip, once + /// no restore sweep needs them. Missing memos, pending sweeps and queued payments + /// protect their evidence. Returns true when release reaches the tip. + /// + /// Restore discovery (see [`Self::maintain_one_time_ivks`]) runs first, in the same + /// transaction, so a payment at the lookahead's edge cannot race with pruning. + fn finish_one_time_nullifier_recovery( + &mut self, + account: ::AccountId, + through: ChainPoint, + ) -> Result::Error>; +} diff --git a/librustzcash/zcash_client_backend/src/data_api/one_time_ivk/restore/recovery.rs b/librustzcash/zcash_client_backend/src/data_api/one_time_ivk/restore/recovery.rs new file mode 100644 index 00000000..fd6650e0 --- /dev/null +++ b/librustzcash/zcash_client_backend/src/data_api/one_time_ivk/restore/recovery.rs @@ -0,0 +1,206 @@ +//! Authentication of the notes a restore sweep retrieves, before wallet accounting. +//! Chain roots and spend-history coverage must come from the wallet independently. + +use orchard::{ + keys::{FullViewingKey, Scope}, + note::{ExtractedNoteCommitment, Note, Nullifier}, + note_encryption::{CompactAction, IronwoodDomain}, + tree::{Anchor, MerklePath}, +}; +use zakura_one_time_ivk::KeyId; +use zcash_note_encryption::{EphemeralKeyBytes, ShieldedOutput, try_note_decryption}; + +use crate::data_api::enhance_pir::storage::stitched_enc_ciphertext; + +/// Serialized encrypted context size, independent of the discovery transport. +pub const ENCRYPTED_NOTE_BYTES: usize = 676; + +/// Compact Action context and the remaining ciphertext, without trusted chain metadata. +#[derive(Clone, PartialEq, Eq)] +pub struct EncryptedNote([u8; ENCRYPTED_NOTE_BYTES]); + +impl EncryptedNote { + /// Joins directory/compact fields with the suffix returned by enhancement. + /// Construction does not authenticate ciphertext or establish wallet ownership. + pub fn from_parts( + action_nullifier: [u8; 32], + cmx: [u8; 32], + ephemeral_key: [u8; 32], + prefix: [u8; 52], + suffix: &[u8; 528], + ) -> Self { + let mut bytes = [0; ENCRYPTED_NOTE_BYTES]; + bytes[..32].copy_from_slice(&action_nullifier); + bytes[32..64].copy_from_slice(&cmx); + bytes[64..96].copy_from_slice(&ephemeral_key); + bytes[96..].copy_from_slice(&stitched_enc_ciphertext(&prefix, suffix)); + Self(bytes) + } + + /// Restores encrypted context. Call `decrypt` again after loading persisted data. + pub fn from_bytes(bytes: [u8; ENCRYPTED_NOTE_BYTES]) -> Self { + Self(bytes) + } + /// Action nullifier, commitment, ephemeral key, then the 580-byte ciphertext. + pub fn to_bytes(&self) -> &[u8; ENCRYPTED_NOTE_BYTES] { + &self.0 + } + + /// Public commitment bytes used to request the matching common witness. + pub fn commitment(&self) -> [u8; 32] { + self.0[32..64].try_into().unwrap() + } + + /// Compare rediscovered compact data without discarding a saved ciphertext suffix. + pub fn matches_compact( + &self, + nullifier: [u8; 32], + commitment: [u8; 32], + ephemeral_key: [u8; 32], + prefix: [u8; 52], + ) -> bool { + self.0[..32] == nullifier + && self.0[32..64] == commitment + && self.0[64..96] == ephemeral_key + && self.0[96..148] == prefix + } + + /// Derives the expected key and authenticates the note. Returns `None` if + /// derivation, decoding or authentication fails, or the note is not a v3 note to + /// the key's address; that never shows the receiver is unused. + /// + /// The memo is discarded: one-time-key notes carry no memo the wallet reads; only + /// internal change carries the refund marker. + pub fn decrypt(&self, account: &FullViewingKey, key_id: KeyId) -> Option { + let key = key_id.derive(account).ok()?; + let nf = + Option::::from(Nullifier::from_bytes(self.0[..32].try_into().unwrap()))?; + let cmx = Option::::from(ExtractedNoteCommitment::from_bytes( + self.0[32..64].try_into().unwrap(), + ))?; + let compact = CompactAction::from_parts( + nf, + cmx, + self.ephemeral_key(), + self.0[96..148].try_into().unwrap(), + ); + let (note, recipient, _) = try_note_decryption( + &IronwoodDomain::for_compact_action(&compact), + &key.to_ivk(Scope::External).prepare(), + self, + )?; + // `IronwoodDomain` accepts only v3 plaintexts. + if recipient != key.address_at(0u32, Scope::External) { + return None; + } + Some(RecoveredNote { + nullifier: note.nullifier(&key), + note, + }) + } +} +impl ShieldedOutput for EncryptedNote { + fn ephemeral_key(&self) -> EphemeralKeyBytes { + EphemeralKeyBytes(self.0[64..96].try_into().unwrap()) + } + fn cmstar_bytes(&self) -> [u8; 32] { + self.0[32..64].try_into().unwrap() + } + fn enc_ciphertext(&self) -> &[u8; 580] { + self.0[96..].try_into().unwrap() + } +} + +/// A note authenticated with its derived receiving key. Not yet safe to credit. +/// Viewing material and plaintext deliberately have no Debug implementation. +pub struct RecoveredNote { + note: Note, + nullifier: Nullifier, +} +impl RecoveredNote { + /// Authenticated note plaintext. + pub fn note(&self) -> &Note { + &self.note + } + /// This received note's spend nullifier, computed locally from its derived FVK. + pub fn nullifier(&self) -> &Nullifier { + &self.nullifier + } + /// Whether `path` places this note's commitment at `position` under a + /// wallet-accepted root. A root supplied by the same discovery service is not + /// independent validation. This does not bind transaction metadata or establish + /// that the note is unspent. + #[must_use] + pub fn verify_position(&self, position: u64, path: &MerklePath, accepted_root: Anchor) -> bool { + position == u64::from(path.position()) + && path.root(self.note.commitment().into()) == accepted_root + } +} + +#[cfg(test)] +mod tests { + use incrementalmerkletree::Hashable; + use orchard::{ + keys::{FullViewingKey, Scope, SpendingKey}, + note::{Note, NoteVersion, RandomSeed, Rho}, + note_encryption::{IronwoodDomain, IronwoodNoteEncryption}, + tree::{Anchor, MerkleHashOrchard, MerklePath}, + value::NoteValue, + }; + use zakura_one_time_ivk::{KeyId, Purpose}; + use zcash_note_encryption::Domain; + + use super::EncryptedNote; + + #[test] + fn authenticate_derived_key_and_bind_position() { + let account = FullViewingKey::from(&SpendingKey::from_bytes([0; 32]).unwrap()); + for purpose in [Purpose::Refund, Purpose::Receive] { + let id = KeyId::new(purpose, 7); + let key = id.derive(&account).unwrap(); + let rho = Rho::from_bytes(&[9; 32]).unwrap(); + let note = Note::from_parts( + key.address_at(0u32, Scope::External), + NoteValue::from_raw(30_000), + rho, + RandomSeed::from_bytes([7; 32], &rho).unwrap(), + NoteVersion::V3, + ) + .unwrap(); + let encryptor = IronwoodNoteEncryption::new(None, note, [4; 512]); + let bytes = encryptor.encrypt_note_plaintext(); + let encrypted = EncryptedNote::from_parts( + [9; 32], + orchard::note::ExtractedNoteCommitment::from(note.commitment()).to_bytes(), + IronwoodDomain::epk_bytes(encryptor.epk()).0, + bytes[..52].try_into().unwrap(), + bytes[52..].try_into().unwrap(), + ); + let restored = EncryptedNote::from_bytes(*encrypted.to_bytes()); + let recovered = restored.decrypt(&account, id).unwrap(); + assert_eq!(recovered.note(), ¬e); + assert_eq!(recovered.nullifier(), ¬e.nullifier(&key)); + assert!(restored.decrypt(&account, KeyId::new(purpose, 8)).is_none()); + let other = FullViewingKey::from(&SpendingKey::from_bytes([1; 32]).unwrap()); + assert!(restored.decrypt(&other, id).is_none()); + for offset in [0, 32, 64, 96, 148, 675] { + let mut bytes = *restored.to_bytes(); + bytes[offset] ^= 1; + assert!( + EncryptedNote::from_bytes(bytes) + .decrypt(&account, id) + .is_none() + ); + } + let path = MerklePath::from_parts( + 2, + std::array::from_fn(|i| MerkleHashOrchard::empty_root((i as u8).into())), + ); + let root = path.root(note.commitment().into()); + assert!(recovered.verify_position(2, &path, root)); + assert!(!recovered.verify_position(3, &path, root)); + assert!(!recovered.verify_position((1u64 << 32) + 2, &path, root)); + assert!(!recovered.verify_position(2, &path, Anchor::empty_tree())); + } + } +} diff --git a/librustzcash/zcash_client_backend/src/data_api/testing.rs b/librustzcash/zcash_client_backend/src/data_api/testing.rs index a35c8fd9..5c736ed1 100644 --- a/librustzcash/zcash_client_backend/src/data_api/testing.rs +++ b/librustzcash/zcash_client_backend/src/data_api/testing.rs @@ -1122,6 +1122,73 @@ where } } +#[cfg(feature = "one-time-ivk")] +impl TestState +where + Cache: TestCache, + ::Error: fmt::Debug, + ParamsT: consensus::Parameters + Send + 'static, + DbT: InputSource + WalletTest + super::one_time_ivk::OneTimeIvkWrite + WalletCommitmentTrees, + ::AccountId: + std::fmt::Debug + ConditionallySelectable + Default + Send + Sync + 'static, +{ + /// Invokes [`scan_cached_blocks_with_one_time_ivks`] with the given arguments, expecting + /// success. + /// + /// [`scan_cached_blocks_with_one_time_ivks`]: super::chain::scan_cached_blocks_with_one_time_ivks + pub fn scan_cached_blocks_with_one_time_ivks( + &mut self, + from_height: BlockHeight, + limit: usize, + ) -> ScanSummary { + let result = self.try_scan_cached_blocks_with_one_time_ivks(from_height, limit); + assert_matches!(result, Ok(_)); + result.unwrap() + } + + /// Invokes [`scan_cached_blocks_with_one_time_ivks`] with the given arguments. + /// + /// [`scan_cached_blocks_with_one_time_ivks`]: super::chain::scan_cached_blocks_with_one_time_ivks + pub fn try_scan_cached_blocks_with_one_time_ivks( + &mut self, + from_height: BlockHeight, + limit: usize, + ) -> Result< + ScanSummary, + super::chain::error::Error< + ::Error, + ::Error, + >, + > { + let prior_cached_block = self + .latest_cached_block_below_height(from_height) + .cloned() + .unwrap_or_else(|| CachedBlock::none(from_height - 1)); + + super::chain::scan_cached_blocks_with_one_time_ivks( + &self.network, + self.cache.block_source(), + &mut self.wallet_data, + from_height, + &prior_cached_block.chain_state, + limit, + ) + } + + /// [`Self::generate_and_scan_empty_blocks`], scanning with one-time keys. + pub fn generate_and_scan_empty_blocks_with_one_time_ivks(&mut self, n: usize) -> BlockHeight { + for _ in 0..n { + let (height, _) = self.generate_empty_block(); + self.scan_cached_blocks_with_one_time_ivks(height, 1); + } + self.wallet() + .block_fully_scanned() + .expect("the wallet reports its fully-scanned block") + .expect("the wallet is fully scanned") + .block_height() + } +} + impl TestState where Cache: TestCache, diff --git a/librustzcash/zcash_client_backend/src/data_api/wallet.rs b/librustzcash/zcash_client_backend/src/data_api/wallet.rs index efcbb91f..d21e2de6 100644 --- a/librustzcash/zcash_client_backend/src/data_api/wallet.rs +++ b/librustzcash/zcash_client_backend/src/data_api/wallet.rs @@ -266,6 +266,49 @@ where Ok(()) } +/// [`decrypt_and_store_transaction`], also decrypting with the wallet's one-time keys +/// (see [`OneTimeIvkRead::get_one_time_transaction_keys`]). A wallet with open one-time +/// keys must decrypt with this function rather than [`decrypt_and_store_transaction`]. +/// +/// The keys are read before decryption and not checked again when storing, so the +/// caller must not open a one-time key between the two, for example by holding its +/// wallet write lock across this call. +/// +/// [`OneTimeIvkRead::get_one_time_transaction_keys`]: super::one_time_ivk::OneTimeIvkRead::get_one_time_transaction_keys +#[cfg(feature = "one-time-ivk")] +pub fn decrypt_and_store_transaction_with_one_time_ivks( + params: &ParamsT, + data: &mut DbT, + tx: &Transaction, + mined_height: Option, +) -> Result<(), ::Error> +where + ParamsT: consensus::Parameters, + DbT: WalletWrite + super::one_time_ivk::OneTimeIvkRead, +{ + let ufvks = data.get_unified_full_viewing_keys()?; + let mined_height = + mined_height.map_or_else(|| data.get_tx_height(tx.txid()), |h| Ok(Some(h)))?; + let chain_height = data.chain_height()?; + // The receivers the account keys recover select the one-time keys to decrypt with. + let receivers: Vec<_> = decrypt_transaction(params, mined_height, chain_height, tx, &ufvks) + .ironwood_outputs() + .iter() + .map(|output| output.note().0.recipient()) + .collect(); + let keys = data.get_one_time_transaction_keys(tx.txid(), mined_height, &receivers)?; + data.store_decrypted_tx(crate::decrypt_transaction_with_one_time_ivks( + params, + mined_height, + chain_height, + tx, + &ufvks, + keys, + ))?; + + Ok(()) +} + /// Errors that may be generated in construction of proposals for shielded->shielded or /// shielded->transparent transfers. pub type ProposeTransferErrT = Error< @@ -1645,8 +1688,8 @@ where .iter() .filter_map(|selected| match selected.note() { Note::Orchard { - note, pool: orchard::ValuePool::Ironwood, + .. } => ironwood_tree .witness_at_checkpoint_id_caching( selected.note_commitment_tree_position(), @@ -1657,7 +1700,7 @@ where QueryError::CheckpointPruned, )) }) - .map(|merkle_path| Some((note, merkle_path.into()))) + .map(|merkle_path| Some((selected, merkle_path.into()))) .map_err(Error::from) .transpose(), _ => None, @@ -1782,14 +1825,24 @@ where } #[cfg(feature = "orchard")] - for (ironwood_note, merkle_path) in ironwood_inputs.into_iter() { - builder.add_ironwood_spend( - ufvk.orchard() - .cloned() - .ok_or(Error::KeyNotAvailable(PoolType::ORCHARD))?, - *ironwood_note, - merkle_path, - )?; + for (selected, merkle_path) in ironwood_inputs.into_iter() { + let Note::Orchard { note, .. } = selected.note() else { + unreachable!() + }; + let fvk = ufvk + .orchard() + .cloned() + .ok_or(Error::KeyNotAvailable(PoolType::ORCHARD))?; + #[cfg(feature = "one-time-ivk")] + let fvk = match selected.one_time_key_id() { + // The spending authority is unchanged, but the proof needs the FVK + // whose incoming key generated this particular note's recipient. + Some(key_id) => key_id + .derive(&fvk) + .map_err(|_| Error::ProposalNotSupported)?, + None => fvk, + }; + builder.add_ironwood_spend(fvk, *note, merkle_path)?; } #[cfg(feature = "transparent-inputs")] diff --git a/librustzcash/zcash_client_backend/src/decrypt.rs b/librustzcash/zcash_client_backend/src/decrypt.rs index 1b46fa34..cb7ed762 100644 --- a/librustzcash/zcash_client_backend/src/decrypt.rs +++ b/librustzcash/zcash_client_backend/src/decrypt.rs @@ -21,6 +21,15 @@ use orchard::note_encryption::{ DomainVersion, IronwoodVersion, NoteEncryptionDomain, OrchardVersion, }; +#[cfg(feature = "one-time-ivk")] +use { + crate::scanning::one_time_ivk::OneTimeScanningKey, std::hash::Hash, zakura_one_time_ivk::KeyId, +}; + +/// The prepared one-time IVKs of an account, with their key identities. +#[cfg(feature = "one-time-ivk")] +type OneTimeIvks = [(KeyId, orchard::keys::PreparedIncomingViewingKey)]; + /// An enumeration of the possible relationships a TXO can have to the wallet. #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum TransferType { @@ -49,6 +58,8 @@ pub struct DecryptedOutput { account: AccountId, memo: MemoBytes, transfer_type: TransferType, + #[cfg(feature = "one-time-ivk")] + one_time_key_id: Option, } impl DecryptedOutput { @@ -67,9 +78,24 @@ impl DecryptedOutput { account, memo, transfer_type, + #[cfg(feature = "one-time-ivk")] + one_time_key_id: None, } } + /// Retains the derived receiving key used for authenticated note decryption. + #[cfg(feature = "one-time-ivk")] + fn with_one_time_key_id(mut self, key_id: Option) -> Self { + self.one_time_key_id = key_id; + self + } + + /// Derived receiving key, relative to the owning account. + #[cfg(feature = "one-time-ivk")] + pub fn one_time_key_id(&self) -> Option { + self.one_time_key_id + } + /// The index of the output within the shielded outputs of the Sapling bundle or the actions of /// the Orchard bundle, depending upon the type of [`Self::note`]. pub fn index(&self) -> usize { @@ -135,6 +161,71 @@ pub fn decrypt_transaction<'a, P: consensus::Parameters, AccountId: Copy>( chain_tip_height: Option, tx: &'a Transaction, ufvks: &HashMap, +) -> DecryptedTransaction<'a, Transaction, AccountId> { + decrypt_transaction_inner( + params, + mined_height, + chain_tip_height, + tx, + ufvks, + #[cfg(feature = "one-time-ivk")] + &|_| &[], + ) +} + +/// [`decrypt_transaction`], also trial-decrypting Ironwood actions with one-time keys. +/// +/// Each key is tried after its account's ordinary incoming viewing keys and before its +/// outgoing viewing key, so an output paid to it is one incoming output carrying its +/// [`DecryptedOutput::one_time_key_id`], even when the account also funded it. A key whose +/// account has no Orchard key in `ufvks` is not tried. The result reports +/// [`DecryptedTransaction::one_time_ivks_applied`]. +#[cfg(feature = "one-time-ivk")] +pub fn decrypt_transaction_with_one_time_ivks< + 'a, + P: consensus::Parameters, + AccountId: Copy + Eq + Hash, +>( + params: &P, + mined_height: Option, + chain_tip_height: Option, + tx: &'a Transaction, + ufvks: &HashMap, + one_time_keys: impl IntoIterator>, +) -> DecryptedTransaction<'a, Transaction, AccountId> { + let mut by_account = HashMap::<_, Vec<_>>::new(); + for key in one_time_keys { + let ivk = key.full_viewing_key().to_ivk(Scope::External); + by_account.entry(*key.account_id()).or_default().push(( + key.key_id(), + orchard::keys::PreparedIncomingViewingKey::new(&ivk), + )); + } + decrypt_transaction_inner( + params, + mined_height, + chain_tip_height, + tx, + ufvks, + &|account| by_account.get(account).map_or(&[], Vec::as_slice), + ) + .with_one_time_ivks_applied() +} + +/// The trial decryption shared by [`decrypt_transaction`] and, with `one_time_ivks` +/// returning each account's one-time keys, [`decrypt_transaction_with_one_time_ivks`]. +fn decrypt_transaction_inner< + 'a, + #[cfg(feature = "one-time-ivk")] 'k, + P: consensus::Parameters, + AccountId: Copy, +>( + params: &P, + mined_height: Option, + chain_tip_height: Option, + tx: &'a Transaction, + ufvks: &HashMap, + #[cfg(feature = "one-time-ivk")] one_time_ivks: &dyn Fn(&AccountId) -> &'k OneTimeIvks, ) -> DecryptedTransaction<'a, Transaction, AccountId> { let zip212_enforcement = zip212_enforcement( params, @@ -207,14 +298,20 @@ pub fn decrypt_transaction<'a, P: consensus::Parameters, AccountId: Copy>( // (selected by `V`: version 2 for [`OrchardVersion`], version 3 for [`IronwoodVersion`]) and // in the value pool their notes belong to. Decrypting a bundle under the other kind's domain // would silently detect nothing. + // An account's `one_time_ivks` are tried after its ordinary incoming viewing keys. #[cfg(feature = "orchard")] - fn decrypt_orchard_protocol_bundle( + fn decrypt_orchard_protocol_bundle< + #[cfg(feature = "one-time-ivk")] 'k, + V: DomainVersion, + AccountId: Copy, + >( ufvks: &HashMap, bundle: &orchard::bundle::Bundle< orchard::bundle::Authorized, zcash_protocol::value::ZatBalance, >, pool: orchard::ValuePool, + #[cfg(feature = "one-time-ivk")] one_time_ivks: &dyn Fn(&AccountId) -> &'k OneTimeIvks, ) -> impl Iterator> { let shielded_pool = crate::wallet::shielded_pool_for_value_pool(pool); ufvks @@ -233,12 +330,23 @@ pub fn decrypt_transaction<'a, P: consensus::Parameters, AccountId: Copy>( .enumerate() .flat_map(move |(index, action)| { let domain = NoteEncryptionDomain::::for_action(action); - try_note_decryption(&domain, &ivk_external, action) + let decrypted = try_note_decryption(&domain, &ivk_external, action) .map(|ret| (ret, TransferType::Incoming)) .or_else(|| { try_note_decryption(&domain, &ivk_internal, action) .map(|ret| (ret, TransferType::AccountInternal)) + }); + #[cfg(feature = "one-time-ivk")] + let mut one_time_key_id = None; + #[cfg(feature = "one-time-ivk")] + let decrypted = decrypted.or_else(|| { + one_time_ivks(&account).iter().find_map(|(key_id, ivk)| { + let ret = try_note_decryption(&domain, ivk, action)?; + one_time_key_id = Some(*key_id); + Some((ret, TransferType::Incoming)) }) + }); + decrypted .or_else(|| { try_output_recovery_with_ovk( &domain, @@ -251,14 +359,17 @@ pub fn decrypt_transaction<'a, P: consensus::Parameters, AccountId: Copy>( }) .into_iter() .map(move |((note, _, memo), transfer_type)| { - DecryptedOutput::new( + let output = DecryptedOutput::new( index, (note, pool), shielded_pool, account, MemoBytes::from_bytes(&memo).expect("correct length"), transfer_type, - ) + ); + #[cfg(feature = "one-time-ivk")] + let output = output.with_one_time_key_id(one_time_key_id); + output }) }) }) @@ -273,6 +384,8 @@ pub fn decrypt_transaction<'a, P: consensus::Parameters, AccountId: Copy>( ufvks, bundle, orchard::ValuePool::Orchard, + #[cfg(feature = "one-time-ivk")] + &|_| &[], ) }) .collect(); @@ -286,6 +399,8 @@ pub fn decrypt_transaction<'a, P: consensus::Parameters, AccountId: Copy>( ufvks, bundle, orchard::ValuePool::Ironwood, + #[cfg(feature = "one-time-ivk")] + one_time_ivks, ) }) .collect(); @@ -300,3 +415,110 @@ pub fn decrypt_transaction<'a, P: consensus::Parameters, AccountId: Copy>( ironwood_outputs, ) } + +#[cfg(all(test, feature = "one-time-ivk"))] +mod tests { + use std::collections::HashMap; + + use orchard::{ + Anchor, Proof, + builder::{Builder, BundleType}, + bundle::{Authorized, BundleVersion}, + keys::{FullViewingKey, Scope}, + primitives::redpallas::Signature, + value::NoteValue, + }; + use zakura_one_time_ivk::{KeyId, Purpose}; + use zcash_keys::keys::UnifiedSpendingKey; + use zcash_primitives::transaction::{Transaction, TransactionData}; + use zcash_protocol::{ + consensus::{BranchId, MAIN_NETWORK}, + value::ZatBalance, + }; + + use super::{TransferType, decrypt_transaction, decrypt_transaction_with_one_time_ivks}; + use crate::scanning::one_time_ivk::OneTimeScanningKey; + + /// An Ironwood transaction paying `outputs`, with placeholder proof and signatures. + fn ironwood_tx( + outputs: &[(Option, orchard::Address)], + ) -> Transaction { + let version = BundleVersion::ironwood_v3(); + let mut builder = Builder::new( + BundleType::DEFAULT, + version, + version.default_flags(), + Anchor::empty_tree(), + ) + .unwrap(); + for (ovk, recipient) in outputs { + builder + .add_output( + ovk.clone(), + *recipient, + NoteValue::from_raw(10_000), + [7; 512], + ) + .unwrap(); + } + let (bundle, _) = builder.build::(rand::rng()).unwrap().unwrap(); + let bundle = bundle.map_authorization( + &mut (), + |_, _, _| Signature::from([0; 64]), + |_, _| Authorized::from_parts(Proof::new(vec![]), Signature::from([0; 64])), + ); + TransactionData::from_parts_v6( + BranchId::Nu6_3, + 0, + 0u32.into(), + None, + None, + None, + Some(bundle), + ) + .freeze() + .unwrap() + } + + #[test] + fn one_time_keys_tag_payments_and_self_payments() { + let usk = + UnifiedSpendingKey::from_seed(&MAIN_NETWORK, &[1; 32], zip32::AccountId::ZERO).unwrap(); + let fvk = FullViewingKey::from(usk.orchard()); + let ufvks = HashMap::from([(0u32, usk.to_unified_full_viewing_key())]); + let refund = KeyId::new(Purpose::Refund, 3); + let receive = KeyId::new(Purpose::Receive, 5); + let address = |id: KeyId| id.derive(&fvk).unwrap().address_at(0u32, Scope::External); + // An external payment to `refund`, and a self-payment to `receive` that the + // account's OVK also recovers. + let tx = ironwood_tx(&[ + (None, address(refund)), + (Some(fvk.to_ovk(Scope::External)), address(receive)), + ]); + + let plain = decrypt_transaction(&MAIN_NETWORK, None, None, &tx, &ufvks); + assert!(!plain.one_time_ivks_applied()); + let outputs = plain.ironwood_outputs(); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].note().0.recipient(), address(receive)); + assert_eq!(outputs[0].transfer_type(), TransferType::Outgoing); + assert_eq!(outputs[0].one_time_key_id(), None); + + let keys = [refund, receive].map(|id| OneTimeScanningKey::derive(0u32, id, &fvk).unwrap()); + let decrypted = + decrypt_transaction_with_one_time_ivks(&MAIN_NETWORK, None, None, &tx, &ufvks, keys); + assert!(decrypted.one_time_ivks_applied()); + let outputs = decrypted.ironwood_outputs(); + assert_eq!(outputs.len(), 2); + for id in [refund, receive] { + let output = outputs + .iter() + .find(|o| o.note().0.recipient() == address(id)) + .unwrap(); + assert_eq!(output.one_time_key_id(), Some(id)); + assert_eq!(output.transfer_type(), TransferType::Incoming); + assert_eq!(output.account(), &0); + assert_eq!(output.memo().as_array(), &[7; 512]); + } + } +} diff --git a/librustzcash/zcash_client_backend/src/lib.rs b/librustzcash/zcash_client_backend/src/lib.rs index 1985d022..aa2444f7 100644 --- a/librustzcash/zcash_client_backend/src/lib.rs +++ b/librustzcash/zcash_client_backend/src/lib.rs @@ -80,6 +80,8 @@ mod task; #[cfg(feature = "tor")] pub mod tor; +#[cfg(feature = "one-time-ivk")] +pub use decrypt::decrypt_transaction_with_one_time_ivks; pub use decrypt::{DecryptedOutput, TransferType, decrypt_transaction}; /// Starts process-lifetime Orchard proving-key warm-up if it has not started. diff --git a/librustzcash/zcash_client_backend/src/scanning.rs b/librustzcash/zcash_client_backend/src/scanning.rs index e342ce0b..d5228b66 100644 --- a/librustzcash/zcash_client_backend/src/scanning.rs +++ b/librustzcash/zcash_client_backend/src/scanning.rs @@ -37,6 +37,8 @@ use orchard::note_encryption::OrchardDomain; pub(crate) mod compact; pub mod full; +#[cfg(feature = "one-time-ivk")] +pub mod one_time_ivk; /// A key that can be used to perform trial decryption and nullifier /// computation for a [`CompactSaplingOutput`] or [`CompactOrchardAction`]. @@ -65,6 +67,12 @@ pub trait ScanningKeyOps { /// Returns the [`zip32::Scope`] for which this key was derived, if known. fn key_scope(&self) -> Option; + /// Derived receiving key that decrypted this output, relative to its account. + #[cfg(feature = "one-time-ivk")] + fn one_time_key_id(&self) -> Option { + None + } + /// Produces the nullifier for the specified note and witness, if possible. /// /// IVK-based implementations of this trait cannot successfully derive @@ -87,6 +95,11 @@ impl> ScanningKeyO (*self).key_scope() } + #[cfg(feature = "one-time-ivk")] + fn one_time_key_id(&self) -> Option { + (*self).one_time_key_id() + } + fn nf(&self, note: &D::Note, note_position: Position) -> Option { (*self).nf(note, note_position) } @@ -107,6 +120,11 @@ impl ScanningKeyOps self.as_ref().key_scope() } + #[cfg(feature = "one-time-ivk")] + fn one_time_key_id(&self) -> Option { + self.as_ref().one_time_key_id() + } + fn nf(&self, note: &D::Note, note_position: Position) -> Option { self.as_ref().nf(note, note_position) } @@ -127,6 +145,11 @@ impl ScanningKeyOps self.as_ref().key_scope() } + #[cfg(feature = "one-time-ivk")] + fn one_time_key_id(&self) -> Option { + self.as_ref().one_time_key_id() + } + fn nf(&self, note: &D::Note, note_position: Position) -> Option { self.as_ref().nf(note, note_position) } @@ -981,23 +1004,24 @@ fn find_received< let note_commitment_tree_position = note_position(output_idx); let nf = key.nf(¬e, note_commitment_tree_position); - shielded_outputs.push( - WalletOutput::from_parts( - output_idx, - output.ephemeral_key(), - enrich_note(note), - is_change, - note_commitment_tree_position, - nf, - *key.account_id(), - key.key_scope(), - ) - .with_compact_ciphertext( - output.enc_ciphertext()[..52] - .try_into() - .expect("compact ciphertext prefix"), - ), + let output = WalletOutput::from_parts( + output_idx, + output.ephemeral_key(), + enrich_note(note), + is_change, + note_commitment_tree_position, + nf, + *key.account_id(), + key.key_scope(), + ) + .with_compact_ciphertext( + output.enc_ciphertext()[..52] + .try_into() + .expect("compact ciphertext prefix"), ); + #[cfg(feature = "one-time-ivk")] + let output = output.with_one_time_key_id(key.one_time_key_id()); + shielded_outputs.push(output); } note_commitments.push((node, retention)) diff --git a/librustzcash/zcash_client_backend/src/scanning/one_time_ivk.rs b/librustzcash/zcash_client_backend/src/scanning/one_time_ivk.rs new file mode 100644 index 00000000..23d50184 --- /dev/null +++ b/librustzcash/zcash_client_backend/src/scanning/one_time_ivk.rs @@ -0,0 +1,115 @@ +//! One-time IVKs in trial decryption. Ordinary account keys keep their existing tags. + +use std::hash::Hash; + +use incrementalmerkletree::Position; +use orchard::{ + keys::{FullViewingKey, PreparedIncomingViewingKey}, + note::Nullifier, + note_encryption::IronwoodDomain, +}; +use zakura_one_time_ivk::KeyId; +use zip32::Scope; + +use super::{ScanningKeyOps, ScanningKeys}; + +/// Identifies a trial-decryption key without conflating multiple keys of one account. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum ReceivingKeyTag { + /// An ordinary account key. + Account(AccountId, Scope), + /// A one-time key of the account. + OneTime(AccountId, KeyId), +} + +/// A derived FVK paired with the identity that must survive note detection. +/// +/// This key only scans Ironwood. Change from its spends uses ordinary account keys. +pub struct OneTimeScanningKey { + account: AccountId, + key_id: KeyId, + fvk: FullViewingKey, +} + +impl OneTimeScanningKey { + /// The account whose receiving sequence this key belongs to. + pub fn account_id(&self) -> &AccountId { + &self.account + } + + /// The identity to credit when persisting blocks scanned with this key. + pub fn key_id(&self) -> KeyId { + self.key_id + } + + /// The derived viewing key, retaining this key's account and sequence identity. + pub fn full_viewing_key(&self) -> &FullViewingKey { + &self.fvk + } + + /// Derives the receiving FVK from the owning account's ordinary external FVK. + pub fn derive( + account: AccountId, + key_id: KeyId, + account_fvk: &FullViewingKey, + ) -> Result { + Ok(Self { + account, + key_id, + fvk: key_id.derive(account_fvk)?, + }) + } +} + +impl ScanningKeyOps + for OneTimeScanningKey +{ + fn prepare(&self) -> PreparedIncomingViewingKey { + PreparedIncomingViewingKey::new(&self.fvk.to_ivk(Scope::External)) + } + + fn account_id(&self) -> &AccountId { + &self.account + } + + fn key_scope(&self) -> Option { + Some(Scope::External) + } + + fn one_time_key_id(&self) -> Option { + Some(self.key_id) + } + + fn nf(&self, note: &orchard::Note, _: Position) -> Option { + Some(note.nullifier(&self.fvk)) + } +} + +impl + ScanningKeys +{ + /// Adds one-time keys to Ironwood trial decryption, retaining ordinary keys in + /// every pool. Reload this set when registrations change and schedule missing history. + pub(crate) fn with_one_time_ivks( + self, + keys: impl IntoIterator>, + ) -> ScanningKeys> { + let tag = |(account, scope)| ReceivingKeyTag::Account(account, scope); + let mut result = ScanningKeys { + sapling: self.sapling.into_iter().map(|(k, v)| (tag(k), v)).collect(), + orchard: self.orchard.into_iter().map(|(k, v)| (tag(k), v)).collect(), + ironwood: self + .ironwood + .into_iter() + .map(|(k, v)| (tag(k), v)) + .collect(), + }; + for key in keys { + result.ironwood.insert( + ReceivingKeyTag::OneTime(key.account, key.key_id), + Box::new(key), + ); + } + result + } +} diff --git a/librustzcash/zcash_client_backend/src/wallet.rs b/librustzcash/zcash_client_backend/src/wallet.rs index 241f68c6..04acca42 100644 --- a/librustzcash/zcash_client_backend/src/wallet.rs +++ b/librustzcash/zcash_client_backend/src/wallet.rs @@ -642,6 +642,8 @@ pub struct WalletOutput { nf: Option, account_id: AccountId, recipient_key_scope: Option, + #[cfg(feature = "one-time-ivk")] + one_time_key_id: Option, } /// Compact action encryption fields retained from scanning for later enhancement. @@ -674,9 +676,24 @@ impl WalletOutput { nf, account_id, recipient_key_scope, + #[cfg(feature = "one-time-ivk")] + one_time_key_id: None, } } + /// Records the derived Ironwood receiving key used for decryption. + #[cfg(feature = "one-time-ivk")] + pub fn with_one_time_key_id(mut self, key_id: Option) -> Self { + self.one_time_key_id = key_id; + self + } + + /// The derived receiving key, or `None` for an ordinary account key. + #[cfg(feature = "one-time-ivk")] + pub fn one_time_key_id(&self) -> Option { + self.one_time_key_id + } + /// Attaches the compact encryption prefix retained by scanning. pub fn with_compact_ciphertext(mut self, ciphertext: [u8; 52]) -> Self { self.compact_ciphertext = Some(ciphertext); @@ -807,6 +824,8 @@ pub struct ReceivedNote { output_index: u16, note: NoteT, spending_key_scope: Scope, + #[cfg(feature = "one-time-ivk")] + one_time_key_id: Option, note_commitment_tree_position: Position, mined_height: Option, max_shielding_input_height: Option, @@ -831,12 +850,27 @@ impl ReceivedNote { output_index, note, spending_key_scope, + #[cfg(feature = "one-time-ivk")] + one_time_key_id: None, note_commitment_tree_position, mined_height, max_shielding_input_height, } } + /// Associates this Ironwood note with its account's derived receiving key. + #[cfg(feature = "one-time-ivk")] + pub fn with_one_time_key_id(mut self, key_id: Option) -> Self { + self.one_time_key_id = key_id; + self + } + + /// Receiving key to rederive before spending, relative to the account FVK. + #[cfg(feature = "one-time-ivk")] + pub fn one_time_key_id(&self) -> Option { + self.one_time_key_id + } + /// Returns the storage backend's internal identifier for the note. pub fn internal_note_id(&self) -> &NoteRef { &self.note_id @@ -888,6 +922,8 @@ impl ReceivedNote { output_index: self.output_index, note: f(self.note), spending_key_scope: self.spending_key_scope, + #[cfg(feature = "one-time-ivk")] + one_time_key_id: self.one_time_key_id, note_commitment_tree_position: self.note_commitment_tree_position, mined_height: self.mined_height, max_shielding_input_height: self.max_shielding_input_height, diff --git a/scripts/dev.py b/scripts/dev.py index 60ac4a9b..387b444c 100644 --- a/scripts/dev.py +++ b/scripts/dev.py @@ -19,6 +19,7 @@ "orchard": (["-p", "zakura-client-backend", "-p", "zakura-client-sqlite"], ["orchard", "test-dependencies"]), "transparent": (["-p", "zakura-client-backend", "-p", "zakura-client-sqlite"], ["orchard", "transparent-inputs", "test-dependencies", "unstable"]), "transparent-import": (["-p", "zakura-client-backend", "-p", "zakura-client-sqlite"], ["orchard", "transparent-inputs", "transparent-key-import", "test-dependencies", "unstable"]), + "one-time-ivk": (["-p", "zakura-client-backend"], ["one-time-ivk", "test-dependencies"]), "sqlite": (["-p", "zakura-client-sqlite"], ["test-dependencies"]), "enhance-wallet": (["-p", "zakura-pir-enhance"], ["wallet"]), "enhance": (["-p", "zakura-pir-enhance"], []),