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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ rpass list # list entries
rpass search example # search entries
rpass show example/login # show an entry explicitly
rpass init alice@example.com # initialize .gpg-id recipients
rpass recipients # list .gpg-id recipients
rpass recipients add bob@example.com # add a .gpg-id recipient
rpass recipients remove bob@example.com # remove a .gpg-id recipient
rpass generate example/login # generate and save a 14-character password
rpass insert example/login # insert a password interactively
rpass edit example/login # edit or create an entry
Expand All @@ -97,6 +100,8 @@ rpass doctor # check local setup

`init` creates the store if needed and writes `.gpg-id` recipients. Use
`--path <subfolder>` or `-p <subfolder>` for directory-level recipients.
`recipients` lists or updates an existing `.gpg-id` without retyping the full
recipient set.

`generate` writes to the store by default. Use `--dry-run` to print a generated
password or passphrase without opening the store, requiring `.gpg-id`, or calling
Expand Down
6 changes: 3 additions & 3 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,9 @@ Goal: provide explicit, predictable Git commands for password-store workflows.
Goal: support creation and maintenance of password-store compatible stores.

- [x] `rpass init <key-id>`
- [ ] `rpass recipients`
- [ ] `rpass recipients add <key-id>`
- [ ] `rpass recipients remove <key-id>`
- [x] `rpass recipients`
- [x] `rpass recipients add <key-id>`
- [x] `rpass recipients remove <key-id>`
- [ ] Support multiple stores only after the single-store model is stable.

## Phase 5: Compatibility Hardening
Expand Down
87 changes: 85 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ use crate::password_generator::{
};
use crate::password_store::{
DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, InitStore, InitStoreResult,
InsertEntry, ListEntries, MoveEntry, OtpCode, PasswordStore, RemoveEntry, SearchEntries,
ShowEntry, StoreDirectory,
InsertEntry, ListEntries, MoveEntry, OtpCode, PasswordStore, Recipients, RecipientsResult,
RemoveEntry, SearchEntries, ShowEntry, StoreDirectory,
};
use tree_output::EntryTree;

Expand Down Expand Up @@ -63,6 +63,9 @@ enum Command {
#[command(about = "Initialize a password store or subfolder")]
Init(InitCommand),

#[command(about = "List or modify password store recipients")]
Recipients(RecipientsCommand),

#[command(about = "Insert a password store entry")]
Insert(InsertCommand),

Expand Down Expand Up @@ -120,6 +123,27 @@ struct InitCommand {
gpg_ids: Vec<String>,
}

#[derive(Debug, Parser)]
struct RecipientsCommand {
#[arg(short = 'p', long = "path", value_name = "SUBFOLDER")]
path: Option<String>,

#[arg(long)]
json: bool,

#[command(subcommand)]
action: Option<RecipientsAction>,
}

#[derive(Debug, Subcommand)]
enum RecipientsAction {
#[command(about = "Add a recipient to .gpg-id")]
Add { key_id: String },

#[command(about = "Remove a recipient from .gpg-id")]
Remove { key_id: String },
}

#[derive(Debug, Parser)]
struct InsertCommand {
entry: String,
Expand Down Expand Up @@ -324,6 +348,7 @@ pub fn run() -> Result<(), CliError> {
Some(Command::List(command)) => list_entries(command, store_directory),
Some(Command::Show(command)) => show_entry(command, store_directory),
Some(Command::Init(command)) => init_store(command, store_directory),
Some(Command::Recipients(command)) => recipients(command, store_directory),
Some(Command::Insert(command)) => insert_entry(command, store_directory),
Some(Command::Edit(command)) => edit_entry(command, store_directory),
Some(Command::Remove(command)) => remove_entry(command, store_directory),
Expand Down Expand Up @@ -373,6 +398,7 @@ impl Command {
Self::List(command) => command.json,
Self::Show(command) => command.json,
Self::Init(command) => command.json,
Self::Recipients(command) => command.json,
Self::Insert(command) => command.json,
Self::Edit(command) => command.json,
Self::Remove(command) => command.json,
Expand Down Expand Up @@ -488,6 +514,57 @@ fn normalize_path(path: &std::path::Path) -> String {
.join("/")
}

fn recipients(command: RecipientsCommand, store_directory: StoreDirectory) -> Result<(), CliError> {
validate_init_path(command.path.as_deref())?;

let store = PasswordStore::open(store_directory)?;
let recipients = Recipients::new(&store);
let result = match &command.action {
Some(RecipientsAction::Add { key_id }) => {
let result = recipients.add(command.path.as_deref(), key_id)?;
if result.changed {
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)?;
auto_commit(&store, &format!("Removed GPG id {key_id}."))?;
result
}
None => recipients.list(command.path.as_deref())?,
};

if command.json {
print_json_recipients(&result)?;
} else {
print_text_recipients(&command, &result);
}

Ok(())
}

fn print_json_recipients(result: &RecipientsResult) -> Result<(), CliError> {
let json = serde_json::to_string_pretty(&RecipientsJson {
path: &normalize_path(&result.gpg_id_path),
recipients: &result.recipients,
})?;
println!("{json}");
Ok(())
}

fn print_text_recipients(command: &RecipientsCommand, result: &RecipientsResult) {
match &command.action {
Some(RecipientsAction::Add { key_id }) => println!("Recipient '{key_id}' added"),
Some(RecipientsAction::Remove { key_id }) => println!("Recipient '{key_id}' removed"),
None => {
for recipient in &result.recipients {
println!("{recipient}");
}
}
}
}

fn search_entries(command: SearchCommand, store_directory: StoreDirectory) -> Result<(), CliError> {
let store = PasswordStore::open(store_directory)?;
let entries = SearchEntries::new(&store).execute(&command.query)?;
Expand Down Expand Up @@ -951,6 +1028,12 @@ struct InitJson<'entry> {
removed: bool,
}

#[derive(Debug, Serialize)]
struct RecipientsJson<'entry> {
path: &'entry str,
recipients: &'entry [String],
}

#[derive(Debug, Serialize)]
struct InsertJson<'entry> {
name: &'entry str,
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 @@ -9,6 +9,7 @@ mod insert_entry;
mod list_entries;
mod move_entry;
mod otp;
mod recipients;
mod remove_entry;
mod search_entries;
mod show_entry;
Expand All @@ -25,6 +26,7 @@ pub use insert_entry::InsertEntry;
pub use list_entries::ListEntries;
pub use move_entry::MoveEntry;
pub use otp::OtpCode;
pub use recipients::{Recipients, RecipientsResult};
pub use remove_entry::RemoveEntry;
pub use search_entries::SearchEntries;
pub use show_entry::ShowEntry;
Expand Down
111 changes: 111 additions & 0 deletions src/password_store/recipients.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
use std::fs;
use std::path::{Path, PathBuf};

use super::{PasswordStore, PasswordStoreError};

#[derive(Debug)]
pub struct RecipientsResult {
pub gpg_id_path: PathBuf,
pub recipients: Vec<String>,
pub changed: bool,
}

pub struct Recipients<'store> {
store: &'store PasswordStore,
}

impl<'store> Recipients<'store> {
pub fn new(store: &'store PasswordStore) -> Self {
Self { store }
}

pub fn list(&self, subfolder: Option<&str>) -> Result<RecipientsResult, PasswordStoreError> {
let gpg_id_path = gpg_id_path(self.store.path(), subfolder);
let recipients = read_recipients(&gpg_id_path)?;

Ok(RecipientsResult {
gpg_id_path: relative_path(self.store.path(), &gpg_id_path),
recipients,
changed: false,
})
}

pub fn add(
&self,
subfolder: Option<&str>,
recipient: &str,
) -> Result<RecipientsResult, PasswordStoreError> {
let gpg_id_path = gpg_id_path(self.store.path(), subfolder);
let mut recipients = read_recipients(&gpg_id_path)?;
let changed = !recipients.iter().any(|existing| existing == recipient);

if changed {
recipients.push(recipient.to_owned());
write_recipients(&gpg_id_path, &recipients)?;
}

Ok(RecipientsResult {
gpg_id_path: relative_path(self.store.path(), &gpg_id_path),
recipients,
changed,
})
}

pub fn remove(
&self,
subfolder: Option<&str>,
recipient: &str,
) -> Result<RecipientsResult, PasswordStoreError> {
let gpg_id_path = gpg_id_path(self.store.path(), subfolder);
let mut recipients = read_recipients(&gpg_id_path)?;
let original_len = recipients.len();
recipients.retain(|existing| existing != recipient);

if recipients.len() == original_len {
return Err(PasswordStoreError::RecipientNotFound(recipient.to_owned()));
}

write_recipients(&gpg_id_path, &recipients)?;

Ok(RecipientsResult {
gpg_id_path: relative_path(self.store.path(), &gpg_id_path),
recipients,
changed: true,
})
}
}

fn gpg_id_path(store_root: &Path, subfolder: Option<&str>) -> PathBuf {
match subfolder {
Some(subfolder) => store_root.join(subfolder).join(".gpg-id"),
None => store_root.join(".gpg-id"),
}
}

fn read_recipients(path: &Path) -> Result<Vec<String>, PasswordStoreError> {
if !path.exists() {
return Err(PasswordStoreError::GpgIdNotFound);
}

let recipients = fs::read_to_string(path)?
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_owned)
.collect::<Vec<_>>();

if recipients.is_empty() {
return Err(PasswordStoreError::GpgIdNotFound);
}

Ok(recipients)
}

fn write_recipients(path: &Path, recipients: &[String]) -> Result<(), PasswordStoreError> {
fs::write(path, recipients.join("\n") + "\n")?;
Ok(())
}

fn relative_path(root: &Path, path: &Path) -> PathBuf {
path.strip_prefix(root).unwrap_or(path).to_path_buf()
}
4 changes: 4 additions & 0 deletions src/password_store/store_directory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ pub enum PasswordStoreError {
#[error("entry contains an invalid otpauth URI")]
InvalidOtpUri,

#[error("recipient not found: {0}")]
RecipientNotFound(String),

#[error("failed to access password store: {0}")]
Io(#[from] std::io::Error),
}
Expand Down Expand Up @@ -151,6 +154,7 @@ impl PasswordStoreError {
Self::GpgVersionFailed(_) => "gpg_version_failed",
Self::OtpNotFound => "otp_not_found",
Self::InvalidOtpUri => "invalid_otp_uri",
Self::RecipientNotFound(_) => "recipient_not_found",
Self::Io(_) => "io_error",
}
}
Expand Down
Loading
Loading