Skip to content

Commit dd1fa97

Browse files
authored
Merge pull request #44 from rxtsel/feat/re-encrypt-on-recipient-change
Feat/re encrypt on recipient change
2 parents 34ca598 + 8b576bd commit dd1fa97

7 files changed

Lines changed: 453 additions & 4 deletions

File tree

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ rpass completions powershell >> $PROFILE
113113
**Known differences from `pass`:**
114114
- `generate`, `insert`, `edit`, `rm`, and `mv` for writes
115115
- Git is explicit (`rpass git <args>`) rather than automatic
116-
- Changing recipients with `init` does not re-encrypt existing entries
117116
- Clipboard and QR codes are not implemented
118117
- Unsupported `pass` flags are rejected instead of ignored
119118

src/cli.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ use crate::password_store::importer::bitwarden::BitwardenImporter;
1818
use crate::password_store::{
1919
DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, ImportEntries, ImportResult,
2020
InitStore, InitStoreResult, InsertEntry, ListEntries, MoveEntry, OtpCode, PasswordStore,
21-
Recipients, RecipientsResult, RemoveEntry, SearchEntries, ShowEntry, StoreDirectory,
21+
ReEncryptEntries, Recipients, RecipientsResult, RemoveEntry, SearchEntries, ShowEntry,
22+
StoreDirectory,
2223
};
2324
use tree_output::EntryTree;
2425

@@ -496,6 +497,16 @@ fn init_store(command: InitCommand, store_directory: StoreDirectory) -> Result<(
496497
let result = InitStore::new(store_directory.clone())
497498
.execute(command.path.as_deref(), &command.gpg_ids)?;
498499
let store = PasswordStore::open(store_directory)?;
500+
501+
if !result.removed && !result.recipients.is_empty() {
502+
let gpg = GpgCommand::from_environment();
503+
ReEncryptEntries::new(&store, gpg).execute(
504+
command.path.as_deref(),
505+
&result.recipients,
506+
None,
507+
)?;
508+
}
509+
499510
auto_commit(&store, &init_commit_message(&command, &result))?;
500511

501512
if command.json {
@@ -577,12 +588,24 @@ fn recipients(command: RecipientsCommand, store_directory: StoreDirectory) -> Re
577588
Some(RecipientsAction::Add { key_id }) => {
578589
let result = recipients.add(command.path.as_deref(), key_id)?;
579590
if result.changed {
591+
let gpg = GpgCommand::from_environment();
592+
ReEncryptEntries::new(&store, gpg).execute(
593+
command.path.as_deref(),
594+
&result.recipients,
595+
None,
596+
)?;
580597
auto_commit(&store, &format!("Added GPG id {key_id}."))?;
581598
}
582599
result
583600
}
584601
Some(RecipientsAction::Remove { key_id }) => {
585602
let result = recipients.remove(command.path.as_deref(), key_id)?;
603+
let gpg = GpgCommand::from_environment();
604+
ReEncryptEntries::new(&store, gpg).execute(
605+
command.path.as_deref(),
606+
&result.recipients,
607+
None,
608+
)?;
586609
auto_commit(&store, &format!("Removed GPG id {key_id}."))?;
587610
result
588611
}

src/password_store/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod insert_entry;
1111
mod list_entries;
1212
mod move_entry;
1313
mod otp;
14+
mod re_encrypt_entries;
1415
mod recipients;
1516
mod remove_entry;
1617
mod search_entries;
@@ -29,6 +30,7 @@ pub use insert_entry::InsertEntry;
2930
pub use list_entries::ListEntries;
3031
pub use move_entry::MoveEntry;
3132
pub use otp::OtpCode;
33+
pub use re_encrypt_entries::ReEncryptEntries;
3234
pub use recipients::{Recipients, RecipientsResult};
3335
pub use remove_entry::RemoveEntry;
3436
pub use search_entries::SearchEntries;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
use std::fs;
2+
use std::path::{Path, PathBuf};
3+
4+
use super::{GpgCommand, PasswordStore, PasswordStoreError};
5+
6+
pub struct ReEncryptEntries<'store> {
7+
store: &'store PasswordStore,
8+
gpg: GpgCommand,
9+
}
10+
11+
impl<'store> ReEncryptEntries<'store> {
12+
pub fn new(store: &'store PasswordStore, gpg: GpgCommand) -> Self {
13+
Self { store, gpg }
14+
}
15+
16+
pub fn execute(
17+
&self,
18+
subfolder: Option<&str>,
19+
recipients: &[String],
20+
passphrase: Option<&str>,
21+
) -> Result<Vec<PathBuf>, PasswordStoreError> {
22+
if recipients.is_empty() {
23+
return Ok(Vec::new());
24+
}
25+
26+
let target_dir = match subfolder {
27+
Some(sub) => self.store.path().join(sub),
28+
None => self.store.path().to_path_buf(),
29+
};
30+
31+
let mut reencrypted = Vec::new();
32+
reencrypt_dir(
33+
&self.gpg,
34+
&target_dir,
35+
recipients,
36+
passphrase,
37+
&mut reencrypted,
38+
)?;
39+
Ok(reencrypted)
40+
}
41+
}
42+
43+
fn reencrypt_dir(
44+
gpg: &GpgCommand,
45+
dir: &Path,
46+
recipients: &[String],
47+
passphrase: Option<&str>,
48+
reencrypted: &mut Vec<PathBuf>,
49+
) -> Result<(), PasswordStoreError> {
50+
let entries = match fs::read_dir(dir) {
51+
Ok(e) => e,
52+
Err(_) => return Ok(()),
53+
};
54+
55+
for entry in entries.flatten() {
56+
let path = entry.path();
57+
58+
if path.is_dir() {
59+
if path.join(".gpg-id").exists() {
60+
continue;
61+
}
62+
reencrypt_dir(gpg, &path, recipients, passphrase, reencrypted)?;
63+
} else if path.extension().is_some_and(|ext| ext == "gpg") {
64+
let plaintext = gpg.decrypt(&path, passphrase)?;
65+
gpg.encrypt(&plaintext, &path, recipients)?;
66+
reencrypted.push(path);
67+
}
68+
}
69+
70+
Ok(())
71+
}

tests/init_command.rs

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::path::Path;
66
use predicates::prelude::*;
77
use serde_json::Value;
88

9-
use support::rpass;
9+
use support::{reencrypting_gpg_script, rpass};
1010

1111
#[test]
1212
fn init_creates_missing_store_and_writes_gpg_id() {
@@ -196,6 +196,143 @@ fn init_auto_commits_when_store_is_git_repository() {
196196
);
197197
}
198198

199+
#[test]
200+
fn init_re_encrypts_existing_entries_with_new_recipients() {
201+
let store = tempfile::TempDir::new().expect("temp dir");
202+
write_file(store.path().join(".gpg-id"), "[email protected]\n");
203+
write_file(store.path().join("entry.gpg"), "secret\n");
204+
write_file(store.path().join("subdir/nested.gpg"), "nested-secret\n");
205+
206+
let (gpg, log_file) = reencrypting_gpg_script(store.path());
207+
208+
rpass()
209+
.env("PASSWORD_STORE_GPG", &gpg)
210+
.args([
211+
"--store-dir",
212+
store.path().to_str().expect("store path"),
213+
"init",
214+
215+
])
216+
.assert()
217+
.success()
218+
.stdout("Password store initialized for [email protected]\n")
219+
.stderr("");
220+
221+
let log = fs::read_to_string(&log_file).expect("log file");
222+
assert!(
223+
log.contains("recipient:[email protected]"),
224+
"expected new recipient in log, got: {log}"
225+
);
226+
let encrypt_count = log.lines().filter(|l| *l == "encrypt").count();
227+
assert_eq!(
228+
encrypt_count, 2,
229+
"expected 2 entries re-encrypted, got: {encrypt_count}"
230+
);
231+
232+
assert_eq!(
233+
fs::read_to_string(store.path().join("entry.gpg")).expect("entry"),
234+
"secret\n",
235+
"content should be preserved after re-encryption"
236+
);
237+
assert_eq!(
238+
fs::read_to_string(store.path().join("subdir/nested.gpg")).expect("nested entry"),
239+
"nested-secret\n",
240+
"nested content should be preserved after re-encryption"
241+
);
242+
}
243+
244+
#[test]
245+
fn init_skips_entries_in_subdirectory_with_own_gpg_id() {
246+
let store = tempfile::TempDir::new().expect("temp dir");
247+
write_file(store.path().join(".gpg-id"), "[email protected]\n");
248+
write_file(store.path().join("team/.gpg-id"), "[email protected]\n");
249+
write_file(store.path().join("entry.gpg"), "root-secret\n");
250+
write_file(store.path().join("team/entry.gpg"), "team-secret\n");
251+
252+
let (gpg, log_file) = reencrypting_gpg_script(store.path());
253+
254+
rpass()
255+
.env("PASSWORD_STORE_GPG", &gpg)
256+
.args([
257+
"--store-dir",
258+
store.path().to_str().expect("store path"),
259+
"init",
260+
261+
])
262+
.assert()
263+
.success();
264+
265+
let log = fs::read_to_string(&log_file).expect("log file");
266+
let encrypt_count = log.lines().filter(|l| *l == "encrypt").count();
267+
assert_eq!(
268+
encrypt_count, 1,
269+
"only the root entry should be re-encrypted, not the team entry with its own .gpg-id; log: {log}"
270+
);
271+
}
272+
273+
#[test]
274+
fn init_re_encrypts_only_entries_in_target_subfolder() {
275+
let store = tempfile::TempDir::new().expect("temp dir");
276+
write_file(store.path().join(".gpg-id"), "[email protected]\n");
277+
write_file(
278+
store.path().join("team/.gpg-id"),
279+
280+
);
281+
write_file(store.path().join("root-entry.gpg"), "root-secret\n");
282+
write_file(store.path().join("team/entry.gpg"), "team-secret\n");
283+
284+
let (gpg, log_file) = reencrypting_gpg_script(store.path());
285+
286+
rpass()
287+
.env("PASSWORD_STORE_GPG", &gpg)
288+
.args([
289+
"--store-dir",
290+
store.path().to_str().expect("store path"),
291+
"init",
292+
"--path",
293+
"team",
294+
295+
])
296+
.assert()
297+
.success();
298+
299+
let log = fs::read_to_string(&log_file).expect("log file");
300+
let encrypt_count = log.lines().filter(|l| *l == "encrypt").count();
301+
assert_eq!(
302+
encrypt_count, 1,
303+
"only team/entry.gpg should be re-encrypted; log: {log}"
304+
);
305+
assert!(
306+
log.contains("recipient:[email protected]"),
307+
"new team recipient should be used; log: {log}"
308+
);
309+
}
310+
311+
#[test]
312+
fn init_skips_re_encryption_when_store_has_no_entries() {
313+
let store = tempfile::TempDir::new().expect("temp dir");
314+
315+
let (gpg, log_file) = reencrypting_gpg_script(store.path());
316+
317+
rpass()
318+
.env("PASSWORD_STORE_GPG", &gpg)
319+
.args([
320+
"--store-dir",
321+
store.path().to_str().expect("store path"),
322+
"init",
323+
324+
])
325+
.assert()
326+
.success()
327+
.stdout("Password store initialized for [email protected]\n");
328+
329+
let log = fs::read_to_string(&log_file).unwrap_or_default();
330+
assert!(
331+
log.is_empty(),
332+
"no re-encryption should happen with empty store; log: {log}"
333+
);
334+
}
335+
199336
fn git<const N: usize>(path: &Path, args: [&str; N]) {
200337
let status = std::process::Command::new("git")
201338
.arg("-C")

0 commit comments

Comments
 (0)