diff --git a/Cargo.lock b/Cargo.lock index 3980a56..d1f5e71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,12 +95,6 @@ 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" @@ -286,8 +280,8 @@ dependencies = [ "clap", "getrandom 0.3.4", "libc", - "region", "rlimit", + "secrets", "tempfile", "windows-sys 0.59.0", "zeroize", @@ -359,15 +353,6 @@ 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" @@ -392,6 +377,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -403,6 +398,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "poly1305" version = "0.8.0" @@ -480,18 +481,6 @@ 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" @@ -507,13 +496,25 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags", "errno", "libc", "linux-raw-sys", "windows-sys 0.61.2", ] +[[package]] +name = "secrets" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71f5325144404085953b8078fa4a0b4d224d13f17ee3854534260ad7ff3dfb5c" +dependencies = [ + "libc", + "page_size", + "pkg-config", + "vcpkg", +] + [[package]] name = "serde" version = "1.0.228" @@ -613,6 +614,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -643,21 +650,34 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" 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" diff --git a/Cargo.toml b/Cargo.toml index b4c2e21..dcdc5cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ argon2 = "0.5" chacha20poly1305 = "0.10" clap = {version = "4", features = ["derive"]} getrandom = {version = "0.3"} -region = "3" +protected-secrets = {package = "secrets", version = "1.3"} zeroize = {version = "1", features = ["derive"]} [target.'cfg(unix)'.dependencies] diff --git a/src/crypto.rs b/src/crypto.rs index 8e6bd06..130d452 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -6,7 +6,7 @@ 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::secrets::{SecretBytes32, SecretVec}; use crate::utils::*; /// XChaCha20Poly1305 nonce: 24 bytes total. STREAM splits the trailing 5 bytes @@ -28,15 +28,15 @@ fn make_nonce(prefix: &[u8; NONCE_PREFIX_LEN], counter: u32, last: bool) -> XNon /// For `KdfParams::Argon2id`, `passphrase` must be supplied. pub fn derive_key( kdf: &KdfParams, - raw_key: Option<&[u8; 32]>, - passphrase: Option<&[u8]>, + raw_key: Option<&SecretBytes32>, + passphrase: Option<&SecretVec>, ) -> 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); + raw.with_array(|raw| out.with_mut_array(|out| out.copy_from_slice(raw))); } KdfParams::Argon2id { salt, @@ -49,7 +49,7 @@ pub fn derive_key( 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())?; + pw.with_slice(|pw| out.with_mut_array(|out| argon.hash_password_into(pw, salt, out)))?; } } Ok(out) @@ -79,7 +79,9 @@ pub fn encrypt>( let aad = header.encode(); f_encrypted.write_all(&aad)?; - let aead = XChaCha20Poly1305::new(key.as_array().into()); + // The AEAD keeps its own unprotected key copy while the loop runs. + // chacha20poly1305 zeroizes that copy on drop. + let aead = key.with_array(|key| XChaCha20Poly1305::new(key.into())); let mut buf = vec![0u8; chunk_sz]; let mut counter: u32 = 0; @@ -121,8 +123,8 @@ pub fn encrypt>( pub fn decrypt>( input_file: Option, output_file: Option, - raw_key: Option<&[u8; 32]>, - passphrase: Option<&[u8]>, + raw_key: Option<&SecretBytes32>, + passphrase: Option<&SecretVec>, ) -> Result<(), FcryError> { let mut reader = open_input(input_file)?; let header = Header::read(&mut reader)?; @@ -136,7 +138,9 @@ pub fn decrypt>( let mut f_encrypted = AheadReader::from(reader, cipher_chunk); let mut f_plain = OutSink::open(output_file)?; - let aead = XChaCha20Poly1305::new(key.as_array().into()); + // The AEAD keeps its own unprotected key copy while the loop runs. + // chacha20poly1305 zeroizes that copy on drop. + let aead = key.with_array(|key| XChaCha20Poly1305::new(key.into())); let mut buf = vec![0u8; cipher_chunk]; let mut counter: u32 = 0; diff --git a/src/main.rs b/src/main.rs index 7d196aa..118e92e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,7 +73,7 @@ fn parse_raw_key(s: &str) -> Result { ))); } let mut key = SecretBytes32::zeroed(); - key.as_mut_array().copy_from_slice(raw); + key.with_mut_array(|key| key.copy_from_slice(raw)); Ok(key) } @@ -86,9 +86,8 @@ enum PassphraseSource { 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. + // Take the env value, then immediately copy it into upstream + // protected storage. The source Vec is zeroed after the copy. // 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(|_| { @@ -105,7 +104,7 @@ fn read_passphrase(src: &PassphraseSource, confirm: bool) -> Result zeroized + munlocked. + // pw2 dropped here -> zeroized + unlocked by the upstream crate. } Ok(pw) } @@ -160,12 +159,7 @@ fn run(mut cli: Cli) -> Result<(), FcryError> { 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()), - )?; + decrypt(input, output, raw_key.as_ref(), pw.as_ref())?; } else { let (key, kdf) = if let Some(src) = &pw_src { let mut salt = [0u8; ARGON2_SALT_LEN]; @@ -180,7 +174,7 @@ fn run(mut cli: Cli) -> Result<(), FcryError> { p_cost: argon_parallelism, }; let pw = read_passphrase(src, true)?; - let key = derive_key(&kdf, None, Some(pw.as_slice()))?; + let key = derive_key(&kdf, None, Some(&pw))?; (key, kdf) } else { let key = parse_raw_key(raw_key_str.as_deref().unwrap())?; diff --git a/src/secrets.rs b/src/secrets.rs index 3627fab..11f8671 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -2,120 +2,111 @@ //! Secret-handling primitives. //! -//! Two wrappers and a cross-platform passphrase reader: +//! Thin local adapters around the upstream `secrets` crate plus 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. +//! * [`SecretBytes32`] — heap-allocated 32-byte buffer protected by +//! `secrets::SecretBox`. +//! * [`SecretVec`] — fixed-allocation byte buffer protected by +//! `secrets::SecretVec`, with a separate logical length so tty input can be +//! appended without reallocations. //! * [`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; + +use protected_secrets::{SecretBox as ProtectedSecretBox, SecretVec as ProtectedSecretVec}; /// 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. +/// Heap-allocated 32-byte secret protected by the upstream `secrets` crate. 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>, + inner: ProtectedSecretBox<[u8; 32]>, } 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, + inner: ProtectedSecretBox::zero(), } } - /// 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 - }; + pub fn with_array(&self, f: impl FnOnce(&[u8; 32]) -> R) -> R { + let inner = self.inner.borrow(); + f(&inner) + } + + pub fn with_mut_array(&mut self, f: impl FnOnce(&mut [u8; 32]) -> R) -> R { + let mut inner = self.inner.borrow_mut(); + f(&mut inner) + } +} + +/// Heap-allocated byte buffer with **fixed capacity** protected by upstream +/// `secrets::SecretVec`. +/// +/// Upstream `SecretVec` is fixed-length, so this adapter stores a separate +/// logical length. Bytes after `len` remain zero-filled padding and are never +/// exposed through [`SecretVec::with_slice`]. +pub struct SecretVec { + inner: ProtectedSecretVec, + len: usize, +} + +impl SecretVec { + /// Allocate a protected buffer with fixed `capacity`. + pub fn with_capacity(capacity: usize) -> Self { Self { - _lock: lock, - inner, - capacity: cap, + inner: ProtectedSecretVec::zero(capacity), + len: 0, + } + } + + /// Copy bytes from an already-allocated `Vec` into protected storage. + /// The upstream conversion zeroes the source bytes after copying; the + /// allocation itself is then released normally when the Vec is dropped. + pub fn from_vec(mut v: Vec) -> Self { + let len = v.len(); + Self { + inner: ProtectedSecretVec::from(&mut v[..]), + len, } } pub fn push(&mut self, b: u8) -> io::Result<()> { - if self.inner.len() >= self.capacity { + if self.len >= self.inner.len() { return Err(io::Error::new( io::ErrorKind::InvalidInput, "secret buffer full", )); } - self.inner.push(b); + { + let mut inner = self.inner.borrow_mut(); + inner[self.len] = b; + } + self.len += 1; Ok(()) } - pub fn as_slice(&self) -> &[u8] { - &self.inner + pub fn with_slice(&self, f: impl FnOnce(&[u8]) -> R) -> R { + let inner = self.inner.borrow(); + f(&inner[..self.len]) } } 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() { + // Constant-time-ish: length still leaks, but contents do not early-out. + if self.len != other.len { return false; } + let a = self.inner.borrow(); + let b = other.inner.borrow(); let mut diff: u8 = 0; - for (x, y) in a.iter().zip(b.iter()) { + for (x, y) in a[..self.len].iter().zip(b[..other.len].iter()) { diff |= x ^ y; } diff == 0