Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <args>`) 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

Expand Down
25 changes: 24 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions src/password_store/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
186 changes: 186 additions & 0 deletions src/password_store/re_encrypt_entries.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<PathBuf>, 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<PathBuf>,
) -> 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());
}
}
Loading
Loading