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
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,11 @@ The crates.io package is `rpass-cli`; the installed binary is `rpass`.

## Status

`rpass` can list, search, show, generate, insert, edit, remove, move, and run
Git commands for password-store entries using external GnuPG. It also supports
TOTP generation from `otpauth://` lines.
`rpass` can initialize, list, search, show, generate, insert, edit, remove,
move, and run Git commands for password-store entries using external GnuPG. It
also supports TOTP generation from `otpauth://` lines.

Commands such as clipboard support and store initialization are intentionally
not implemented yet.
Commands such as clipboard support are intentionally not implemented yet.

## Commands

Expand All @@ -84,6 +83,7 @@ rpass -h # show help
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 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 @@ -95,6 +95,9 @@ rpass otp example/login # generate an OTP code
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.

`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
GPG. Use `--length <N>` with `--dry-run` when no entry name is provided.
Expand Down Expand Up @@ -154,8 +157,8 @@ Known differences from `pass`:

- write support is limited to `generate`, `insert`, `edit`, `rm`, and `mv`;
- Git integration is explicit through `rpass git <args...>`;
- shell completion, clipboard, QR code, and store initialization are not
implemented;
- changing recipients with `init` does not re-encrypt existing entries yet;
- shell completion, clipboard, and QR code are not implemented;
- unsupported `pass` flags are rejected instead of ignored;
- JSON output is an `rpass` integration contract, not part of the original
`pass` CLI.
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Goal: provide explicit, predictable Git commands for password-store workflows.

Goal: support creation and maintenance of password-store compatible stores.

- [ ] `rpass init <key-id>`
- [x] `rpass init <key-id>`
- [ ] `rpass recipients`
- [ ] `rpass recipients add <key-id>`
- [ ] `rpass recipients remove <key-id>`
Expand Down
111 changes: 109 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ use crate::password_generator::{
max_passphrase_words, max_password_length,
};
use crate::password_store::{
DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, InsertEntry, ListEntries,
MoveEntry, OtpCode, PasswordStore, RemoveEntry, SearchEntries, ShowEntry, StoreDirectory,
DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, InitStore, InitStoreResult,
InsertEntry, ListEntries, MoveEntry, OtpCode, PasswordStore, RemoveEntry, SearchEntries,
ShowEntry, StoreDirectory,
};
use tree_output::EntryTree;

Expand Down Expand Up @@ -59,6 +60,9 @@ enum Command {
#[command(about = "Show a password store entry")]
Show(ShowCommand),

#[command(about = "Initialize a password store or subfolder")]
Init(InitCommand),

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

Expand Down Expand Up @@ -104,6 +108,18 @@ struct ShowCommand {
json: bool,
}

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

#[arg(long)]
json: bool,

#[arg(value_name = "GPG-ID", required = true)]
gpg_ids: Vec<String>,
}

#[derive(Debug, Parser)]
struct InsertCommand {
entry: String,
Expand Down Expand Up @@ -307,6 +323,7 @@ pub fn run() -> Result<(), CliError> {
let result = match cli.command {
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::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 @@ -355,6 +372,7 @@ impl Command {
match self {
Self::List(command) => command.json,
Self::Show(command) => command.json,
Self::Init(command) => command.json,
Self::Insert(command) => command.json,
Self::Edit(command) => command.json,
Self::Remove(command) => command.json,
Expand Down Expand Up @@ -392,6 +410,84 @@ fn print_json_entries(entries: &[String]) -> Result<(), CliError> {
Ok(())
}

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

let result = InitStore::new(store_directory.clone())
.execute(command.path.as_deref(), &command.gpg_ids)?;
let store = PasswordStore::open(store_directory)?;
auto_commit(&store, &init_commit_message(&command, &result))?;

if command.json {
print_json_init(&result)?;
} else if result.removed {
print_init_removed(&command);
} else {
print_init_success(&command);
}

Ok(())
}

fn validate_init_path(path: Option<&str>) -> Result<(), CliError> {
let Some(path) = path else {
return Ok(());
};

if path.is_empty()
|| path.contains('\\')
|| path.split('/').any(|segment| segment.is_empty())
|| path.split('/').any(|segment| matches!(segment, "." | ".."))
{
return Err(CliError::InvalidInitPath(path.to_owned()));
}

Ok(())
}

fn init_commit_message(command: &InitCommand, result: &InitStoreResult) -> String {
if result.removed {
match command.path.as_deref() {
Some(path) => format!("Removed GPG id from {path}."),
None => "Removed GPG id from store.".to_string(),
}
} else {
format!("Set GPG id to {}.", command.gpg_ids.join(", "))
}
}

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

fn print_init_success(command: &InitCommand) {
let recipients = command.gpg_ids.join(", ");
match command.path.as_deref() {
Some(path) => println!("Password store initialized for {recipients} ({path})"),
None => println!("Password store initialized for {recipients}"),
}
}

fn print_init_removed(command: &InitCommand) {
match command.path.as_deref() {
Some(path) => println!("Password store recipients removed ({path})"),
None => println!("Password store recipients removed"),
}
}

fn normalize_path(path: &std::path::Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}

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 @@ -848,6 +944,13 @@ struct ShowEntryJson<'entry> {
extra_lines: &'entry [String],
}

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

#[derive(Debug, Serialize)]
struct InsertJson<'entry> {
name: &'entry str,
Expand Down Expand Up @@ -920,6 +1023,9 @@ pub enum CliError {
#[error("passphrase word count must be between {min} and {max}")]
InvalidGenerateWordCount { min: usize, max: usize },

#[error("invalid init path '{0}'")]
InvalidInitPath(String),

#[error("entry is required unless --dry-run is used")]
GenerateEntryRequired,

Expand Down Expand Up @@ -960,6 +1066,7 @@ impl CliError {
Self::PasswordGenerator(_) => "password_generation_failed",
Self::InvalidGenerateLength { .. } => "invalid_generate_length",
Self::InvalidGenerateWordCount { .. } => "invalid_generate_word_count",
Self::InvalidInitPath(_) => "invalid_init_path",
Self::GenerateEntryRequired => "generate_entry_required",
Self::PasswordConfirmationMismatch => "password_confirmation_mismatch",
Self::RemoveConfirmationRequired => "remove_confirmation_required",
Expand Down
67 changes: 67 additions & 0 deletions src/password_store/init_store.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use std::fs;
use std::path::{Path, PathBuf};

use super::{PasswordStoreError, StoreDirectory};

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

pub struct InitStore {
store_directory: StoreDirectory,
}

impl InitStore {
pub fn new(store_directory: StoreDirectory) -> Self {
Self { store_directory }
}

pub fn execute(
&self,
subfolder: Option<&str>,
recipients: &[String],
) -> Result<InitStoreResult, PasswordStoreError> {
let store_root = self.store_directory.path();
fs::create_dir_all(store_root)?;

let target_directory = match subfolder {
Some(subfolder) => store_root.join(subfolder),
None => store_root.to_path_buf(),
};
fs::create_dir_all(&target_directory)?;

let gpg_id_path = target_directory.join(".gpg-id");
let removed = recipients.len() == 1 && recipients.first().is_some_and(String::is_empty);

if removed {
remove_gpg_id_if_present(&gpg_id_path)?;
} else {
fs::write(&gpg_id_path, recipients.join("\n") + "\n")?;
}

Ok(InitStoreResult {
gpg_id_path: relative_path(store_root, &gpg_id_path),
recipients: if removed {
Vec::new()
} else {
recipients.to_vec()
},
removed,
})
}
}

fn remove_gpg_id_if_present(path: &Path) -> Result<(), PasswordStoreError> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}

fn relative_path(root: &Path, path: &Path) -> PathBuf {
path.strip_prefix(root).unwrap_or(path).to_path_buf()
}
2 changes: 2 additions & 0 deletions src/password_store/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod edit_entry;
mod entry_name;
mod git;
mod gpg;
mod init_store;
mod insert_entry;
mod list_entries;
mod move_entry;
Expand All @@ -19,6 +20,7 @@ pub use edit_entry::EditEntry;
pub use entry_name::EntryName;
pub use git::GitCommand;
pub use gpg::GpgCommand;
pub use init_store::{InitStore, InitStoreResult};
pub use insert_entry::InsertEntry;
pub use list_entries::ListEntries;
pub use move_entry::MoveEntry;
Expand Down
Loading
Loading