From 0cd3d0674c6e7357d90c571f8020d62eb0a8afdc Mon Sep 17 00:00:00 2001 From: Cristhian Melo Date: Mon, 20 Jul 2026 11:21:37 -0500 Subject: [PATCH 1/4] feat(password_store): re-encrypt entries when recipients change on init and recipients commands --- src/cli.rs | 25 ++- src/password_store/mod.rs | 2 + src/password_store/re_encrypt_entries.rs | 186 +++++++++++++++++++++++ 3 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 src/password_store/re_encrypt_entries.rs 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..25a4dd8 --- /dev/null +++ b/src/password_store/re_encrypt_entries.rs @@ -0,0 +1,186 @@ +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, &target_dir, recipients, passphrase, &mut reencrypted)?; + Ok(reencrypted) + } +} + +fn reencrypt_dir( + gpg: &GpgCommand, + dir: &Path, + target_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() { + // This subdir manages its own recipients โ€” skip it entirely + continue; + } + reencrypt_dir(gpg, &path, target_dir, recipients, passphrase, reencrypted)?; + } else if path.extension().map_or(false, |ext| ext == "gpg") { + let plaintext = gpg.decrypt(&path, passphrase)?; + gpg.encrypt(&plaintext, &path, recipients)?; + reencrypted.push(path); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::ReEncryptEntries; + use crate::password_store::{GpgCommand, PasswordStore, StoreDirectory}; + + fn store_with_files(files: &[(&str, &str)]) -> TempDir { + let dir = TempDir::new().expect("temp dir"); + for (path, content) in files { + let full_path = dir.path().join(path); + fs::create_dir_all(full_path.parent().expect("parent")).expect("create dir"); + fs::write(&full_path, content).expect("write file"); + } + dir + } + + #[cfg(not(windows))] + fn passthrough_gpg_script(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) { + use std::os::unix::fs::PermissionsExt; + + let script = dir.join("gpg-test"); + let log = dir.join("gpg-test-log.txt"); + + fs::write( + &script, + format!( + r#"#!/bin/sh +set -eu +log='{log}' +mode='encrypt' +output='' +input_file='' +while [ "$#" -gt 0 ]; do + case "$1" in + --decrypt) mode='decrypt'; shift ;; + --recipient) printf 'recipient:%s\n' "$2" >> "$log"; 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" +cat > "$output" +"#, + log = log.display() + ), + ) + .expect("write script"); + + let mut perms = fs::metadata(&script).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script, perms).expect("set permissions"); + + (script, log) + } + + #[test] + #[cfg(not(windows))] + fn re_encrypts_gpg_files_in_store() { + let temp = store_with_files(&[ + (".gpg-id", "alice\n"), + ("entry.gpg", "secret\n"), + ]); + let (script, log) = passthrough_gpg_script(temp.path()); + let store = PasswordStore::open(StoreDirectory::from_path(temp.path())).expect("store"); + let gpg = GpgCommand::new(script); + + let result = ReEncryptEntries::new(&store, gpg) + .execute(None, &["alice".to_string()], None) + .expect("re-encrypt"); + + assert_eq!(result.len(), 1); + let log_content = fs::read_to_string(log).expect("log"); + assert!(log_content.contains("recipient:alice")); + assert_eq!(fs::read_to_string(temp.path().join("entry.gpg")).expect("entry"), "secret\n"); + } + + #[test] + #[cfg(not(windows))] + fn skips_entries_in_subdir_with_own_gpg_id() { + let temp = store_with_files(&[ + (".gpg-id", "alice\n"), + ("team/.gpg-id", "team\n"), + ("entry.gpg", "root\n"), + ("team/entry.gpg", "team-secret\n"), + ]); + let (script, log) = passthrough_gpg_script(temp.path()); + let store = PasswordStore::open(StoreDirectory::from_path(temp.path())).expect("store"); + let gpg = GpgCommand::new(script); + + let result = ReEncryptEntries::new(&store, gpg) + .execute(None, &["alice".to_string()], None) + .expect("re-encrypt"); + + assert_eq!(result.len(), 1, "only root entry should be re-encrypted"); + let log_content = fs::read_to_string(log).expect("log"); + assert_eq!(log_content.lines().filter(|l| *l == "encrypt").count(), 1); + } + + #[test] + #[cfg(not(windows))] + fn returns_empty_when_no_gpg_files() { + let temp = store_with_files(&[(".gpg-id", "alice\n")]); + let (script, _) = passthrough_gpg_script(temp.path()); + let store = PasswordStore::open(StoreDirectory::from_path(temp.path())).expect("store"); + let gpg = GpgCommand::new(script); + + let result = ReEncryptEntries::new(&store, gpg) + .execute(None, &["alice".to_string()], None) + .expect("re-encrypt"); + + assert!(result.is_empty()); + } +} From b50b2fcfe4a3750d0f798355f81912117eed9d89 Mon Sep 17 00:00:00 2001 From: Cristhian Melo Date: Mon, 20 Jul 2026 11:21:45 -0500 Subject: [PATCH 2/4] test: add integration tests for re-encryption on init and recipients add/remove --- tests/init_command.rs | 130 +++++++++++++++++++++++++++++++++++- tests/recipients_command.rs | 99 ++++++++++++++++++++++++++- tests/support/mod.rs | 111 ++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+), 2 deletions(-) diff --git a/tests/init_command.rs b/tests/init_command.rs index ca69c20..abc5b5c 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,134 @@ 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..1f31d24 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,103 @@ 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; From 61c9ba9f85a41c072fd6dc28f3f65416e2a8b40c Mon Sep 17 00:00:00 2001 From: Cristhian Melo Date: Mon, 20 Jul 2026 11:21:51 -0500 Subject: [PATCH 3/4] docs: remove outdated known-difference about init not re-encrypting --- README.md | 1 - 1 file changed, 1 deletion(-) 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 From 145c5b9f4720eb876a6dd929af49dbc353f536b6 Mon Sep 17 00:00:00 2001 From: Cristhian Melo Date: Mon, 20 Jul 2026 11:24:44 -0500 Subject: [PATCH 4/4] chore: release v0.1.17 --- CHANGELOG.md | 18 ++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0580539..994e612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. This project uses [Conventional Commits](https://www.conventionalcommits.org/) and release automation with release-plz. +## [0.1.17](https://github.com/rxtsel/rpass-cli/compare/v0.1.16...v0.1.17) - 2026-07-20 + +### ๐Ÿš€ Features + +- *(password_store)* Re-encrypt entries when recipients change on init and recipients commands + + +### ๐Ÿ“š Documentation + +- Remove outdated known-difference about init not re-encrypting + + +### ๐Ÿงช Testing + +- Add integration tests for re-encryption on init and recipients add/remove + + + ## [0.1.16](https://github.com/rxtsel/rpass-cli/compare/v0.1.15...v0.1.16) - 2026-06-20 ### ๐Ÿš€ Features diff --git a/Cargo.lock b/Cargo.lock index eeb2451..0c25458 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -788,7 +788,7 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "rpass-cli" -version = "0.1.16" +version = "0.1.17" dependencies = [ "assert_cmd", "clap", diff --git a/Cargo.toml b/Cargo.toml index 65fc1b2..6c78b0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rpass-cli" -version = "0.1.16" +version = "0.1.17" edition = "2024" authors = ["rxtsel"] description = "Cross-platform pass-compatible CLI for GPG-encrypted secrets"