Skip to content
Merged
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
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
71 changes: 71 additions & 0 deletions src/password_store/re_encrypt_entries.rs
Original file line number Diff line number Diff line change
@@ -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<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,
recipients,
passphrase,
&mut reencrypted,
)?;
Ok(reencrypted)
}
}

fn reencrypt_dir(
gpg: &GpgCommand,
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() {
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(())
}
139 changes: 138 additions & 1 deletion tests/init_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<const N: usize>(path: &Path, args: [&str; N]) {
let status = std::process::Command::new("git")
.arg("-C")
Expand Down
Loading
Loading