diff --git a/zingolib/src/wallet/disk.rs b/zingolib/src/wallet/disk.rs index 81edfa6294..32a87dcac4 100644 --- a/zingolib/src/wallet/disk.rs +++ b/zingolib/src/wallet/disk.rs @@ -90,6 +90,30 @@ fn chain_name_from_stored(stored: &str) -> io::Result<&'static str> { } } +struct CountingReader { + inner: R, + offset: u64, +} + +impl Read for CountingReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let bytes_read = self.inner.read(buf)?; + self.offset += bytes_read as u64; + Ok(bytes_read) + } +} + +/// Reads a little-endian u32 and rejects values outside the ZIP 32 account id range as `InvalidData`. +fn read_account_id(reader: &mut R) -> io::Result { + let raw_account_id = reader.read_u32::()?; + zip32::AccountId::try_from(raw_account_id).map_err(|_| { + Error::new( + ErrorKind::InvalidData, + format!("invalid account id {raw_account_id} stored in wallet file"), + ) + }) +} + fn check_saved_chain(saved_network: &str, chain_type: &ChainType) -> io::Result<()> { if saved_network == chain_type.to_string() { Ok(()) @@ -135,6 +159,16 @@ impl LightWallet { 42 } + /// Upper bound on the version word [`Self::read_recovery_info`] accepts: + /// far above any version this project will reach, low enough that random + /// or encrypted bytes cannot land in it. + pub const MAX_RECOVERABLE_VERSION: u64 = 1000; + + /// Upper bound on the birthday [`Self::read_recovery_info`] accepts: no + /// real chain reaches this height for centuries, while fabricated + /// prefixes decode to birthdays far above it. + pub const MAX_RECOVERABLE_BIRTHDAY: u32 = 100_000_000; + /// Serialize into `writer` pub fn write( &mut self, @@ -234,6 +268,26 @@ impl LightWallet { } } + /// Confirms the bytes parse as a complete wallet this build can read, by + /// running the full [`Self::read`] deserialization and discarding the + /// result; on failure the error names the byte offset reached. + pub fn validate(reader: R, chain_type: ChainType) -> io::Result<()> { + let mut counting_reader = CountingReader { + inner: reader, + offset: 0, + }; + Self::read(&mut counting_reader, chain_type).map_err(|error| { + Error::new( + error.kind(), + format!( + "wallet file failed to parse at byte {}: {error}", + counting_reader.offset + ), + ) + })?; + Ok(()) + } + fn read_v0(mut reader: R, chain_type: ChainType, version: u64) -> io::Result { let mut wallet_capability = WalletCapability::read(&mut reader, chain_type)?; let mut _blocks = Vector::read(&mut reader, |r| BlockData::read(r))?; @@ -266,12 +320,13 @@ impl LightWallet { } else { WalletOptions::read(&mut reader)? }; - let birthday = BlockHeight::from_u32( - reader - .read_u64::()? - .try_into() - .expect("should never overflow"), - ); + let stored_birthday = reader.read_u64::()?; + let birthday = BlockHeight::from_u32(stored_birthday.try_into().map_err(|_| { + Error::new( + ErrorKind::InvalidData, + format!("stored birthday {stored_birthday} exceeds the maximum block height"), + ) + })?); if version <= 22 { let _sapling_tree_verified = if version <= 12 { @@ -490,11 +545,7 @@ impl LightWallet { let unified_key_store = if version >= 35 { Vector::read(&mut reader, |r| { - Ok(( - zip32::AccountId::try_from(r.read_u32::()?) - .expect("only valid account ids are stored"), - UnifiedKeyStore::read(r, chain_type)?, - )) + Ok((read_account_id(r)?, UnifiedKeyStore::read(r, chain_type)?)) })? .into_iter() .collect::>() @@ -508,8 +559,7 @@ impl LightWallet { }; let mut unified_addresses = Vector::read(&mut reader, |r| { - let account_id = zip32::AccountId::try_from(r.read_u32::()?) - .expect("only valid account ids are stored"); + let account_id = read_account_id(r)?; let address_index = r.read_u32::()?; let receivers = ReceiverSelection::read(r, ())?; @@ -534,11 +584,18 @@ impl LightWallet { .into_iter() .collect::>(); let mut transparent_addresses = Vector::read(&mut reader, |r| { - let account_id = zip32::AccountId::try_from(r.read_u32::()?) - .expect("only valid account ids are stored"); + let account_id = read_account_id(r)?; let scope = TransparentScope::try_from(r.read_u8()?)?; - let address_index = NonHardenedChildIndex::from_index(r.read_u32::()?) - .expect("only non-hardened child indexes should be written"); + let raw_address_index = r.read_u32::()?; + let address_index = + NonHardenedChildIndex::from_index(raw_address_index).ok_or_else(|| { + Error::new( + ErrorKind::InvalidData, + format!( + "hardened transparent address index {raw_address_index} stored in wallet file" + ), + ) + })?; Ok(( TransparentAddressId::new(account_id, scope, address_index), @@ -565,7 +622,12 @@ impl LightWallet { if version < 36 { let unified_key = unified_key_store .get(&zip32::AccountId::ZERO) - .expect("account 0 must exist"); + .ok_or_else(|| { + Error::new( + ErrorKind::InvalidData, + "wallet file stores no key for account 0", + ) + })?; unified_addresses = BTreeMap::new(); if let Some(receivers) = unified_key.default_receivers() { let unified_address_id = UnifiedAddressId { @@ -644,8 +706,13 @@ impl LightWallet { let wallet_settings = if version >= 33 { let sync_config = SyncConfig::read(&mut reader)?; let min_confirmations = if version >= 38 { - NonZeroU32::try_from(reader.read_u32::()?) - .expect("only valid non-zero u32s stored") + let stored_min_confirmations = reader.read_u32::()?; + NonZeroU32::try_from(stored_min_confirmations).map_err(|_| { + Error::new( + ErrorKind::InvalidData, + "min_confirmations of zero stored in wallet file", + ) + })? } else { NonZeroU32::try_from(3).expect("hard-coded non-zero integer") }; @@ -781,6 +848,10 @@ impl LightWallet { /// Fails on legacy files (version below 32), whose seed is stored too /// deep in the file to reach without a full parse, and on view-only /// wallets, which store no seed. + /// + /// Rejects versions above [`Self::MAX_RECOVERABLE_VERSION`] and birthdays + /// above [`Self::MAX_RECOVERABLE_BIRTHDAY`], so random or encrypted bytes + /// cannot come back as a confident seed phrase. pub fn read_recovery_info(mut reader: R) -> io::Result { let version = reader.read_u64::()?; if version < 32 { @@ -789,6 +860,16 @@ impl LightWallet { format!("wallet version {version} predates the recoverable prefix layout"), )); } + if version > Self::MAX_RECOVERABLE_VERSION { + return Err(Error::new( + ErrorKind::InvalidData, + format!( + "version {version} is above {}, so this is not a wallet file \ + this project or any future revision of it could have written", + Self::MAX_RECOVERABLE_VERSION + ), + )); + } if version >= 41 { let _chain_type_index = reader.read_u8()?; } else if version == 40 { @@ -809,6 +890,16 @@ impl LightWallet { let mnemonic = ::from_entropy(seed_bytes) .map_err(|e| Error::new(ErrorKind::InvalidData, e.to_string()))?; let birthday = reader.read_u32::()?; + if birthday > Self::MAX_RECOVERABLE_BIRTHDAY { + return Err(Error::new( + ErrorKind::InvalidData, + format!( + "recovered birthday {birthday} is above block height {}, which no \ + real wallet can reach; this is not a wallet file", + Self::MAX_RECOVERABLE_BIRTHDAY + ), + )); + } let no_of_accounts = if version >= 35 { u32::try_from(CompactSize::read(&mut reader)?).map_err(|e| { Error::new( diff --git a/zingolib/src/wallet/disk/testing/tests.rs b/zingolib/src/wallet/disk/testing/tests.rs index a82facf7a9..9d96712d86 100644 --- a/zingolib/src/wallet/disk/testing/tests.rs +++ b/zingolib/src/wallet/disk/testing/tests.rs @@ -655,6 +655,323 @@ fn data_wallets_corpus_parses_or_salvages() { } } +mod validation { + use proptest::prelude::*; + + use zingo_common_components::protocol::ActivationHeights; + + use pepper_sync::keys::transparent::TransparentScope; + + use crate::config::ChainType; + use crate::wallet::{LightWallet, utils}; + + use super::current_version_wallet_bytes; + use super::{ + AbandonAbandonVersion, AbsurdAmountVersion, ChimneyBetterVersion, HospitalMuseumVersion, + HotelHumorVersion, MainnetSeedVersion, MobileShuffleVersion, NetworkSeedVersion, + RegtestSeedVersion, TestnetSeedVersion, VillageTargetVersion, + }; + + /// [`LightWallet::validate`] accepts every checked-in example wallet file. + #[test] + fn validate_accepts_every_example_wallet_fixture() { + let regtest = ChainType::Regtest(ActivationHeights::default()); + let fixtures = [ + ( + NetworkSeedVersion::Regtest(RegtestSeedVersion::HospitalMuseum( + HospitalMuseumVersion::V27, + )), + regtest, + ), + ( + NetworkSeedVersion::Regtest(RegtestSeedVersion::AbandonAbandon( + AbandonAbandonVersion::V26, + )), + regtest, + ), + ( + NetworkSeedVersion::Regtest(RegtestSeedVersion::AbsurdAmount( + AbsurdAmountVersion::OrchAndSapl, + )), + regtest, + ), + ( + NetworkSeedVersion::Regtest(RegtestSeedVersion::AbsurdAmount( + AbsurdAmountVersion::OrchOnly, + )), + regtest, + ), + ( + NetworkSeedVersion::Testnet(TestnetSeedVersion::ChimneyBetter( + ChimneyBetterVersion::V26, + )), + ChainType::Testnet, + ), + ( + NetworkSeedVersion::Testnet(TestnetSeedVersion::ChimneyBetter( + ChimneyBetterVersion::V27, + )), + ChainType::Testnet, + ), + ( + NetworkSeedVersion::Testnet(TestnetSeedVersion::ChimneyBetter( + ChimneyBetterVersion::V28, + )), + ChainType::Testnet, + ), + ( + NetworkSeedVersion::Testnet(TestnetSeedVersion::ChimneyBetter( + ChimneyBetterVersion::Latest, + )), + ChainType::Testnet, + ), + ( + NetworkSeedVersion::Testnet(TestnetSeedVersion::MobileShuffle( + MobileShuffleVersion::Gab72a38b, + )), + ChainType::Testnet, + ), + ( + NetworkSeedVersion::Testnet(TestnetSeedVersion::MobileShuffle( + MobileShuffleVersion::G93738061a, + )), + ChainType::Testnet, + ), + ( + NetworkSeedVersion::Testnet(TestnetSeedVersion::MobileShuffle( + MobileShuffleVersion::Latest, + )), + ChainType::Testnet, + ), + ( + NetworkSeedVersion::Testnet(TestnetSeedVersion::GloryGoddess), + ChainType::Testnet, + ), + ( + NetworkSeedVersion::Mainnet(MainnetSeedVersion::VillageTarget( + VillageTargetVersion::V28, + )), + ChainType::Mainnet, + ), + ( + NetworkSeedVersion::Mainnet(MainnetSeedVersion::HotelHumor( + HotelHumorVersion::Gf0aaf9347, + )), + ChainType::Mainnet, + ), + ( + NetworkSeedVersion::Mainnet(MainnetSeedVersion::HotelHumor( + HotelHumorVersion::Latest, + )), + ChainType::Mainnet, + ), + ]; + + for (fixture, chain_type) in fixtures { + let path = fixture.example_wallet_path(); + let bytes = std::fs::read(&path).expect("example wallet files are checked in"); + LightWallet::validate(bytes.as_slice(), chain_type) + .unwrap_or_else(|error| panic!("{} must validate: {error}", path.display())); + } + } + + /// [`LightWallet::validate`] accepts the output of `write` untouched and + /// rejects it truncated to every shorter length, a superset of every + /// field boundary and every position one byte past one. + #[tokio::test] + async fn validate_accepts_write_output_and_rejects_every_truncation() { + let expected = current_version_wallet_bytes().await; + + LightWallet::validate(expected.bytes.as_slice(), expected.chain_type) + .expect("the untruncated output of write must validate"); + + for length in 0..expected.bytes.len() { + assert!( + LightWallet::validate(&expected.bytes[..length], expected.chain_type).is_err(), + "the file truncated to {length} of {} bytes must be rejected", + expected.bytes.len() + ); + } + } + + /// [`LightWallet::read_recovery_info`] must + /// still read the prefix of a file stamped with a future version. + #[tokio::test] + async fn recovery_info_salvages_versions_above_the_current_write_version() { + let expected = current_version_wallet_bytes().await; + + for future_version in [ + LightWallet::serialized_version() + 1, + LightWallet::serialized_version() + 2, + ] { + let mut bytes = expected.bytes.clone(); + bytes[..8].copy_from_slice(&future_version.to_le_bytes()); + + let salvaged = + LightWallet::read_recovery_info(bytes.as_slice()).unwrap_or_else(|error| { + panic!("version {future_version} must remain salvageable: {error}") + }); + assert_eq!(salvaged, expected.recovery_info); + } + } + + /// Regression case: invalid seed phrase bytes must not recover to a seed phrase. + #[test] + fn recovery_info_rejects_forty_seven_space_bytes() { + let error = LightWallet::read_recovery_info(vec![0x20; 47].as_slice()) + .expect_err("uniform filler bytes must not decode to a seed phrase"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!( + error + .to_string() + .contains(&0x2020202020202020u64.to_string()), + "the error must name the rejected version: {error}" + ); + } + + /// A well-formed recovery prefix is accepted, and the same prefix with an invalid birthday is rejected. + #[test] + fn recovery_info_rejects_an_implausible_birthday() { + let prefix = |birthday: u32| { + let mut bytes = LightWallet::serialized_version().to_le_bytes().to_vec(); + bytes.push(0); + bytes.push(32); + bytes.extend_from_slice(&[0x55; 32]); + bytes.extend_from_slice(&birthday.to_le_bytes()); + bytes.push(1); + bytes + }; + + let info = LightWallet::read_recovery_info(prefix(2_000_000).as_slice()).unwrap(); + assert_eq!(info.birthday, 2_000_000); + + let error = LightWallet::read_recovery_info(prefix(600_000_000).as_slice()) + .expect_err("a birthday in the hundreds of millions must be rejected"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + } + + /// The lowest u32 with the ZIP 32 hardened-derivation bit set, valid neither as an account id nor as a non-hardened child index. + const FIRST_HARDENED_INDEX: u32 = 1 << 31; + + /// The chain tag byte the current wallet version stores for mainnet. + const MAINNET_CHAIN_TAG: u8 = 0; + + /// The filler byte the crafted fixtures use as seed entropy. + const SEED_ENTROPY_FILL: u8 = 0x55; + + /// The oldest wallet version that stores the unified key store as a vector of account entries. + const FIRST_VECTORED_KEY_STORE_VERSION: u64 = 35; + + /// Builds a current-version wallet prefix through the birthday field, ready for a crafted tail. + fn current_version_prefix_through_birthday() -> Vec { + let mut bytes = LightWallet::serialized_version().to_le_bytes().to_vec(); + bytes.push(MAINNET_CHAIN_TAG); + let seed_entropy = [SEED_ENTROPY_FILL; 32]; + bytes.push(seed_entropy.len() as u8); + bytes.extend_from_slice(&seed_entropy); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes + } + + /// [`LightWallet::validate`] returns an error, rather than panicking, on a key store entry whose account id has the hardened bit set. + #[test] + fn validate_rejects_a_hardened_key_store_account_id() { + let mut bytes = current_version_prefix_through_birthday(); + bytes.push(1); + bytes.extend_from_slice(&FIRST_HARDENED_INDEX.to_le_bytes()); + + let error = LightWallet::validate(bytes.as_slice(), ChainType::Mainnet) + .expect_err("a hardened account id must be rejected, not panicked on"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!( + error + .to_string() + .contains(&FIRST_HARDENED_INDEX.to_string()), + "the error must name the rejected account id: {error}" + ); + } + + /// [`LightWallet::validate`] returns an error, rather than panicking, on a unified address whose account id has the hardened bit set. + #[test] + fn validate_rejects_a_hardened_unified_address_account_id() { + let mut bytes = current_version_prefix_through_birthday(); + bytes.push(0); + bytes.push(1); + bytes.extend_from_slice(&FIRST_HARDENED_INDEX.to_le_bytes()); + + let error = LightWallet::validate(bytes.as_slice(), ChainType::Mainnet) + .expect_err("a hardened account id must be rejected, not panicked on"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + } + + /// [`LightWallet::validate`] returns an error, rather than panicking, on a transparent address whose account id has the hardened bit set. + #[test] + fn validate_rejects_a_hardened_transparent_address_account_id() { + let mut bytes = current_version_prefix_through_birthday(); + bytes.push(0); + bytes.push(0); + bytes.push(1); + bytes.extend_from_slice(&FIRST_HARDENED_INDEX.to_le_bytes()); + + let error = LightWallet::validate(bytes.as_slice(), ChainType::Mainnet) + .expect_err("a hardened account id must be rejected, not panicked on"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + } + + /// [`LightWallet::validate`] returns an error, rather than panicking, on a transparent address index with the hardened bit set. + #[test] + fn validate_rejects_a_hardened_transparent_address_index() { + let mut bytes = current_version_prefix_through_birthday(); + bytes.push(0); + bytes.push(0); + bytes.push(1); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.push(TransparentScope::External as u8); + bytes.extend_from_slice(&FIRST_HARDENED_INDEX.to_le_bytes()); + + let error = LightWallet::validate(bytes.as_slice(), ChainType::Mainnet) + .expect_err("a hardened address index must be rejected, not panicked on"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + } + + /// [`LightWallet::validate`] returns an error, rather than panicking, on a version 35 wallet whose key store vector holds no account 0. + #[test] + fn validate_rejects_an_account_zero_free_version_thirty_five_wallet() { + let mut bytes = FIRST_VECTORED_KEY_STORE_VERSION.to_le_bytes().to_vec(); + utils::write_string(&mut bytes, &"main".to_string()) + .expect("writing to a vector cannot fail"); + bytes.push(0); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.push(0); + bytes.push(0); + bytes.push(0); + + let error = LightWallet::validate(bytes.as_slice(), ChainType::Mainnet) + .expect_err("a wallet with no account 0 key must be rejected, not panicked on"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + } + + proptest! { + /// Any byte string whose version word falls outside the accepted + /// range is rejected by both functions. + #[test] + fn out_of_range_version_words_are_rejected( + bytes in proptest::collection::vec(any::(), 8..512) + ) { + let version = u64::from_le_bytes(bytes[..8].try_into().unwrap()); + + if version > 43 { + prop_assert!( + LightWallet::validate(bytes.as_slice(), ChainType::Mainnet).is_err() + ); + } + if !(32..=LightWallet::MAX_RECOVERABLE_VERSION).contains(&version) { + prop_assert!(LightWallet::read_recovery_info(bytes.as_slice()).is_err()); + } + } + } +} + mod version_forty { use bip0039::Mnemonic;