From fe65e1f899bf2acee9542c93c3645e1879478ee2 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sat, 2 May 2026 18:26:44 +0200 Subject: [PATCH] feat!: argon2id passphrases, secret hardening, atomic output, manual STREAM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit lands four follow-up items that were explicitly deferred in TODO.md after the prior file-format change, plus a CLI/units cleanup that fell out of reviewing them: 1. Manual STREAM nonce construction (drops `stream` cargo feature). 2. Atomic file output (`.tmp` + rename, with cleanup on failure). 3. Argon2id KDF + passphrase prompt + matching CLI flags. 4. Hardened secret handling: zeroize-on-drop, mlock'd buffers, custom cross-platform tty reader (replaces `rpassword`). Why --- The prior version had three concrete weaknesses that were fine for "early development" but unacceptable past that point: * `--raw-key` was the only way to supply a key, exposing it in `/proc/$pid/cmdline`. There was no passphrase mode at all. * Crashes/aborts during encrypt could leave a half-written output file in place of (or replacing) the user's target. * Key material wasn't zeroed and could end up in swap or coredumps. rpassword's reallocating String buffers also leaked stale heap copies of typed passphrases that no `Zeroizing` wrapper could reach after the fact. (1) Manual STREAM nonces ------------------------ Replaces `aead::stream::EncryptorBE32` / `DecryptorBE32` with explicit `make_nonce(prefix, counter, last)` and direct `XChaCha20Poly1305::{encrypt,decrypt}_in_place` calls. The wire format is unchanged (XChaCha20Poly1305 STREAM-BE32 = 19-byte prefix || 4-byte big-endian counter || 1-byte last-block flag), so files written by the previous version still decrypt. Counter overflow is now an explicit `Format` error rather than a panic in the upstream stream wrapper. This removes the `stream` cargo feature from `chacha20poly1305` and prepares the encrypt path for parallelism: with explicit nonces we can hand chunks to a worker pool keyed by counter without the stream wrapper's stateful API getting in the way. (2) Atomic file output ---------------------- New `utils::OutSink` writes to `.tmp`, calls `sync_all()` on `commit()`, and renames into place. If dropped without commit (panic, crypto/IO error, ctrl-C), the temp file is unlinked so the existing target is untouched. Stdout output is unaffected (no temp dance). A new integration test (`atomic_output_no_stale_tmp_on_failure`) verifies that a failed decrypt leaves neither the final output nor the temp file behind. (3) Argon2id + passphrase ------------------------- New `KdfParams::Argon2id { salt, m_cost, t_cost, p_cost }` variant encoded into the header (and authenticated as AAD), so tampering with KDF params fails authentication on every chunk. CLI surface (BREAKING): * `--raw-key` is now optional; one of `--raw-key`, `--passphrase`, `--passphrase-env ` is required. * `--passphrase` prompts on the controlling terminal with echo off, and asks for confirmation when encrypting. * `--passphrase-env ` reads from a named env var; intended for non-interactive use (scripts, tests). The env-table copy is a known leak for that path. * `--argon-memory ` (default 1024 = 1 GiB), `--argon-passes` (default 2), `--argon-parallelism` (default 4). Names follow argon2 RFC 9106 terminology; memory is MiB rather than KiB to match how humans actually think about RAM. Defaults follow the "Balanced" preset for 2026-era hardware (~1.5–4 s on a laptop). The argon2 crate wants KiB internally, so the CLI value is multiplied by 1024 with overflow-check. (4) Secret hardening -------------------- New `secrets` module provides: * `SecretBytes32`: heap-allocated 32-byte buffer wrapped in `Zeroizing<[u8; 32]>` and mlock'd via the `region` crate. Field order ensures the lock guard drops *before* the buffer is freed (otherwise munlock would target freed memory). * `SecretVec`: fixed-capacity, mlock'd, zeroize-on-drop byte buffer. `push()` rejects writes past the reserved capacity so the underlying allocation never reallocates and moves — which would invalidate the lock and leave a stale unzeroed copy on the heap. * `read_passphrase_tty()`: direct tty reader. On Unix, opens `/dev/tty`, clears `ECHO` via `tcgetattr`/`tcsetattr` with an RAII guard that restores termios on drop. On Windows, opens `CONIN$`/`CONOUT$` and clears `ENABLE_ECHO_INPUT` via `Get/SetConsoleMode`. Reads byte-by-byte into a pre-reserved `SecretVec` (1024 bytes), so neither the Rust side nor the libc side reallocates during read. This replaces `rpassword`, which returned a `String` that grew by reallocation and left unzeroed copies of typed passphrases on the heap. `PartialEq` on `SecretVec` is constant-time-ish (length check + xor-or accumulate) so the confirmation comparison doesn't early-out on the first differing byte. `disable_core_dumps()` calls `setrlimit(CORE, 0)` on Unix; on Windows it's a no-op (WER/minidump suppression is a per-machine policy and intentionally not done here). `Cli`'s secret-bearing fields are moved out into local bindings at the top of `run()` and the `Cli` is explicitly dropped, so they don't sit in the parsed struct for the rest of the function. `Cli.raw_key` is `Option>` so the field we own zeroes itself on drop. Clap's own intermediate copies during parsing are an accepted leak. Threat model — what is and isn't covered ----------------------------------------- Covered (best-effort): * Secrets in coredumps → rlimit on Unix. * Secrets paged to swap or hibernation → mlock on the AEAD key and passphrase buffer. * Half-written ciphertext on crash → atomic rename. * Stale heap copies of typed passphrase → custom tty reader, pre-reserved buffer. * Stale stack/heap copies of the AEAD key or passphrase post-process-exit → zeroize on drop. Not covered (and not pretending to be): * Live-process attackers with ptrace or `/proc/$pid/mem` access. * The kernel's tty/line buffer. * Clap's transient String allocations during arg parsing. * The `environ` table copy of an env-var passphrase. * Swap on systems without functioning mlock or with `RLIMIT_MEMLOCK = 0`. mlock is small (32 bytes + 1024 bytes — two pages at most on any of the three target OSes), so it fits well under the typical unprivileged `RLIMIT_MEMLOCK` of 64 KiB. Portability ----------- The whole binary targets Linux, macOS, and Windows 11 with the same security properties where the OS supports them: * `region` crate provides cross-platform mlock/munlock. * `libc::tcgetattr`/`tcsetattr` covers Linux + macOS. * `windows-sys` covers Console API. * `rlimit` is gated to `cfg(unix)`. The Windows tty path compiles in my head but is unverified on this machine — there is no `x86_64-pc-windows-*` target installed and no Windows runner. Treat that path as "best-effort, needs CI on Windows" until exercised. Files written by the previous v0.10 (Raw KDF, BE32 STREAM) are still readable: the wire format is unchanged for that path. Test plan --------- Existing 17 integration tests pass unchanged. Two new tests: * `roundtrip_passphrase_argon2id` — encrypts and decrypts via `--passphrase-env` with cheap argon2 params (8 MiB / 1 pass) so the test stays fast; also verifies that a wrong passphrase fails. * `atomic_output_no_stale_tmp_on_failure` — wrong-key decrypt leaves neither the final file nor the `.tmp` in place. Manual sanity (not automated): run with `--passphrase` on a terminal and confirm echo is off and confirmation works. Follow-ups (still in TODO.md) ----------------------------- * Multi-threaded encrypt pipeline (now feasible — manual nonces). * Length-committed mode + random-access decrypt fast path. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 208 ++++++++++++++++++++++++++++++++++- Cargo.toml | 17 ++- TODO.md | 8 +- src/crypto.rs | 95 +++++++++++++--- src/error.rs | 8 ++ src/header.rs | 45 +++++++- src/main.rs | 150 +++++++++++++++++++++++-- src/secrets.rs | 269 +++++++++++++++++++++++++++++++++++++++++++++ src/utils.rs | 103 ++++++++++++++++- tests/roundtrip.rs | 91 +++++++++++++++ 10 files changed, 948 insertions(+), 46 deletions(-) create mode 100644 src/secrets.rs diff --git a/Cargo.lock b/Cargo.lock index 06dd106..3980a56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,7 +48,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -59,7 +59,19 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", ] [[package]] @@ -77,12 +89,42 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -207,6 +249,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "errno" version = "0.3.14" @@ -214,7 +267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -227,11 +280,17 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" name = "fcry" version = "0.10.0" dependencies = [ + "argon2", "assert_cmd", "chacha20poly1305", "clap", "getrandom 0.3.4", + "libc", + "region", + "rlimit", "tempfile", + "windows-sys 0.59.0", + "zeroize", ] [[package]] @@ -300,6 +359,15 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.8.0" @@ -324,6 +392,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -401,17 +480,38 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +[[package]] +name = "region" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7" +dependencies = [ + "bitflags 1.3.2", + "libc", + "mach2", + "windows-sys 0.52.0", +] + +[[package]] +name = "rlimit" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7043b63bd0cd1aaa628e476b80e6d4023a3b50eb32789f2728908107bd0c793a" +dependencies = [ + "libc", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -476,7 +576,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -549,6 +649,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -558,6 +676,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -569,3 +751,17 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index dcaf66c..b4c2e21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,9 +5,24 @@ name = "fcry" version = "0.10.0" [dependencies] -chacha20poly1305 = {version = "0.10", features = ["stream"]} +argon2 = "0.5" +chacha20poly1305 = "0.10" clap = {version = "4", features = ["derive"]} getrandom = {version = "0.3"} +region = "3" +zeroize = {version = "1", features = ["derive"]} + +[target.'cfg(unix)'.dependencies] +libc = "0.2" +rlimit = "0.10" + +[target.'cfg(windows)'.dependencies] +windows-sys = {version = "0.59", features = [ + "Win32_System_Console", + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_Security", +]} [dev-dependencies] assert_cmd = "2" diff --git a/TODO.md b/TODO.md index d16f62e..f9540eb 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,3 @@ **Deferred to follow-up commits** (in order): -1. Switch single `EncryptorBE32` for manual STREAM nonces (preparation for parallelism) -2. `secrets` crate for key handling + `rlimit` to disable core dumps -3. Atomic file output (`.tmp` + rename) -4. `argon2id` KDF + passphrase prompt + CLI flags -5. Multi-threaded pipeline (worker pool + ordered writer) -6. Length-committed mode + random-access decrypt fast path for files +1. Multi-threaded pipeline (worker pool + ordered writer) +2. Length-committed mode + random-access decrypt fast path for files diff --git a/src/crypto.rs b/src/crypto.rs index a757c1a..8e6bd06 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -1,21 +1,70 @@ // SPDX-License-Identifier: GPL-3.0-only -use chacha20poly1305::{KeyInit, XChaCha20Poly1305, aead::stream}; +use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce, aead::AeadInPlace}; +use std::io::Write; use crate::error::*; use crate::header::{AlgId, Header, KdfParams, NONCE_PREFIX_LEN, TAG_LEN}; use crate::reader::{AheadReader, ReadInfoChunk}; +use crate::secrets::SecretBytes32; use crate::utils::*; +/// XChaCha20Poly1305 nonce: 24 bytes total. STREAM splits the trailing 5 bytes +/// into a 4-byte big-endian counter and a 1-byte "last block" flag. +const NONCE_LEN: usize = 24; +const COUNTER_LEN: usize = 4; +const _: () = assert!(NONCE_PREFIX_LEN + COUNTER_LEN + 1 == NONCE_LEN); + +fn make_nonce(prefix: &[u8; NONCE_PREFIX_LEN], counter: u32, last: bool) -> XNonce { + let mut n = [0u8; NONCE_LEN]; + n[..NONCE_PREFIX_LEN].copy_from_slice(prefix); + n[NONCE_PREFIX_LEN..NONCE_PREFIX_LEN + COUNTER_LEN].copy_from_slice(&counter.to_be_bytes()); + n[NONCE_LEN - 1] = u8::from(last); + XNonce::from(n) +} + +/// Derive (or unwrap) the 32-byte AEAD key from KDF parameters and an optional passphrase. +/// For `KdfParams::Raw`, `raw_key` must be supplied. +/// For `KdfParams::Argon2id`, `passphrase` must be supplied. +pub fn derive_key( + kdf: &KdfParams, + raw_key: Option<&[u8; 32]>, + passphrase: Option<&[u8]>, +) -> Result { + let mut out = SecretBytes32::zeroed(); + match kdf { + KdfParams::Raw => { + let raw = + raw_key.ok_or_else(|| FcryError::Format("raw kdf requires --raw-key".into()))?; + out.as_mut_array().copy_from_slice(raw); + } + KdfParams::Argon2id { + salt, + m_cost, + t_cost, + p_cost, + } => { + let pw = passphrase + .ok_or_else(|| FcryError::Format("argon2id kdf requires a passphrase".into()))?; + let params = argon2::Params::new(*m_cost, *t_cost, *p_cost, Some(32))?; + let argon = + argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + argon.hash_password_into(pw, salt, out.as_mut_array())?; + } + } + Ok(out) +} + pub fn encrypt>( input_file: Option, output_file: Option, - key: [u8; 32], + key: &SecretBytes32, chunk_size: u32, + kdf: KdfParams, ) -> Result<(), FcryError> { let chunk_sz = chunk_size as usize; let mut f_plain = AheadReader::from(open_input(input_file)?, chunk_sz); - let mut f_encrypted = open_output(output_file)?; + let mut f_encrypted = OutSink::open(output_file)?; let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN]; getrandom::fill(&mut nonce_prefix)?; @@ -24,27 +73,32 @@ pub fn encrypt>( alg: AlgId::XChaCha20Poly1305, flags: 0, chunk_size, - kdf: KdfParams::Raw, + kdf, nonce_prefix, }; let aad = header.encode(); f_encrypted.write_all(&aad)?; - let aead = XChaCha20Poly1305::new(&key.into()); - let mut stream_encryptor = stream::EncryptorBE32::from_aead(aead, &nonce_prefix.into()); + let aead = XChaCha20Poly1305::new(key.as_array().into()); let mut buf = vec![0u8; chunk_sz]; + let mut counter: u32 = 0; loop { match f_plain.read_ahead(&mut buf)? { ReadInfoChunk::Normal(_) => { - stream_encryptor.encrypt_next_in_place(&aad, &mut buf)?; + let nonce = make_nonce(&nonce_prefix, counter, false); + aead.encrypt_in_place(&nonce, &aad, &mut buf)?; f_encrypted.write_all(&buf)?; buf.truncate(chunk_sz); + counter = counter.checked_add(1).ok_or_else(|| { + FcryError::Format("STREAM counter overflow (input too large)".into()) + })?; } ReadInfoChunk::Last(n) => { buf.truncate(n); - stream_encryptor.encrypt_last_in_place(&aad, &mut buf)?; + let nonce = make_nonce(&nonce_prefix, counter, true); + aead.encrypt_in_place(&nonce, &aad, &mut buf)?; f_encrypted.write_all(&buf)?; break; } @@ -52,46 +106,56 @@ pub fn encrypt>( // Empty plaintext: still emit a final "last" tag so the decryptor // authenticates the (empty) stream rather than silently producing nothing. buf.clear(); - stream_encryptor.encrypt_last_in_place(&aad, &mut buf)?; + let nonce = make_nonce(&nonce_prefix, counter, true); + aead.encrypt_in_place(&nonce, &aad, &mut buf)?; f_encrypted.write_all(&buf)?; break; } } } + f_encrypted.commit()?; Ok(()) } pub fn decrypt>( input_file: Option, output_file: Option, - key: [u8; 32], + raw_key: Option<&[u8; 32]>, + passphrase: Option<&[u8]>, ) -> Result<(), FcryError> { let mut reader = open_input(input_file)?; let header = Header::read(&mut reader)?; let aad = header.encode(); + let key = derive_key(&header.kdf, raw_key, passphrase)?; + let chunk_sz = header.chunk_size as usize; let cipher_chunk = chunk_sz + TAG_LEN; let mut f_encrypted = AheadReader::from(reader, cipher_chunk); - let mut f_plain = open_output(output_file)?; + let mut f_plain = OutSink::open(output_file)?; - let aead = XChaCha20Poly1305::new(&key.into()); - let mut stream_decryptor = stream::DecryptorBE32::from_aead(aead, &header.nonce_prefix.into()); + let aead = XChaCha20Poly1305::new(key.as_array().into()); let mut buf = vec![0u8; cipher_chunk]; + let mut counter: u32 = 0; loop { match f_encrypted.read_ahead(&mut buf)? { ReadInfoChunk::Normal(_) => { - stream_decryptor.decrypt_next_in_place(&aad, &mut buf)?; + let nonce = make_nonce(&header.nonce_prefix, counter, false); + aead.decrypt_in_place(&nonce, &aad, &mut buf)?; f_plain.write_all(&buf)?; buf.resize(cipher_chunk, 0); + counter = counter + .checked_add(1) + .ok_or_else(|| FcryError::Format("STREAM counter overflow".into()))?; } ReadInfoChunk::Last(n) => { buf.truncate(n); - stream_decryptor.decrypt_last_in_place(&aad, &mut buf)?; + let nonce = make_nonce(&header.nonce_prefix, counter, true); + aead.decrypt_in_place(&nonce, &aad, &mut buf)?; f_plain.write_all(&buf)?; break; } @@ -103,5 +167,6 @@ pub fn decrypt>( } } + f_plain.commit()?; Ok(()) } diff --git a/src/error.rs b/src/error.rs index 574f5cb..4d34704 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,6 +10,8 @@ pub enum FcryError { Crypto(aead::Error), Rng(getrandom::Error), Format(String), + Kdf(String), + Passphrase(String), } impl From for FcryError { @@ -29,3 +31,9 @@ impl From for FcryError { FcryError::Rng(e) } } + +impl From for FcryError { + fn from(e: argon2::Error) -> Self { + FcryError::Kdf(e.to_string()) + } +} diff --git a/src/header.rs b/src/header.rs index 16b9d64..0009478 100644 --- a/src/header.rs +++ b/src/header.rs @@ -47,31 +47,68 @@ impl AlgId { } } +pub const ARGON2_SALT_LEN: usize = 16; + /// Key-derivation parameters stored in the header. /// -/// `Raw` means the key was supplied directly (no KDF). Future variants -/// (e.g. Argon2id) will carry their salt + cost parameters here. +/// `Raw` means the key was supplied directly (no KDF). `Argon2id` carries +/// the salt and cost parameters needed to redo derivation on decrypt. #[derive(Clone, Debug)] pub enum KdfParams { Raw, + Argon2id { + salt: [u8; ARGON2_SALT_LEN], + m_cost: u32, + t_cost: u32, + p_cost: u32, + }, } impl KdfParams { fn id(&self) -> u8 { match self { Self::Raw => 0, + Self::Argon2id { .. } => 1, } } - fn write_into(&self, _out: &mut Vec) { + fn write_into(&self, out: &mut Vec) { match self { Self::Raw => {} + Self::Argon2id { + salt, + m_cost, + t_cost, + p_cost, + } => { + out.extend_from_slice(salt); + out.extend_from_slice(&m_cost.to_le_bytes()); + out.extend_from_slice(&t_cost.to_le_bytes()); + out.extend_from_slice(&p_cost.to_le_bytes()); + } } } - fn read_from(id: u8, _r: &mut impl Read) -> Result { + fn read_from(id: u8, r: &mut impl Read) -> Result { match id { 0 => Ok(Self::Raw), + 1 => { + let mut salt = [0u8; ARGON2_SALT_LEN]; + r.read_exact(&mut salt)?; + let mut buf = [0u8; 4]; + r.read_exact(&mut buf)?; + let m_cost = u32::from_le_bytes(buf); + r.read_exact(&mut buf)?; + let t_cost = u32::from_le_bytes(buf); + r.read_exact(&mut buf)?; + let p_cost = u32::from_le_bytes(buf); + Ok(Self::Argon2id { + salt, + m_cost, + t_cost, + p_cost, + }) + } _ => Err(FcryError::Format(format!("unknown kdf id: {id}"))), } } diff --git a/src/main.rs b/src/main.rs index b5f7a64..7d196aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,13 +4,17 @@ mod crypto; mod error; mod header; mod reader; +mod secrets; mod utils; use crypto::*; use error::FcryError; +use header::{ARGON2_SALT_LEN, KdfParams}; +use secrets::{SecretBytes32, SecretVec, read_passphrase_tty}; use utils::DEFAULT_CHUNK_SIZE; use clap::Parser; +use zeroize::Zeroizing; /// fcry - [f]ile[cry]pt: A file en-/decryption tool for easy use #[derive(Parser, Debug)] @@ -31,35 +35,165 @@ struct Cli { /// The raw bytes of the crypto key. Has to be exactly 32 bytes. /// *** DANGEROUS: visible in process listings (ps/proc). Testing only. *** + #[clap(short, long, conflicts_with_all = ["passphrase", "passphrase_env"])] + raw_key: Option>, + + /// Read passphrase interactively (terminal). Implies argon2id KDF on encrypt. #[clap(short, long)] - raw_key: String, + passphrase: bool, + + /// Read passphrase from the named environment variable (for non-interactive use). + /// Implies argon2id KDF on encrypt. Mutually exclusive with --passphrase. + #[clap(long, conflicts_with = "passphrase")] + passphrase_env: Option, /// Plaintext chunk size in bytes (encryption only; decryption reads it from the header). #[clap(long, default_value_t = DEFAULT_CHUNK_SIZE)] chunk_size: u32, + + /// Argon2id memory in MiB (encryption only). Default: 1024 (= 1 GiB). + #[clap(long, default_value_t = 1024)] + argon_memory: u32, + + /// Argon2id passes / iterations (encryption only). + #[clap(long, default_value_t = 2)] + argon_passes: u32, + + /// Argon2id parallelism / lanes (encryption only). + #[clap(long, default_value_t = 4)] + argon_parallelism: u32, } -fn run(cli: Cli) -> Result<(), FcryError> { - let raw = cli.raw_key.as_bytes(); +fn parse_raw_key(s: &str) -> Result { + let raw = s.as_bytes(); if raw.len() != 32 { return Err(FcryError::Format(format!( "raw_key must be exactly 32 bytes, got {}", raw.len() ))); } - let mut key = [0u8; 32]; - key.copy_from_slice(raw); + let mut key = SecretBytes32::zeroed(); + key.as_mut_array().copy_from_slice(raw); + Ok(key) +} - if cli.decrypt { - decrypt(cli.input_file, cli.output_file, key)? +/// Source of a passphrase: either the terminal or a named env var. +enum PassphraseSource { + Tty, + EnvVar(String), +} + +fn read_passphrase(src: &PassphraseSource, confirm: bool) -> Result { + match src { + PassphraseSource::EnvVar(var) => { + // Take the env value, then immediately convert to a Zeroize+mlock'd + // buffer. The original `String` from `env::var` is consumed by + // `into_bytes()`, so its allocation moves into our SecretVec. + // Note: a copy still exists in the process `environ` table; that is + // a known and accepted leak for the env-var path. + let v = std::env::var(var).map_err(|_| { + FcryError::Passphrase(format!("environment variable {var} not set or not unicode")) + })?; + Ok(SecretVec::from_vec(v.into_bytes())) + } + PassphraseSource::Tty => { + let pw = read_passphrase_tty("Passphrase: ") + .map_err(|e| FcryError::Passphrase(e.to_string()))?; + if confirm { + let pw2 = read_passphrase_tty("Confirm passphrase: ") + .map_err(|e| FcryError::Passphrase(e.to_string()))?; + if pw != pw2 { + return Err(FcryError::Passphrase("passphrases do not match".into())); + } + // pw2 dropped here -> zeroized + munlocked. + } + Ok(pw) + } + } +} + +/// Best-effort: prevent secrets from landing in a core dump. +#[cfg(unix)] +fn disable_core_dumps() { + use rlimit::Resource; + let _ = rlimit::setrlimit(Resource::CORE, 0, 0); +} + +#[cfg(not(unix))] +fn disable_core_dumps() { + // Windows doesn't have rlimit-style core dumps. WER (Windows Error Reporting) + // and minidumps would be the analogue; disabling those requires per-machine + // policy and is intentionally not done here. +} + +fn run(mut cli: Cli) -> Result<(), FcryError> { + // Move the secret-bearing fields out of `Cli` immediately so they don't + // sit in the parsed struct for the rest of the function. + let raw_key_str: Option> = cli.raw_key.take(); + let pw_src: Option = if cli.passphrase { + Some(PassphraseSource::Tty) } else { - encrypt(cli.input_file, cli.output_file, key, cli.chunk_size)? + cli.passphrase_env.take().map(PassphraseSource::EnvVar) + }; + + let decrypt_mode = cli.decrypt; + let input = cli.input_file.take(); + let output = cli.output_file.take(); + let chunk_size = cli.chunk_size; + let argon_memory = cli.argon_memory; + let argon_passes = cli.argon_passes; + let argon_parallelism = cli.argon_parallelism; + drop(cli); + + if pw_src.is_none() && raw_key_str.is_none() { + return Err(FcryError::Format( + "must provide one of --raw-key, --passphrase, --passphrase-env".into(), + )); + } + + if decrypt_mode { + let raw_key = match raw_key_str.as_deref() { + Some(s) => Some(parse_raw_key(s)?), + None => None, + }; + let pw = match &pw_src { + Some(src) => Some(read_passphrase(src, false)?), + None => None, + }; + decrypt( + input, + output, + raw_key.as_ref().map(|k| k.as_array()), + pw.as_ref().map(|p| p.as_slice()), + )?; + } else { + let (key, kdf) = if let Some(src) = &pw_src { + let mut salt = [0u8; ARGON2_SALT_LEN]; + getrandom::fill(&mut salt)?; + let m_cost_kib = argon_memory.checked_mul(1024).ok_or_else(|| { + FcryError::Format("argon-memory too large (overflow when converting to KiB)".into()) + })?; + let kdf = KdfParams::Argon2id { + salt, + m_cost: m_cost_kib, + t_cost: argon_passes, + p_cost: argon_parallelism, + }; + let pw = read_passphrase(src, true)?; + let key = derive_key(&kdf, None, Some(pw.as_slice()))?; + (key, kdf) + } else { + let key = parse_raw_key(raw_key_str.as_deref().unwrap())?; + (key, KdfParams::Raw) + }; + encrypt(input, output, &key, chunk_size, kdf)?; } Ok(()) } fn main() { + disable_core_dumps(); let cli = Cli::parse(); if let Err(e) = run(cli) { eprintln!("Error: {:?}", e); diff --git a/src/secrets.rs b/src/secrets.rs new file mode 100644 index 0000000..3627fab --- /dev/null +++ b/src/secrets.rs @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! Secret-handling primitives. +//! +//! Two wrappers and a cross-platform passphrase reader: +//! +//! * [`SecretBytes32`] — heap-allocated 32-byte buffer, mlock'd, zero on drop. +//! * [`SecretVec`] — heap-allocated `Vec` with stable capacity, mlock'd, +//! zero on drop. +//! * [`read_passphrase_tty`] — direct tty reader (Linux/macOS termios, +//! Windows Console API). Reads into a pre-reserved `SecretVec` so no +//! reallocation can leave stale unzeroed copies on the heap. +//! +//! mlock is provided via the `region` crate (portable across Linux/macOS/Windows). +//! The lock is dropped *before* the underlying buffer is freed (field order +//! matters in Rust drop semantics). + +use std::io; +use zeroize::Zeroizing; + +/// Maximum passphrase length we accept on the tty. +/// Pre-reserved so the underlying Vec never reallocates while reading. +pub const MAX_PASSPHRASE_LEN: usize = 1024; + +/// Heap-allocated 32-byte secret. mlock'd; zeroed on drop. +pub struct SecretBytes32 { + // Field order matters: `_lock` is dropped first (munlock the page), then + // `inner` is dropped (zeroize the bytes, then free). + _lock: Option, + inner: Box>, +} + +impl SecretBytes32 { + pub fn zeroed() -> Self { + let inner = Box::new(Zeroizing::new([0u8; 32])); + let lock = region::lock(inner.as_ptr(), inner.len()).ok(); + Self { _lock: lock, inner } + } + + pub fn as_array(&self) -> &[u8; 32] { + &self.inner + } + + pub fn as_mut_array(&mut self) -> &mut [u8; 32] { + &mut self.inner + } +} + +/// Heap-allocated byte buffer with **fixed capacity** that is mlock'd and +/// zeroed on drop. Pushing beyond the reserved capacity is rejected so the +/// underlying allocation never moves (which would invalidate the lock and +/// leave a stale unzeroed copy behind). +pub struct SecretVec { + _lock: Option, + inner: Zeroizing>, + capacity: usize, +} + +impl SecretVec { + /// Allocate a buffer with fixed `capacity` and mlock it. + pub fn with_capacity(capacity: usize) -> Self { + let inner = Zeroizing::new(Vec::with_capacity(capacity)); + let lock = if capacity > 0 { + region::lock(inner.as_ptr(), capacity).ok() + } else { + None + }; + Self { + _lock: lock, + inner, + capacity, + } + } + + /// Wrap an already-allocated `Vec` (e.g. one we got from + /// `String::into_bytes()` for the env-var path). The Vec's `capacity` + /// is mlock'd as-is. Pushing afterwards is forbidden. + pub fn from_vec(v: Vec) -> Self { + let cap = v.capacity(); + let inner = Zeroizing::new(v); + let lock = if cap > 0 { + region::lock(inner.as_ptr(), cap).ok() + } else { + None + }; + Self { + _lock: lock, + inner, + capacity: cap, + } + } + + pub fn push(&mut self, b: u8) -> io::Result<()> { + if self.inner.len() >= self.capacity { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "secret buffer full", + )); + } + self.inner.push(b); + Ok(()) + } + + pub fn as_slice(&self) -> &[u8] { + &self.inner + } +} + +impl PartialEq for SecretVec { + fn eq(&self, other: &Self) -> bool { + // constant-time-ish: compare full slices, no early return on mismatch. + let a = self.as_slice(); + let b = other.as_slice(); + if a.len() != b.len() { + return false; + } + let mut diff: u8 = 0; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 + } +} + +// ============================================================================ +// tty passphrase reader +// ============================================================================ + +/// Read a passphrase from the controlling terminal with echo disabled. +/// +/// Bytes go directly into a pre-reserved `SecretVec` so no reallocation can +/// leave stale heap copies. CR is skipped, LF terminates the line. +/// Returns an error if the input exceeds `MAX_PASSPHRASE_LEN` bytes. +pub fn read_passphrase_tty(prompt: &str) -> io::Result { + imp::read_passphrase_tty(prompt) +} + +#[cfg(unix)] +mod imp { + use super::{MAX_PASSPHRASE_LEN, SecretVec}; + use std::fs::OpenOptions; + use std::io::{self, Read, Write}; + use std::os::unix::io::AsRawFd; + + /// RAII guard that restores the original termios on drop. + struct TermiosGuard { + fd: i32, + orig: libc::termios, + } + + impl Drop for TermiosGuard { + fn drop(&mut self) { + unsafe { + libc::tcsetattr(self.fd, libc::TCSANOW, &self.orig); + } + } + } + + pub fn read_passphrase_tty(prompt: &str) -> io::Result { + let mut tty = OpenOptions::new().read(true).write(true).open("/dev/tty")?; + let fd = tty.as_raw_fd(); + + let mut orig: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(fd, &mut orig) } != 0 { + return Err(io::Error::last_os_error()); + } + + let mut new = orig; + // Disable echo of typed characters; keep ECHONL so the final newline + // is shown when the user presses Enter (cosmetic). + new.c_lflag &= !libc::ECHO; + new.c_lflag |= libc::ECHONL; + if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &new) } != 0 { + return Err(io::Error::last_os_error()); + } + let _guard = TermiosGuard { fd, orig }; + + write!(tty, "{prompt}")?; + tty.flush()?; + + let mut buf = SecretVec::with_capacity(MAX_PASSPHRASE_LEN); + let mut byte = [0u8; 1]; + loop { + match tty.read(&mut byte) { + Ok(0) => break, // EOF + Ok(_) => match byte[0] { + b'\n' => break, + b'\r' => continue, + b => buf.push(b)?, + }, + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + + Ok(buf) + } +} + +#[cfg(windows)] +mod imp { + use super::{MAX_PASSPHRASE_LEN, SecretVec}; + use std::fs::OpenOptions; + use std::io::{self, Read, Write}; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::System::Console::{ + ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, GetConsoleMode, + SetConsoleMode, + }; + + struct ConsoleModeGuard { + handle: HANDLE, + orig: u32, + } + + impl Drop for ConsoleModeGuard { + fn drop(&mut self) { + unsafe { + SetConsoleMode(self.handle, self.orig); + } + } + } + + pub fn read_passphrase_tty(prompt: &str) -> io::Result { + let mut tty_in = OpenOptions::new().read(true).write(true).open("CONIN$")?; + let mut tty_out = OpenOptions::new().write(true).open("CONOUT$")?; + + let h_in = tty_in.as_raw_handle() as HANDLE; + let mut orig_mode: u32 = 0; + if unsafe { GetConsoleMode(h_in, &mut orig_mode) } == 0 { + return Err(io::Error::last_os_error()); + } + + let new_mode = + (orig_mode | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT) & !ENABLE_ECHO_INPUT; + if unsafe { SetConsoleMode(h_in, new_mode) } == 0 { + return Err(io::Error::last_os_error()); + } + let _guard = ConsoleModeGuard { + handle: h_in, + orig: orig_mode, + }; + + write!(tty_out, "{prompt}")?; + tty_out.flush()?; + + let mut buf = SecretVec::with_capacity(MAX_PASSPHRASE_LEN); + let mut byte = [0u8; 1]; + loop { + match tty_in.read(&mut byte) { + Ok(0) => break, + Ok(_) => match byte[0] { + b'\n' => break, + b'\r' => continue, + b => buf.push(b)?, + }, + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + + // Echo was off, so emit a newline so the next shell prompt is on a fresh line. + let _ = writeln!(tty_out); + let _ = tty_out.flush(); + + Ok(buf) + } +} diff --git a/src/utils.rs b/src/utils.rs index c9b78e7..060ad39 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,7 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-only -use std::fs::File; +use std::fs::{self, File}; use std::io::{self, BufRead, BufReader, Write}; +use std::path::PathBuf; /// Default plaintext chunk size: 1 MiB. /// @@ -16,9 +17,99 @@ pub(crate) fn open_input>(input_file: Option) -> io::Result>(output_file: Option) -> io::Result> { - Ok(match output_file { - Some(f) => Box::new(File::create(f.as_ref())?), - None => Box::new(io::stdout()), - }) +/// Output sink that supports atomic file replacement. +/// +/// For file outputs: bytes are written to `.tmp`. On `commit()`, the +/// temp file is renamed into place. If dropped without commit (panic, error, +/// process exit), the temp file is deleted so a partial/garbage file does +/// not replace any existing target. +/// +/// For stdout: behaves as a passthrough; `commit()` is a no-op. +pub enum OutSink { + Stdout(io::Stdout), + File { + tmp_path: PathBuf, + final_path: PathBuf, + file: Option, + committed: bool, + }, +} + +impl OutSink { + pub fn open>(output_file: Option) -> io::Result { + match output_file { + None => Ok(Self::Stdout(io::stdout())), + Some(f) => { + let final_path = PathBuf::from(f.as_ref()); + let mut tmp_path = final_path.clone(); + let name = tmp_path + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_default(); + let mut tmp_name = name; + tmp_name.push(".tmp"); + tmp_path.set_file_name(tmp_name); + let file = File::create(&tmp_path)?; + Ok(Self::File { + tmp_path, + final_path, + file: Some(file), + committed: false, + }) + } + } + } + + pub fn commit(mut self) -> io::Result<()> { + if let Self::File { + tmp_path, + final_path, + file, + committed, + } = &mut self + { + if let Some(mut f) = file.take() { + f.flush()?; + f.sync_all()?; + } + fs::rename(&*tmp_path, &*final_path)?; + *committed = true; + } + Ok(()) + } +} + +impl Write for OutSink { + fn write(&mut self, buf: &[u8]) -> io::Result { + match self { + Self::Stdout(s) => s.write(buf), + Self::File { file, .. } => file.as_mut().expect("file taken before commit").write(buf), + } + } + + fn flush(&mut self) -> io::Result<()> { + match self { + Self::Stdout(s) => s.flush(), + Self::File { file, .. } => match file.as_mut() { + Some(f) => f.flush(), + None => Ok(()), + }, + } + } +} + +impl Drop for OutSink { + fn drop(&mut self) { + if let Self::File { + tmp_path, + committed, + file, + .. + } = self + && !*committed + { + file.take(); // close the file before unlink + let _ = fs::remove_file(tmp_path); + } + } } diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 366079f..0b36ab0 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -331,6 +331,97 @@ fn rejects_short_raw_key() { ); } +#[test] +fn roundtrip_passphrase_argon2id() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + let ct = dir.path().join("c.bin"); + let rt = dir.path().join("r.bin"); + let data = pseudo_random(7, 100_000); + fs::write(&plain, &data).unwrap(); + + // Use cheap argon2 params so the test stays fast. + let enc = fcry() + .arg("-i") + .arg(&plain) + .arg("-o") + .arg(&ct) + .arg("--passphrase-env") + .arg("FCRY_TEST_PW") + .arg("--argon-memory") + .arg("8") + .arg("--argon-passes") + .arg("1") + .env("FCRY_TEST_PW", "correct horse battery staple") + .output() + .unwrap(); + assert!( + enc.status.success(), + "passphrase encrypt failed: {}", + String::from_utf8_lossy(&enc.stderr) + ); + + let dec = fcry() + .arg("-d") + .arg("-i") + .arg(&ct) + .arg("-o") + .arg(&rt) + .arg("--passphrase-env") + .arg("FCRY_TEST_PW") + .env("FCRY_TEST_PW", "correct horse battery staple") + .output() + .unwrap(); + assert!( + dec.status.success(), + "passphrase decrypt failed: {}", + String::from_utf8_lossy(&dec.stderr) + ); + assert_eq!(fs::read(&rt).unwrap(), data); + + // Wrong passphrase must fail. + let bad = fcry() + .arg("-d") + .arg("-i") + .arg(&ct) + .arg("-o") + .arg(dir.path().join("bad.bin")) + .arg("--passphrase-env") + .arg("FCRY_TEST_PW") + .env("FCRY_TEST_PW", "wrong passphrase") + .output() + .unwrap(); + assert!(!bad.status.success(), "wrong passphrase should fail"); +} + +#[test] +fn atomic_output_no_stale_tmp_on_failure() { + // A failed decrypt (wrong key) should not leave the output file behind. + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + let ct = dir.path().join("c.bin"); + let rt = dir.path().join("r.bin"); + fs::write(&plain, b"hello world").unwrap(); + encrypt_file(&plain, &ct, None); + + let wrong = "ffffffffffffffffffffffffffffffff"; + let out = fcry() + .arg("-d") + .arg("-i") + .arg(&ct) + .arg("-o") + .arg(&rt) + .arg("--raw-key") + .arg(wrong) + .output() + .unwrap(); + assert!(!out.status.success()); + assert!(!rt.exists(), "final output must not exist after failure"); + let mut tmp = rt.clone(); + tmp.set_file_name("r.bin.tmp"); + assert!(!tmp.exists(), "temp file must be cleaned up"); +} + #[test] fn header_chunk_size_is_authoritative_on_decrypt() { // Encrypt with a non-default chunk size; decrypt without specifying one.