Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions zingolib/src/wallet/disk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@ fn chain_name_from_stored(stored: &str) -> io::Result<&'static str> {
}
}

struct CountingReader<R> {
inner: R,
offset: u64,
}

impl<R: Read> Read for CountingReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let bytes_read = self.inner.read(buf)?;
self.offset += bytes_read as u64;
Ok(bytes_read)
}
}

fn check_saved_chain(saved_network: &str, chain_type: &ChainType) -> io::Result<()> {
if saved_network == chain_type.to_string() {
Ok(())
Expand Down Expand Up @@ -135,6 +148,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<W: Write>(
&mut self,
Expand Down Expand Up @@ -234,6 +257,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<R: Read>(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<R: Read>(mut reader: R, chain_type: ChainType, version: u64) -> io::Result<Self> {
let mut wallet_capability = WalletCapability::read(&mut reader, chain_type)?;
let mut _blocks = Vector::read(&mut reader, |r| BlockData::read(r))?;
Expand Down Expand Up @@ -781,6 +824,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<R: Read>(mut reader: R) -> io::Result<RecoveryInfo> {
let version = reader.read_u64::<LittleEndian>()?;
if version < 32 {
Expand All @@ -789,6 +836,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 {
Expand All @@ -809,6 +866,16 @@ impl LightWallet {
let mnemonic = <Mnemonic>::from_entropy(seed_bytes)
.map_err(|e| Error::new(ErrorKind::InvalidData, e.to_string()))?;
let birthday = reader.read_u32::<LittleEndian>()?;
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(
Expand Down
214 changes: 214 additions & 0 deletions zingolib/src/wallet/disk/testing/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,220 @@ fn data_wallets_corpus_parses_or_salvages() {
}
}

mod validation {
use proptest::prelude::*;

use zingo_common_components::protocol::ActivationHeights;

use crate::config::ChainType;
use crate::wallet::LightWallet;

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);
}

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::<u8>(), 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;

Expand Down
Loading