From 7f8b6673de716027336b7acd47f31c4702a3311d Mon Sep 17 00:00:00 2001 From: ericliswan Date: Wed, 28 Oct 2026 01:21:04 +1000 Subject: [PATCH 1/7] test(core): flipping every byte of a small vault is rejected Drives storage::unlock on a real file rather than crypto::open, so the test covers the whole path a user hits. It also pins which error each header region produces. Refs #20 --- crates/pwv-core/tests/tamper.rs | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 crates/pwv-core/tests/tamper.rs diff --git a/crates/pwv-core/tests/tamper.rs b/crates/pwv-core/tests/tamper.rs new file mode 100644 index 0000000..d318ee4 --- /dev/null +++ b/crates/pwv-core/tests/tamper.rs @@ -0,0 +1,55 @@ +//! Tampering, truncation and downgrade tests, run against real vault files through the +//! public `storage` API. Each test's doc comment names the README claim it proves. + +// Every test unlocks vault files, which needs the fast KDF profile. +#![cfg(feature = "insecure-test-kdf")] +// `allow-unwrap-in-tests` does not reach helper functions in integration tests. +#![allow(clippy::unwrap_used)] + +use std::fs; + +use pwv_core::{Error, storage}; +use secrecy::SecretString; + +const PASSWORD: &str = "correct horse battery staple"; + +fn password(s: &str) -> SecretString { + SecretString::from(s.to_owned()) +} + +/// The bytes of a freshly created vault with no entries. +fn new_vault_bytes() -> Vec { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.pwv"); + storage::create(&path, &password(PASSWORD)).unwrap(); + fs::read(&path).unwrap() +} + +/// Writes `bytes` as a vault file and unlocks it with the correct password. +fn unlock_bytes(bytes: &[u8]) -> Result<(), Error> { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.pwv"); + fs::write(&path, bytes).unwrap(); + storage::unlock(&path, &password(PASSWORD)).map(drop) +} + +/// README claim: changing any byte of the vault file makes unlock fail. +/// +/// Header bytes fail parsing or validation where they can; everything else, including the +/// salt, the nonce and in-range KDF parameters, fails authentication. +#[test] +fn flipping_any_byte_is_rejected() { + let file = new_vault_bytes(); + for i in 0..file.len() { + let mut tampered = file.clone(); + tampered[i] ^= 0xFF; + let err = unlock_bytes(&tampered).expect_err("tampered vault unlocked"); + let expected = match i { + 0..4 => matches!(err, Error::Format), + 4 | 5 | 34 => matches!(err, Error::UnsupportedVersion), + 6..18 => matches!(err, Error::ParamsOutOfRange | Error::Decrypt), + _ => matches!(err, Error::Decrypt), + }; + assert!(expected, "byte {i}: unexpected {err:?}"); + } +} From c332e084873c127447122fb25f1070cce4416593 Mon Sep 17 00:00:00 2001 From: ericliswan Date: Wed, 28 Oct 2026 01:21:21 +1000 Subject: [PATCH 2/7] test(core): truncated, empty and extended files are rejected Files shorter than header + tag must fail as Format before any key is derived. Longer ones that are cut or padded must fail authentication. Refs #20 --- crates/pwv-core/tests/tamper.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/pwv-core/tests/tamper.rs b/crates/pwv-core/tests/tamper.rs index d318ee4..8bd7cb2 100644 --- a/crates/pwv-core/tests/tamper.rs +++ b/crates/pwv-core/tests/tamper.rs @@ -8,6 +8,7 @@ use std::fs; +use pwv_core::header::{HEADER_LEN, TAG_LEN}; use pwv_core::{Error, storage}; use secrecy::SecretString; @@ -53,3 +54,24 @@ fn flipping_any_byte_is_rejected() { assert!(expected, "byte {i}: unexpected {err:?}"); } } + +/// README claim: a truncated or extended vault file is refused, never partly read. +/// +/// Covers every length from empty up to one byte short, plus one extra byte at the end. +#[test] +fn truncated_empty_and_extended_files_are_rejected() { + let file = new_vault_bytes(); + for len in 0..file.len() { + let err = unlock_bytes(&file[..len]).expect_err("truncated vault unlocked"); + let expected = if len < HEADER_LEN + TAG_LEN { + matches!(err, Error::Format) + } else { + matches!(err, Error::Decrypt) + }; + assert!(expected, "length {len}: unexpected {err:?}"); + } + + let mut extended = file; + extended.push(0); + assert!(matches!(unlock_bytes(&extended), Err(Error::Decrypt))); +} From e1e82d55921c5f5346819ce4520a82127b00da6b Mon Sep 17 00:00:00 2001 From: ericliswan Date: Wed, 28 Oct 2026 01:22:17 +1000 Subject: [PATCH 3/7] test(core): header downgrade of memory cost is rejected Builds a vault above the floor, then rewrites m_cost in the header. The original key must still fail to open it. That assertion is the one that breaks if the header ever drops out of the associated data. Salt, parameters and nonce all change the key or keystream anyway. Refs #20 --- crates/pwv-core/tests/tamper.rs | 46 +++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/pwv-core/tests/tamper.rs b/crates/pwv-core/tests/tamper.rs index 8bd7cb2..f0437b1 100644 --- a/crates/pwv-core/tests/tamper.rs +++ b/crates/pwv-core/tests/tamper.rs @@ -8,8 +8,10 @@ use std::fs; -use pwv_core::header::{HEADER_LEN, TAG_LEN}; -use pwv_core::{Error, storage}; +use pwv_core::header::{HEADER_LEN, Header, NONCE_LEN, TAG_LEN}; +use pwv_core::kdf::{self, KdfParams}; +use pwv_core::model::Vault; +use pwv_core::{Error, crypto, storage}; use secrecy::SecretString; const PASSWORD: &str = "correct horse battery staple"; @@ -75,3 +77,43 @@ fn truncated_empty_and_extended_files_are_rejected() { extended.push(0); assert!(matches!(unlock_bytes(&extended), Err(Error::Decrypt))); } + +/// README claim: lowering the KDF cost in the header does not weaken the vault. Below the +/// floor it is refused as out of range; within range it fails authentication, because the +/// parameters are part of the associated data. +#[test] +fn memory_cost_downgrade_is_rejected() { + let floor = KdfParams::FLOOR; + let original = KdfParams { + m_cost_kib: floor.m_cost_kib * 4, + ..floor + }; + let header = Header { + kdf: original, + salt: kdf::random_salt(), + nonce: [0; NONCE_LEN], + }; + let key = kdf::derive_key(&password(PASSWORD), &header.salt, &original).unwrap(); + let file = crypto::seal(&key, &header, &Vault::default().to_json().unwrap()); + unlock_bytes(&file).unwrap(); + + let with_m_cost = |m: u32| { + let mut f = file.clone(); + f[6..10].copy_from_slice(&m.to_le_bytes()); + f + }; + + // Even the original key cannot open the file once the header says something else. + let downgraded = with_m_cost(floor.m_cost_kib); + assert!(matches!( + crypto::open(&key, &downgraded), + Err(Error::Decrypt) + )); + assert!(matches!(unlock_bytes(&downgraded), Err(Error::Decrypt))); + + let below_floor = with_m_cost(floor.m_cost_kib - 1); + assert!(matches!( + unlock_bytes(&below_floor), + Err(Error::ParamsOutOfRange) + )); +} From b6349087c79b881fc0d33efbe89980b96204108f Mon Sep 17 00:00:00 2001 From: ericliswan Date: Wed, 28 Oct 2026 01:23:27 +1000 Subject: [PATCH 4/7] test(core): property test that random vaults round-trip Random entries go through create, save and unlock on disk. Fields hold arbitrary Unicode, including quotes, backslashes and control characters. Timestamps span the full RFC 3339 range at nanosecond precision. The test catches anything the JSON or file layers fail to preserve exactly. Adds proptest as a dev-dependency. Refs #20 --- Cargo.lock | 221 +++++++++++++++++++++++++++++ crates/pwv-core/Cargo.toml | 1 + crates/pwv-core/tests/roundtrip.rs | 96 +++++++++++++ 3 files changed, 318 insertions(+) create mode 100644 crates/pwv-core/tests/roundtrip.rs diff --git a/Cargo.lock b/Cargo.lock index b441545..ca434ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,12 +24,37 @@ dependencies = [ "password-hash", ] +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64ct" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "2.13.2" @@ -54,18 +79,54 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.2" @@ -107,6 +168,12 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.3.1" @@ -177,6 +244,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + [[package]] name = "errno" version = "0.3.14" @@ -193,6 +266,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "futures-core" version = "0.3.34" @@ -229,6 +308,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hex-literal" version = "1.1.0" @@ -244,6 +329,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "inout" version = "0.2.5" @@ -303,6 +398,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -358,6 +462,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -367,6 +480,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "chacha20", + "core_detect", + "num-traits", + "rand", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "pwv-cli" version = "0.0.0" @@ -382,6 +515,7 @@ dependencies = [ "chacha20poly1305", "directories", "hex-literal", + "proptest", "rand", "secrecy", "serde", @@ -393,6 +527,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.47" @@ -425,6 +565,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_xorshift" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" +dependencies = [ + "rand_core", +] + [[package]] name = "redox_users" version = "0.5.3" @@ -435,6 +584,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rustix" version = "1.1.6" @@ -454,6 +609,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -586,12 +753,48 @@ dependencies = [ "time-core", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.27" @@ -620,6 +823,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -680,6 +892,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "zeroize" version = "1.9.1" diff --git a/crates/pwv-core/Cargo.toml b/crates/pwv-core/Cargo.toml index cfc8b5f..c9393c2 100644 --- a/crates/pwv-core/Cargo.toml +++ b/crates/pwv-core/Cargo.toml @@ -30,3 +30,4 @@ insecure-test-kdf = [] [dev-dependencies] hex-literal = "1.1.0" +proptest = "1.12.0" diff --git a/crates/pwv-core/tests/roundtrip.rs b/crates/pwv-core/tests/roundtrip.rs new file mode 100644 index 0000000..fc5eee4 --- /dev/null +++ b/crates/pwv-core/tests/roundtrip.rs @@ -0,0 +1,96 @@ +//! Round-trip tests: a vault saved to disk unlocks to exactly what was saved. Each test's doc +//! comment names the README claim it proves. + +// Every test unlocks vault files, which needs the fast KDF profile. +#![cfg(feature = "insecure-test-kdf")] +// `allow-unwrap-in-tests` does not reach helper functions in integration tests. +#![allow(clippy::unwrap_used)] + +use proptest::prelude::*; +use pwv_core::model::{Entry, SecretText}; +use pwv_core::storage; +use secrecy::SecretString; +use time::OffsetDateTime; +use uuid::Uuid; + +const PASSWORD: &str = "correct horse battery staple"; + +fn password(s: &str) -> SecretString { + SecretString::from(s.to_owned()) +} + +/// Any text, including newlines, quotes, backslashes, control characters and non-ASCII. +const TEXT: &str = "(?s).{0,40}"; + +/// Any instant RFC 3339 can write (years 0000 to 9999), to the nanosecond. +fn timestamp() -> impl Strategy { + const NANOS: i128 = 1_000_000_000; + (-62_167_219_200 * NANOS..253_402_300_800 * NANOS) + .prop_map(|ns| OffsetDateTime::from_unix_timestamp_nanos(ns).unwrap()) +} + +fn entry() -> impl Strategy { + let fields = ( + any::(), + TEXT, + TEXT, + TEXT, + TEXT, + timestamp(), + timestamp(), + ); + fields.prop_map( + |(id, site, username, password, notes, created_at, updated_at)| Entry { + id: Uuid::from_u128(id), + site, + username, + password: SecretText::from(password), + notes: SecretText::from(notes), + created_at, + updated_at, + }, + ) +} + +type Fields<'a> = ( + Uuid, + &'a str, + &'a str, + &'a str, + &'a str, + OffsetDateTime, + OffsetDateTime, +); + +fn fields(e: &Entry) -> Fields<'_> { + ( + e.id, + &e.site, + &e.username, + e.password.expose(), + e.notes.expose(), + e.created_at, + e.updated_at, + ) +} + +proptest! { + // Each case writes and unlocks real files, so keep the count modest. + #![proptest_config(ProptestConfig::with_cases(64))] + + /// README claim: every field you store comes back exactly as entered. + #[test] + fn random_vaults_round_trip(entries in prop::collection::vec(entry(), 0..8)) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.pwv"); + let mut unlocked = storage::create(&path, &password(PASSWORD)).unwrap(); + unlocked.vault.entries = entries.clone(); + unlocked.save().unwrap(); + + let back = storage::unlock(&path, &password(PASSWORD)).unwrap().vault.entries; + prop_assert_eq!( + back.iter().map(fields).collect::>(), + entries.iter().map(fields).collect::>() + ); + } +} From 4853e04b118fde5e92c61efe087f13f94df90e97 Mon Sep 17 00:00:00 2001 From: ericliswan Date: Wed, 28 Oct 2026 01:23:52 +1000 Subject: [PATCH 5/7] test(core): two saves produce different nonces and ciphertexts Saves the same plaintext twice and checks that the salt is kept while the nonce and ciphertext both change. This supersedes the storage unit test that checked only the nonce, so that test is removed. Refs #20 --- crates/pwv-core/src/storage.rs | 16 ---------------- crates/pwv-core/tests/roundtrip.rs | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/crates/pwv-core/src/storage.rs b/crates/pwv-core/src/storage.rs index 9f9d86c..895b95b 100644 --- a/crates/pwv-core/src/storage.rs +++ b/crates/pwv-core/src/storage.rs @@ -216,22 +216,6 @@ mod file_tests { assert!(matches!(err, Error::Decrypt)); } - #[test] - fn save_keeps_salt_and_changes_nonce() { - let dir = tempfile::tempdir().unwrap(); - let path = new_vault(&dir); - let before = fs::read(&path).unwrap(); - unlock(&path, &password("correct horse")) - .unwrap() - .save() - .unwrap(); - let after = fs::read(&path).unwrap(); - let (h1, _) = Header::parse(&before).unwrap(); - let (h2, _) = Header::parse(&after).unwrap(); - assert_eq!(h1.salt, h2.salt); - assert_ne!(h1.nonce, h2.nonce); - } - #[test] fn save_keeps_previous_file_as_bak() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/pwv-core/tests/roundtrip.rs b/crates/pwv-core/tests/roundtrip.rs index fc5eee4..805e0ea 100644 --- a/crates/pwv-core/tests/roundtrip.rs +++ b/crates/pwv-core/tests/roundtrip.rs @@ -6,7 +6,10 @@ // `allow-unwrap-in-tests` does not reach helper functions in integration tests. #![allow(clippy::unwrap_used)] +use std::fs; + use proptest::prelude::*; +use pwv_core::header::Header; use pwv_core::model::{Entry, SecretText}; use pwv_core::storage; use secrecy::SecretString; @@ -94,3 +97,21 @@ proptest! { ); } } + +/// README claim: every save uses a fresh random nonce and keeps the salt, so saving the same +/// vault twice never produces the same ciphertext. +#[test] +fn two_saves_produce_different_nonces_and_ciphertexts() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.pwv"); + let unlocked = storage::create(&path, &password(PASSWORD)).unwrap(); + let first = fs::read(&path).unwrap(); + unlocked.save().unwrap(); + let second = fs::read(&path).unwrap(); + + let (h1, c1) = Header::parse(&first).unwrap(); + let (h2, c2) = Header::parse(&second).unwrap(); + assert_eq!(h1.salt, h2.salt); + assert_ne!(h1.nonce, h2.nonce); + assert_ne!(c1, c2); +} From b111d0232fa70a9b6895b120fedb1edf6926f7ca Mon Sep 17 00:00:00 2001 From: ericliswan Date: Wed, 28 Oct 2026 01:24:29 +1000 Subject: [PATCH 6/7] test(core): wrong password fails and leaves file unchanged Snapshots every file in the vault directory, names and bytes, before and after a failed unlock. A rewritten vault or .bak fails the test, and so does a stray temp file. The test also pins the exact error message, so a wrong password and a corrupted file stay indistinguishable. This supersedes the storage unit test that checked only the error variant. Refs #20 --- crates/pwv-core/src/storage.rs | 8 ------- crates/pwv-core/tests/tamper.rs | 38 +++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/crates/pwv-core/src/storage.rs b/crates/pwv-core/src/storage.rs index 895b95b..069b3b8 100644 --- a/crates/pwv-core/src/storage.rs +++ b/crates/pwv-core/src/storage.rs @@ -208,14 +208,6 @@ mod file_tests { assert_eq!(v.vault.find("example.com")[0].password.expose(), "hunter2"); } - #[test] - fn wrong_password_fails_with_decrypt() { - let dir = tempfile::tempdir().unwrap(); - let path = new_vault(&dir); - let err = unlock(&path, &password("wrong")).err().unwrap(); - assert!(matches!(err, Error::Decrypt)); - } - #[test] fn save_keeps_previous_file_as_bak() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/pwv-core/tests/tamper.rs b/crates/pwv-core/tests/tamper.rs index f0437b1..a2aaaf7 100644 --- a/crates/pwv-core/tests/tamper.rs +++ b/crates/pwv-core/tests/tamper.rs @@ -1,5 +1,6 @@ -//! Tampering, truncation and downgrade tests, run against real vault files through the -//! public `storage` API. Each test's doc comment names the README claim it proves. +//! Rejection tests: wrong password, tampering, truncation and downgrade, run against real +//! vault files through the public `storage` API. Each test's doc comment names the README +//! claim it proves. // Every test unlocks vault files, which needs the fast KDF profile. #![cfg(feature = "insecure-test-kdf")] @@ -117,3 +118,36 @@ fn memory_cost_downgrade_is_rejected() { Err(Error::ParamsOutOfRange) )); } + +/// README claim: a wrong password gets the same error as a corrupted file and changes +/// nothing on disk. +#[test] +fn wrong_password_fails_and_leaves_files_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.pwv"); + // The second save creates the `.bak`. + storage::create(&path, &password(PASSWORD)) + .unwrap() + .save() + .unwrap(); + let snapshot = || { + let mut files: Vec<_> = fs::read_dir(dir.path()) + .unwrap() + .map(|e| { + let e = e.unwrap(); + (e.file_name(), fs::read(e.path()).unwrap()) + }) + .collect(); + files.sort(); + files + }; + let before = snapshot(); + assert_eq!(before.len(), 2, "expected the vault and its .bak"); + + let err = storage::unlock(&path, &password("wrong password")) + .map(drop) + .unwrap_err(); + assert!(matches!(err, Error::Decrypt)); + assert_eq!(err.to_string(), "wrong password or corrupted vault"); + assert_eq!(snapshot(), before); +} From 087515d02b5a01b7717e20767a30117b339ced13 Mon Sep 17 00:00:00 2001 From: ericliswan Date: Wed, 28 Oct 2026 01:27:16 +1000 Subject: [PATCH 7/7] test(core): check the untampered vault unlocks before tampering Without this baseline, the flip and truncation tests would pass vacuously if unlock always failed. Refs #20 --- crates/pwv-core/tests/tamper.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/pwv-core/tests/tamper.rs b/crates/pwv-core/tests/tamper.rs index a2aaaf7..2f39303 100644 --- a/crates/pwv-core/tests/tamper.rs +++ b/crates/pwv-core/tests/tamper.rs @@ -21,11 +21,13 @@ fn password(s: &str) -> SecretString { SecretString::from(s.to_owned()) } -/// The bytes of a freshly created vault with no entries. +/// The bytes of a freshly created vault with no entries, checked to unlock as-is so the +/// rejection tests cannot pass just because unlocking always fails. fn new_vault_bytes() -> Vec { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("vault.pwv"); storage::create(&path, &password(PASSWORD)).unwrap(); + storage::unlock(&path, &password(PASSWORD)).unwrap(); fs::read(&path).unwrap() }