From 11d329c5a2a2df0c785dfcf3025fc52828923ff0 Mon Sep 17 00:00:00 2001 From: Joseph O'Brien <98370624+89jobrien@users.noreply.github.com> Date: Sat, 11 Apr 2026 23:38:41 -0400 Subject: [PATCH] security: fix Bitwarden password exposure and ChaCha nonce hardening Issue 12: pass bw unlock password via stdin (--passwordstdin) instead of as a CLI arg, preventing it from appearing in /proc//cmdline or ps output. Issue 11: replace Nonce::default() (all-zero) with the first 12 bytes of the 16-byte random payload nonce. The key-nonce pair was already unique per message (HKDF salt), but using an explicit non-zero nonce removes the latent risk of accidental key reuse and makes the safety invariant self-documenting. Closes #11, closes #12 --- crates/notsecrets/src/decrypt.rs | 5 ++++- crates/notsecrets/src/encrypt.rs | 8 +++++++- crates/notsecrets/src/sources/bitwarden.rs | 23 ++++++++++++++++++---- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/notsecrets/src/decrypt.rs b/crates/notsecrets/src/decrypt.rs index 7e8abcb..1f93eef 100644 --- a/crates/notsecrets/src/decrypt.rs +++ b/crates/notsecrets/src/decrypt.rs @@ -55,7 +55,10 @@ impl Decryptor { let payload_key = derive_payload_key(&file_key, &nonce_bytes)?; let cipher = ChaCha20Poly1305::new(Key::from_slice(&payload_key)); - let counter_nonce = Nonce::default(); + // Mirror encrypt.rs: use the first 12 bytes of the 16-byte random nonce. + let counter_nonce = Nonce::from( + <[u8; 12]>::try_from(&nonce_bytes[..12]).expect("slice is exactly 12 bytes"), + ); let plaintext = cipher .decrypt(&counter_nonce, payload_ciphertext) .map_err(|_| AgeError::CryptoError("payload decryption failed".to_string()))?; diff --git a/crates/notsecrets/src/encrypt.rs b/crates/notsecrets/src/encrypt.rs index afb4d9b..48ccdc7 100644 --- a/crates/notsecrets/src/encrypt.rs +++ b/crates/notsecrets/src/encrypt.rs @@ -82,7 +82,13 @@ impl Encryptor { let payload_key = derive_payload_key(&file_key, &nonce_bytes)?; let cipher = ChaCha20Poly1305::new(Key::from_slice(&payload_key)); - let counter_nonce = Nonce::default(); // [0u8; 12] + // Use the first 12 bytes of the 16-byte random nonce as the ChaCha nonce. + // The payload key is already unique per message (derived via HKDF with the full + // 16-byte nonce as salt), so this is safe — but using a non-zero nonce makes + // the invariant explicit and removes the latent risk of accidental key reuse. + let counter_nonce = Nonce::from( + <[u8; 12]>::try_from(&nonce_bytes[..12]).expect("slice is exactly 12 bytes"), + ); let ciphertext = cipher .encrypt(&counter_nonce, plaintext) .map_err(|_| AgeError::CryptoError("payload encryption failed".to_string()))?; diff --git a/crates/notsecrets/src/sources/bitwarden.rs b/crates/notsecrets/src/sources/bitwarden.rs index dd3c943..7006e96 100644 --- a/crates/notsecrets/src/sources/bitwarden.rs +++ b/crates/notsecrets/src/sources/bitwarden.rs @@ -2,7 +2,8 @@ use crate::error::AgeError; use crate::identities::Identity; use crate::identities::x25519::X25519Identity; use crate::sources::IdentitySource; -use std::process::Command; +use std::io::Write; +use std::process::{Command, Stdio}; pub struct BitwardenSource { pub item_name: String, @@ -37,13 +38,27 @@ impl BitwardenSource { source: anyhow::anyhow!("could not read password: {e}"), } })?; - let output = Command::new("bw") - .args(["unlock", "--raw", &password]) - .output() + // Pass password via stdin to avoid it appearing in the process list. + let mut child = Command::new("bw") + .args(["unlock", "--raw", "--passwordstdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() .map_err(|e| AgeError::SourceError { name: self.name().to_string(), source: anyhow::anyhow!("bw unlock spawn: {e}"), })?; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(password.as_bytes()).map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw unlock stdin write: {e}"), + })?; + } + let output = child.wait_with_output().map_err(|e| AgeError::SourceError { + name: self.name().to_string(), + source: anyhow::anyhow!("bw unlock wait: {e}"), + })?; if !output.status.success() { return Err(AgeError::SourceError { name: self.name().to_string(),