refactor(secrets): back SecretBytes32/SecretVec with the secrets crate
Replace the homegrown `region::lock` + `Zeroizing` wrappers with thin
adapters over `secrets::SecretBox` and `secrets::SecretVec`. The
upstream crate already provides everything the local types were doing
by hand (mlock, zero-on-drop) and adds protections we didn't have:
guard pages around the allocation and `mprotect`-based access control
(`PROT_NONE` at rest, `PROT_READ` during a borrow, `PROT_READ|WRITE`
during a mut borrow). Net result is a security upgrade, not just a
dependency swap.
Why a local adapter still exists
--------------------------------
`secrets::SecretVec` is fixed-length — no `push`. The tty passphrase
reader needs to append bytes one at a time without ever reallocating
(a panicking partial read must not leave stale plaintext on the heap),
so `SecretVec` keeps a separate logical `len` over a fixed protected
allocation of `MAX_PASSPHRASE_LEN` bytes. Bytes past `len` stay
zero-padding and are never exposed through `with_slice`.
API shape: closure-scoped borrows
---------------------------------
The previous `as_slice` / `as_array` returned long-lived `&[u8]`
references, which would have kept the upstream pages in `PROT_READ`
for the full lifetime of the borrow. The new API uses
`with_array(|s| ...)`, `with_mut_array(|s| ...)`, `with_slice(|s| ...)`
so the unprotected window is exactly the closure body. This is uglier
at call sites (notably the nested closures in `derive_key`) but it's
the right tradeoff — minimizing the unprotected window is the whole
point of using the crate.
AEAD key copy footnote
----------------------
`XChaCha20Poly1305::new` copies the key into its own (unprotected)
state, which then lives in the `aead` binding for the entire
encrypt/decrypt loop. This is unchanged from before — the cipher
state was never protected — but it's now called out explicitly with
a comment at both call sites noting that `chacha20poly1305` zeroizes
that internal copy on drop. Future readers shouldn't have to
rediscover this by reading upstream source.
`from_vec` zeroing
------------------
`SecretVec::from_vec(v: Vec<u8>)` is used on the env-var path. It
calls `secrets::SecretVec::from(&mut [u8])`, which (verified against
secrets-1.3.0: `Box::from` -> `transfer` -> `memtransfer`) copies the
bytes into protected storage and zeroes the source slice. The
original Vec's allocation is then released through the normal
allocator — the bytes inside it are zero, but the heap block itself
isn't specially handled. The doc comment on `from_vec` reflects this
precisely. As before, the env-var path also leaves a copy in the
process `environ` table, which is a known accepted leak.
Cargo.toml
----------
Use `protected-secrets = { package = "secrets", version = "1.3" }`
with default features. The `secrets` crate has no pure-Rust backend
at v1.3 — disabling default features only switches *how* libsodium
is linked (bundled `libsodium-sys` vs. the crate's own bindings to a
system libsodium), and can break builds where the chosen path isn't
set up. Defaults are correct here. The `region` dependency is
dropped.
Test plan
---------
- `cargo build` clean.
- `cargo test` — 2 unit + 18 integration tests pass, including
`roundtrip_passphrase_argon2id` which exercises the full
passphrase -> argon2id -> AEAD key path through the new wrappers.
- `cargo clippy` (and `--tests`, `--benches`) clean.
- `cargo +nightly fmt` applied.
This commit is contained in:
+13
-9
@@ -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<SecretBytes32, FcryError> {
|
||||
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<S: AsRef<str>>(
|
||||
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<S: AsRef<str>>(
|
||||
pub fn decrypt<S: AsRef<str>>(
|
||||
input_file: Option<S>,
|
||||
output_file: Option<S>,
|
||||
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<S: AsRef<str>>(
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user