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/src/storage.rs b/crates/pwv-core/src/storage.rs index 9f9d86c..069b3b8 100644 --- a/crates/pwv-core/src/storage.rs +++ b/crates/pwv-core/src/storage.rs @@ -208,30 +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_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 new file mode 100644 index 0000000..805e0ea --- /dev/null +++ b/crates/pwv-core/tests/roundtrip.rs @@ -0,0 +1,117 @@ +//! 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 std::fs; + +use proptest::prelude::*; +use pwv_core::header::Header; +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::>() + ); + } +} + +/// 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); +} diff --git a/crates/pwv-core/tests/tamper.rs b/crates/pwv-core/tests/tamper.rs new file mode 100644 index 0000000..2f39303 --- /dev/null +++ b/crates/pwv-core/tests/tamper.rs @@ -0,0 +1,155 @@ +//! 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")] +// `allow-unwrap-in-tests` does not reach helper functions in integration tests. +#![allow(clippy::unwrap_used)] + +use std::fs; + +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"; + +fn password(s: &str) -> SecretString { + SecretString::from(s.to_owned()) +} + +/// 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() +} + +/// 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:?}"); + } +} + +/// 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))); +} + +/// 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) + )); +} + +/// 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); +}