diff --git a/README.md b/README.md index b73fc3a..1d8722d 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,6 @@ rpass completions powershell >> $PROFILE **Known differences from `pass`:** - `generate`, `insert`, `edit`, `rm`, and `mv` for writes - Git is explicit (`rpass git `) rather than automatic -- Changing recipients with `init` does not re-encrypt existing entries - Clipboard and QR codes are not implemented - Unsupported `pass` flags are rejected instead of ignored diff --git a/src/cli.rs b/src/cli.rs index 45aa35d..89f10ed 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -18,7 +18,8 @@ use crate::password_store::importer::bitwarden::BitwardenImporter; use crate::password_store::{ DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, ImportEntries, ImportResult, InitStore, InitStoreResult, InsertEntry, ListEntries, MoveEntry, OtpCode, PasswordStore, - Recipients, RecipientsResult, RemoveEntry, SearchEntries, ShowEntry, StoreDirectory, + ReEncryptEntries, Recipients, RecipientsResult, RemoveEntry, SearchEntries, ShowEntry, + StoreDirectory, }; use tree_output::EntryTree; @@ -496,6 +497,16 @@ fn init_store(command: InitCommand, store_directory: StoreDirectory) -> Result<( let result = InitStore::new(store_directory.clone()) .execute(command.path.as_deref(), &command.gpg_ids)?; let store = PasswordStore::open(store_directory)?; + + if !result.removed && !result.recipients.is_empty() { + let gpg = GpgCommand::from_environment(); + ReEncryptEntries::new(&store, gpg).execute( + command.path.as_deref(), + &result.recipients, + None, + )?; + } + auto_commit(&store, &init_commit_message(&command, &result))?; if command.json { @@ -577,12 +588,24 @@ fn recipients(command: RecipientsCommand, store_directory: StoreDirectory) -> Re Some(RecipientsAction::Add { key_id }) => { let result = recipients.add(command.path.as_deref(), key_id)?; if result.changed { + let gpg = GpgCommand::from_environment(); + ReEncryptEntries::new(&store, gpg).execute( + command.path.as_deref(), + &result.recipients, + None, + )?; auto_commit(&store, &format!("Added GPG id {key_id}."))?; } result } Some(RecipientsAction::Remove { key_id }) => { let result = recipients.remove(command.path.as_deref(), key_id)?; + let gpg = GpgCommand::from_environment(); + ReEncryptEntries::new(&store, gpg).execute( + command.path.as_deref(), + &result.recipients, + None, + )?; auto_commit(&store, &format!("Removed GPG id {key_id}."))?; result } diff --git a/src/password_store/mod.rs b/src/password_store/mod.rs index 49373f3..0ddb49d 100644 --- a/src/password_store/mod.rs +++ b/src/password_store/mod.rs @@ -11,6 +11,7 @@ mod insert_entry; mod list_entries; mod move_entry; mod otp; +mod re_encrypt_entries; mod recipients; mod remove_entry; mod search_entries; @@ -29,6 +30,7 @@ pub use insert_entry::InsertEntry; pub use list_entries::ListEntries; pub use move_entry::MoveEntry; pub use otp::OtpCode; +pub use re_encrypt_entries::ReEncryptEntries; pub use recipients::{Recipients, RecipientsResult}; pub use remove_entry::RemoveEntry; pub use search_entries::SearchEntries; diff --git a/src/password_store/re_encrypt_entries.rs b/src/password_store/re_encrypt_entries.rs new file mode 100644 index 0000000..0580ced --- /dev/null +++ b/src/password_store/re_encrypt_entries.rs @@ -0,0 +1,71 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use super::{GpgCommand, PasswordStore, PasswordStoreError}; + +pub struct ReEncryptEntries<'store> { + store: &'store PasswordStore, + gpg: GpgCommand, +} + +impl<'store> ReEncryptEntries<'store> { + pub fn new(store: &'store PasswordStore, gpg: GpgCommand) -> Self { + Self { store, gpg } + } + + pub fn execute( + &self, + subfolder: Option<&str>, + recipients: &[String], + passphrase: Option<&str>, + ) -> Result, PasswordStoreError> { + if recipients.is_empty() { + return Ok(Vec::new()); + } + + let target_dir = match subfolder { + Some(sub) => self.store.path().join(sub), + None => self.store.path().to_path_buf(), + }; + + let mut reencrypted = Vec::new(); + reencrypt_dir( + &self.gpg, + &target_dir, + recipients, + passphrase, + &mut reencrypted, + )?; + Ok(reencrypted) + } +} + +fn reencrypt_dir( + gpg: &GpgCommand, + dir: &Path, + recipients: &[String], + passphrase: Option<&str>, + reencrypted: &mut Vec, +) -> Result<(), PasswordStoreError> { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return Ok(()), + }; + + for entry in entries.flatten() { + let path = entry.path(); + + if path.is_dir() { + if path.join(".gpg-id").exists() { + continue; + } + reencrypt_dir(gpg, &path, recipients, passphrase, reencrypted)?; + } else if path.extension().is_some_and(|ext| ext == "gpg") { + let plaintext = gpg.decrypt(&path, passphrase)?; + gpg.encrypt(&plaintext, &path, recipients)?; + reencrypted.push(path); + } + } + + Ok(()) +} diff --git a/tests/init_command.rs b/tests/init_command.rs index ca69c20..f4fe1d9 100644 --- a/tests/init_command.rs +++ b/tests/init_command.rs @@ -6,7 +6,7 @@ use std::path::Path; use predicates::prelude::*; use serde_json::Value; -use support::rpass; +use support::{reencrypting_gpg_script, rpass}; #[test] fn init_creates_missing_store_and_writes_gpg_id() { @@ -196,6 +196,143 @@ fn init_auto_commits_when_store_is_git_repository() { ); } +#[test] +fn init_re_encrypts_existing_entries_with_new_recipients() { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file(store.path().join(".gpg-id"), "old@example.invalid\n"); + write_file(store.path().join("entry.gpg"), "secret\n"); + write_file(store.path().join("subdir/nested.gpg"), "nested-secret\n"); + + let (gpg, log_file) = reencrypting_gpg_script(store.path()); + + rpass() + .env("PASSWORD_STORE_GPG", &gpg) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "init", + "new@example.invalid", + ]) + .assert() + .success() + .stdout("Password store initialized for new@example.invalid\n") + .stderr(""); + + let log = fs::read_to_string(&log_file).expect("log file"); + assert!( + log.contains("recipient:new@example.invalid"), + "expected new recipient in log, got: {log}" + ); + let encrypt_count = log.lines().filter(|l| *l == "encrypt").count(); + assert_eq!( + encrypt_count, 2, + "expected 2 entries re-encrypted, got: {encrypt_count}" + ); + + assert_eq!( + fs::read_to_string(store.path().join("entry.gpg")).expect("entry"), + "secret\n", + "content should be preserved after re-encryption" + ); + assert_eq!( + fs::read_to_string(store.path().join("subdir/nested.gpg")).expect("nested entry"), + "nested-secret\n", + "nested content should be preserved after re-encryption" + ); +} + +#[test] +fn init_skips_entries_in_subdirectory_with_own_gpg_id() { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file(store.path().join(".gpg-id"), "old@example.invalid\n"); + write_file(store.path().join("team/.gpg-id"), "team@example.invalid\n"); + write_file(store.path().join("entry.gpg"), "root-secret\n"); + write_file(store.path().join("team/entry.gpg"), "team-secret\n"); + + let (gpg, log_file) = reencrypting_gpg_script(store.path()); + + rpass() + .env("PASSWORD_STORE_GPG", &gpg) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "init", + "new@example.invalid", + ]) + .assert() + .success(); + + let log = fs::read_to_string(&log_file).expect("log file"); + let encrypt_count = log.lines().filter(|l| *l == "encrypt").count(); + assert_eq!( + encrypt_count, 1, + "only the root entry should be re-encrypted, not the team entry with its own .gpg-id; log: {log}" + ); +} + +#[test] +fn init_re_encrypts_only_entries_in_target_subfolder() { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file(store.path().join(".gpg-id"), "root@example.invalid\n"); + write_file( + store.path().join("team/.gpg-id"), + "old-team@example.invalid\n", + ); + write_file(store.path().join("root-entry.gpg"), "root-secret\n"); + write_file(store.path().join("team/entry.gpg"), "team-secret\n"); + + let (gpg, log_file) = reencrypting_gpg_script(store.path()); + + rpass() + .env("PASSWORD_STORE_GPG", &gpg) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "init", + "--path", + "team", + "new-team@example.invalid", + ]) + .assert() + .success(); + + let log = fs::read_to_string(&log_file).expect("log file"); + let encrypt_count = log.lines().filter(|l| *l == "encrypt").count(); + assert_eq!( + encrypt_count, 1, + "only team/entry.gpg should be re-encrypted; log: {log}" + ); + assert!( + log.contains("recipient:new-team@example.invalid"), + "new team recipient should be used; log: {log}" + ); +} + +#[test] +fn init_skips_re_encryption_when_store_has_no_entries() { + let store = tempfile::TempDir::new().expect("temp dir"); + + let (gpg, log_file) = reencrypting_gpg_script(store.path()); + + rpass() + .env("PASSWORD_STORE_GPG", &gpg) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "init", + "alice@example.invalid", + ]) + .assert() + .success() + .stdout("Password store initialized for alice@example.invalid\n"); + + let log = fs::read_to_string(&log_file).unwrap_or_default(); + assert!( + log.is_empty(), + "no re-encryption should happen with empty store; log: {log}" + ); +} + fn git(path: &Path, args: [&str; N]) { let status = std::process::Command::new("git") .arg("-C") diff --git a/tests/recipients_command.rs b/tests/recipients_command.rs index cbfaf50..e0b47da 100644 --- a/tests/recipients_command.rs +++ b/tests/recipients_command.rs @@ -6,7 +6,7 @@ use std::path::Path; use predicates::prelude::*; use serde_json::Value; -use support::rpass; +use support::{reencrypting_gpg_script, rpass}; #[test] fn lists_root_recipients() { @@ -246,6 +246,112 @@ fn add_auto_commits_when_store_is_git_repository() { ); } +#[test] +fn add_re_encrypts_existing_entries_with_new_recipient() { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file(store.path().join(".gpg-id"), "alice@example.invalid\n"); + write_file(store.path().join("entry.gpg"), "secret\n"); + + let (gpg, log_file) = reencrypting_gpg_script(store.path()); + + rpass() + .env("PASSWORD_STORE_GPG", &gpg) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "recipients", + "add", + "bob@example.invalid", + ]) + .assert() + .success() + .stdout("Recipient 'bob@example.invalid' added\n") + .stderr(""); + + let log = fs::read_to_string(&log_file).expect("log file"); + assert!( + log.contains("recipient:alice@example.invalid"), + "alice should still be a recipient; log: {log}" + ); + assert!( + log.contains("recipient:bob@example.invalid"), + "bob should be a recipient after add; log: {log}" + ); + let encrypt_count = log.lines().filter(|l| *l == "encrypt").count(); + assert_eq!( + encrypt_count, 1, + "one entry should be re-encrypted; log: {log}" + ); +} + +#[test] +fn remove_re_encrypts_existing_entries_without_removed_recipient() { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file( + store.path().join(".gpg-id"), + "alice@example.invalid\nbob@example.invalid\n", + ); + write_file(store.path().join("entry.gpg"), "secret\n"); + + let (gpg, log_file) = reencrypting_gpg_script(store.path()); + + rpass() + .env("PASSWORD_STORE_GPG", &gpg) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "recipients", + "remove", + "bob@example.invalid", + ]) + .assert() + .success() + .stdout("Recipient 'bob@example.invalid' removed\n") + .stderr(""); + + let log = fs::read_to_string(&log_file).expect("log file"); + assert!( + log.contains("recipient:alice@example.invalid"), + "alice should still be a recipient; log: {log}" + ); + assert!( + !log.contains("recipient:bob@example.invalid"), + "bob should NOT be a recipient after remove; log: {log}" + ); + let encrypt_count = log.lines().filter(|l| *l == "encrypt").count(); + assert_eq!( + encrypt_count, 1, + "one entry should be re-encrypted; log: {log}" + ); +} + +#[test] +fn add_skips_re_encryption_when_recipient_already_present() { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file(store.path().join(".gpg-id"), "alice@example.invalid\n"); + write_file(store.path().join("entry.gpg"), "secret\n"); + + let (gpg, log_file) = reencrypting_gpg_script(store.path()); + + rpass() + .env("PASSWORD_STORE_GPG", &gpg) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "recipients", + "add", + "alice@example.invalid", + ]) + .assert() + .success(); + + let log = fs::read_to_string(&log_file).unwrap_or_default(); + assert!( + log.is_empty(), + "no re-encryption when recipient already present; log: {log}" + ); +} + fn git(path: &Path, args: [&str; N]) { let status = std::process::Command::new("git") .arg("-C") diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 313a33e..02c4e5e 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -497,6 +497,117 @@ pub fn failing_gpg_script(directory: &Path, message: &str) -> PathBuf { script } +#[cfg(windows)] +#[allow(dead_code)] +pub fn reencrypting_gpg_script(directory: &Path) -> (PathBuf, PathBuf) { + let script = directory.join("gpg-reencrypt.cmd"); + let log_file = directory.join("gpg-reencrypt-log.txt"); + + fs::write( + &script, + format!( + r#"@echo off +setlocal enabledelayedexpansion +set log_file={log} +set mode=encrypt +set output= +set input_file= +:args +if "%~1"=="" goto run +if "%~1"=="--decrypt" ( + set mode=decrypt + shift + goto args +) +if "%~1"=="--recipient" ( + echo recipient:%~2>> "%log_file%" + shift + shift + goto args +) +if "%~1"=="--output" ( + set output=%~2 + shift + shift + goto args +) +set arg=%~1 +if "!arg:~0,1!"=="-" ( + shift + goto args +) +set input_file=%~1 +shift +goto args +:run +if "%mode%"=="decrypt" ( + type "%input_file%" + exit /b 0 +) +echo encrypt>> "%log_file%" +findstr /r ".*" > "%output%" +exit /b 0 +"#, + log = log_file.display() + ), + ) + .expect("script"); + (script, log_file) +} + +#[cfg(not(windows))] +#[allow(dead_code)] +pub fn reencrypting_gpg_script(directory: &Path) -> (PathBuf, PathBuf) { + let script = directory.join("gpg-reencrypt"); + let log_file = directory.join("gpg-reencrypt-log.txt"); + + fs::write( + &script, + format!( + r#"#!/bin/sh +set -eu +log_file='{log}' +mode='encrypt' +output='' +input_file='' +while [ "$#" -gt 0 ]; do + case "$1" in + --decrypt) + mode='decrypt' + shift + ;; + --recipient) + printf 'recipient:%s\n' "$2" >> "$log_file" + shift 2 + ;; + --output) + output="$2" + shift 2 + ;; + -*) + shift + ;; + *) + input_file="$1" + shift + ;; + esac +done +if [ "$mode" = "decrypt" ]; then + cat "$input_file" + exit 0 +fi +printf 'encrypt\n' >> "$log_file" +cat > "$output" +"#, + log = log_file.display() + ), + ) + .expect("script"); + make_executable(&script); + (script, log_file) +} + #[cfg(not(windows))] fn make_executable(path: &Path) { use std::os::unix::fs::PermissionsExt;