diff --git a/README.md b/README.md index b2b6df8..b73fc3a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ rpass git status - **Works everywhere** — Linux, macOS, Windows (no Bash dependency) - **password-store compatible** — reads and writes existing `.gpg` entries and `.gpg-id` files - **JSON output** — structured responses for Raycast, Vicinae, and custom integrations +- **Import** — migrate from other password managers ([docs](docs/import.md)) - **TOTP support** — generate one-time codes from `otpauth://` lines - **Git integration** — explicit `rpass git ` commands; write commands auto-commit - **Shell completions** — Bash, Zsh, Fish, PowerShell @@ -56,6 +57,7 @@ rpass insert example/login # insert a password rpass edit example/login # edit an entry rpass rm example/login # remove an entry rpass mv example/login archive/login # move/rename an entry +rpass import --bitwarden ~/export.json # import from Bitwarden rpass otp example/login # generate a TOTP code rpass git status # run git inside the store rpass doctor # check your local setup diff --git a/docs/import.md b/docs/import.md new file mode 100644 index 0000000..f5ce0b2 --- /dev/null +++ b/docs/import.md @@ -0,0 +1,17 @@ +# Import + +Import passwords from other password managers into your rpass store. + + rpass import --bitwarden + +## Bitwarden + +1. In Bitwarden, go to **Tools → Export vault** and select **File format: JSON** (unencrypted). +2. Run: `rpass import --bitwarden ~/bitwarden_export.json` + +| Flag | Description | +|---|---| +| `--force` | Overwrite existing entries | +| `--json` | JSON output | + +Supports individual and organization vaults. Folders and collections become path prefixes. All item types are handled: logins, secure notes, cards, and identities. diff --git a/src/cli.rs b/src/cli.rs index 2bfe99a..45aa35d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -14,10 +14,11 @@ use crate::password_generator::{ default_passphrase_words, default_password_length, generate_passphrase, generate_password, max_passphrase_words, max_password_length, }; +use crate::password_store::importer::bitwarden::BitwardenImporter; use crate::password_store::{ - DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, InitStore, InitStoreResult, - InsertEntry, ListEntries, MoveEntry, OtpCode, PasswordStore, Recipients, RecipientsResult, - RemoveEntry, SearchEntries, ShowEntry, StoreDirectory, + DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, ImportEntries, ImportResult, + InitStore, InitStoreResult, InsertEntry, ListEntries, MoveEntry, OtpCode, PasswordStore, + Recipients, RecipientsResult, RemoveEntry, SearchEntries, ShowEntry, StoreDirectory, }; use tree_output::EntryTree; @@ -100,6 +101,9 @@ enum Command { #[command(name = "complete-entries", hide = true)] CompleteEntries(CompleteEntriesCommand), + + #[command(about = "Import entries from other password managers")] + Import(ImportCommand), } #[derive(Debug, Parser)] @@ -359,6 +363,21 @@ struct CompleteEntriesCommand { prefix: String, } +#[derive(Debug, Parser)] +struct ImportCommand { + #[arg(help = "Import from a Bitwarden JSON export", long = "bitwarden")] + bitwarden: bool, + + #[arg(help = "Path to the import file")] + file: PathBuf, + + #[arg(short = 'f', long, help = "Overwrite existing entries")] + force: bool, + + #[arg(long, help = "Output in JSON format")] + json: bool, +} + pub fn run() -> Result<(), CliError> { let cli = Cli::parse(); @@ -385,6 +404,7 @@ pub fn run() -> Result<(), CliError> { Some(Command::Otp(command)) => generate_otp(command, store_directory), Some(Command::Search(command)) => search_entries(command, store_directory), Some(Command::Doctor(command)) => run_doctor(command, store_directory), + Some(Command::Import(command)) => import_entries(command, store_directory), Some(Command::CompleteEntries(command)) => { completion::complete_entries(&command.prefix, store_dir); Ok(()) @@ -440,6 +460,7 @@ impl Command { Self::Otp(command) => command.json, Self::Search(command) => command.json, Self::Doctor(command) => command.json, + Self::Import(command) => command.json, Self::Completions(_) | Self::CompleteEntries(_) => false, } } @@ -662,6 +683,60 @@ fn print_json_insert(entry_name: &str) -> Result<(), CliError> { Ok(()) } +fn import_entries(command: ImportCommand, store_directory: StoreDirectory) -> Result<(), CliError> { + let store = PasswordStore::open(store_directory)?; + let gpg = GpgCommand::from_environment(); + let data = std::fs::read_to_string(&command.file).map_err(|e| { + CliError::ImportFailed(format!("cannot read '{}': {}", command.file.display(), e)) + })?; + + let result = + ImportEntries::new(&store, &gpg).execute(&BitwardenImporter, &data, command.force)?; + + auto_commit( + &store, + &format!("Imported {} entries from Bitwarden", result.imported), + )?; + + if command.json { + print_json_import(&result, "Bitwarden")?; + } else { + print_text_import(&result, "Bitwarden"); + } + + Ok(()) +} + +fn print_text_import(result: &ImportResult, source: &str) { + println!( + "Imported {}/{} entries from {}", + result.imported, + result.imported + result.skipped, + source + ); + for error in &result.errors { + eprintln!(" {}", error); + } +} + +fn print_json_import(result: &ImportResult, source: &str) -> Result<(), CliError> { + #[derive(Serialize)] + struct ImportJson<'a> { + source: &'a str, + imported: usize, + skipped: usize, + errors: &'a [String], + } + let json = serde_json::to_string_pretty(&ImportJson { + source, + imported: result.imported, + skipped: result.skipped, + errors: &result.errors, + })?; + println!("{json}"); + Ok(()) +} + fn auto_commit(store: &PasswordStore, message: &str) -> Result<(), CliError> { GitCommand::from_environment().auto_commit(store, message)?; Ok(()) @@ -1159,6 +1234,9 @@ pub enum CliError { #[error("doctor checks failed")] DoctorFailed, + #[error("import failed: {0}")] + ImportFailed(String), + #[error("error already reported")] Reported, @@ -1188,6 +1266,7 @@ impl CliError { Self::RemoveConfirmationRequired => "remove_confirmation_required", Self::RemoveAborted => "remove_aborted", Self::DoctorFailed => "doctor_checks_failed", + Self::ImportFailed(_) => "import_failed", Self::Reported => "reported", Self::NoEntryPoint => "no_entry_or_subcommand_provided", } diff --git a/src/password_store/import.rs b/src/password_store/import.rs new file mode 100644 index 0000000..872361b --- /dev/null +++ b/src/password_store/import.rs @@ -0,0 +1,273 @@ +use super::{ + EntryName, GpgCommand, InsertEntry, PasswordStore, PasswordStoreError, + importer::{ImportEntry, Importer}, +}; + +#[derive(Debug)] +pub struct ImportResult { + pub imported: usize, + pub skipped: usize, + pub errors: Vec, +} + +pub struct ImportEntries<'store, 'gpg> { + store: &'store PasswordStore, + gpg: &'gpg GpgCommand, +} + +impl<'store, 'gpg> ImportEntries<'store, 'gpg> { + pub fn new(store: &'store PasswordStore, gpg: &'gpg GpgCommand) -> Self { + Self { store, gpg } + } + + pub fn execute( + &self, + importer: &dyn Importer, + data: &str, + force: bool, + ) -> Result { + let entries = importer + .parse(data) + .map_err(|error| PasswordStoreError::ImportFailed(error.to_string()))?; + + let total = entries.len(); + let mut imported = 0; + let mut errors = Vec::new(); + + for entry in entries { + match self.insert_import_entry(entry, force) { + Ok(()) => imported += 1, + Err(PasswordStoreError::EntryAlreadyExists(name)) => { + errors.push(format!( + "'{}' already exists; use --force to overwrite or resolve manually", + name + )); + } + Err(e) => { + errors.push(e.to_string()); + } + } + } + + Ok(ImportResult { + imported, + skipped: total - imported, + errors, + }) + } + + fn insert_import_entry( + &self, + entry: ImportEntry, + force: bool, + ) -> Result<(), PasswordStoreError> { + let sanitized_folder = entry.folder.as_deref().map(sanitize_path); + let sanitized_name = sanitize_segment(&entry.name); + let base_name = match sanitized_folder { + Some(ref folder) => format!("{folder}/{sanitized_name}"), + None => sanitized_name, + }; + + let name = self.resolve_entry_name(&base_name, force)?; + let content = build_entry_content(&entry); + InsertEntry::new(self.store, self.gpg).execute(&name, &content, true) + } + + fn resolve_entry_name( + &self, + base_name: &str, + force: bool, + ) -> Result { + let entry_name = EntryName::from_user_input(base_name).map_err(|error| { + PasswordStoreError::InvalidEntryName { + entry: base_name.to_owned(), + reason: error.message(), + } + })?; + let encrypted_file = entry_name.encrypted_file_path(self.store.path()); + + if !encrypted_file.exists() || force { + return Ok(entry_name.into_string()); + } + + for i in 1..1000 { + let candidate = format!("{base_name}-{i}"); + let entry_name = EntryName::from_user_input(&candidate).map_err(|error| { + PasswordStoreError::InvalidEntryName { + entry: candidate, + reason: error.message(), + } + })?; + let encrypted_file = entry_name.encrypted_file_path(self.store.path()); + if !encrypted_file.exists() { + return Ok(entry_name.into_string()); + } + } + + Err(PasswordStoreError::EntryAlreadyExists(base_name.to_owned())) + } +} + +const INVALIDS: &[(char, &str)] = &[ + ('<', "-"), + ('>', "-"), + (':', "-"), + ('"', "-"), + ('/', "-"), + ('\\', "-"), + ('|', "-"), + ('?', "-"), + ('*', "-"), + ('&', "and"), + ('@', "At"), +]; + +fn sanitize_path(path: &str) -> String { + path.trim() + .split('/') + .map(sanitize_segment) + .filter(|s| !s.is_empty()) + .collect::>() + .join("/") +} + +fn sanitize_segment(segment: &str) -> String { + let mut cleaned = String::with_capacity(segment.len()); + for c in segment.trim().chars() { + match c { + '\0' | '\t' | '\'' | '[' | ']' => {} + _ if is_invalid(c) => { + let replacement = INVALIDS + .iter() + .find(|&&(ch, _)| ch == c) + .map(|&(_, s)| s) + .unwrap_or("-"); + cleaned.push_str(replacement); + } + _ => cleaned.push(c), + } + } + cleaned +} + +fn is_invalid(c: char) -> bool { + INVALIDS.iter().any(|&(ch, _)| ch == c) +} + +fn build_entry_content(entry: &ImportEntry) -> String { + let mut lines = Vec::new(); + + lines.push(entry.password.clone().unwrap_or_default()); + + for field in &entry.fields { + lines.push(format!("{}: {}", field.name, field.value)); + } + + if let Some(ref uri) = entry.otp_uri { + lines.push(uri.clone()); + } + + if let Some(ref notes) = entry.notes { + for line in notes.lines() { + lines.push(line.to_owned()); + } + } + + lines.join("\n") + "\n" +} + +#[cfg(test)] +mod tests { + use super::{build_entry_content, sanitize_path, sanitize_segment}; + use crate::password_store::EntryField; + use crate::password_store::importer::ImportEntry; + + #[test] + fn segment_replaces_invalids() { + assert_eq!( + sanitize_segment("ac:d\"e/f\\g|h?i*j"), + "a-b-c-d-e-f-g-h-i-j" + ); + } + + #[test] + fn segment_strips_control_chars() { + assert_eq!(sanitize_segment("foo\x00bar\tbaz"), "foobarbaz"); + } + + #[test] + fn segment_replaces_ampersand() { + assert_eq!(sanitize_segment("foo&bar"), "fooandbar"); + } + + #[test] + fn segment_replaces_at_sign() { + assert_eq!(sanitize_segment("foo@bar"), "fooAtbar"); + } + + #[test] + fn segment_strips_brackets_and_quote() { + assert_eq!(sanitize_segment("foo'bar[baz]"), "foobarbaz"); + } + + #[test] + fn segment_trims_whitespace() { + assert_eq!(sanitize_segment(" foo "), "foo"); + } + + #[test] + fn path_preserves_separators_and_sanitizes_segments() { + assert_eq!(sanitize_path("Social/My Bank"), "Social/My Bank"); + assert_eq!(sanitize_path("Social/My Result, ImportError> { + let root: Value = serde_json::from_str(data)?; + + if root + .get("encrypted") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + return Err(ImportError::EncryptedFile); + } + + let items = root + .get("items") + .and_then(|v| v.as_array()) + .ok_or(ImportError::NoEntries)?; + + if items.is_empty() { + return Err(ImportError::NoEntries); + } + + let folders = build_folder_map(&root); + let mut entries = Vec::new(); + + for item in items { + if let Some(entry) = parse_item(item, &folders) { + entries.push(entry); + } + } + + if entries.is_empty() { + return Err(ImportError::NoEntries); + } + + Ok(entries) + } +} + +fn build_folder_map(root: &Value) -> HashMap { + let mut map = HashMap::new(); + + for key in &["folders", "collections"] { + let Some(groups) = root.get(*key).and_then(|v| v.as_array()) else { + continue; + }; + + for group in groups { + let id = group + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_owned(); + let name = group + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_owned(); + if !id.is_empty() { + map.insert(id, name); + } + } + } + + map +} + +fn parse_item(item: &Value, folders: &HashMap) -> Option { + let name = item.get("name")?.as_str()?.trim().to_owned(); + if name.is_empty() { + return None; + } + + let folder = resolve_folder(item, folders); + let login = item.get("login"); + + let password = login + .and_then(|l| l.get("password")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from); + + let username = login + .and_then(|l| l.get("username")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from); + + let totp = login + .and_then(|l| l.get("totp")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from); + + let uris = login.and_then(|l| l.get("uris")).and_then(|v| v.as_array()); + + let mut fields = Vec::new(); + + if let Some(ref u) = username { + fields.push(EntryField { + name: "username".to_owned(), + value: u.clone(), + }); + } + + if let Some(uris) = uris { + for (i, uri_obj) in uris.iter().enumerate() { + let uri = uri_obj + .get("uri") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + if let Some(uri) = uri { + let key = if i == 0 { + "url".to_owned() + } else { + format!("url{}", i + 1) + }; + fields.push(EntryField { + name: key, + value: uri.to_owned(), + }); + } + } + } + + let item_type = item.get("type").and_then(|v| v.as_i64()).unwrap_or(1); + if item_type == 3 { + if let Some(card) = item.get("card") { + flatten_object(card, &mut fields); + } + } else if item_type == 4 + && let Some(identity) = item.get("identity") + { + flatten_object(identity, &mut fields); + } + + if let Some(fields_arr) = item.get("fields").and_then(|v| v.as_array()) { + for field in fields_arr { + let fname = field.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let fvalue = field.get("value").and_then(|v| v.as_str()).unwrap_or(""); + if !fname.is_empty() && !fvalue.is_empty() { + fields.push(EntryField { + name: fname.to_owned(), + value: fvalue.to_owned(), + }); + } + } + } + + let otp_uri = totp.map(|t| normalize_totp(&t, &name)); + + let notes = item + .get("notes") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from); + + Some(ImportEntry { + name, + password, + fields, + otp_uri, + notes, + folder, + }) +} + +fn resolve_folder(item: &Value, folders: &HashMap) -> Option { + if let Some(folder_id) = item.get("folderId").and_then(|v| v.as_str()) + && !folder_id.is_empty() + { + return folders.get(folder_id).filter(|n| !n.is_empty()).cloned(); + } + + if let Some(collection_ids) = item.get("collectionIds").and_then(|v| v.as_array()) { + for coll_id in collection_ids { + if let Some(id) = coll_id.as_str() + && !id.is_empty() + && let Some(name) = folders.get(id) + && !name.is_empty() + { + return Some(name.clone()); + } + } + } + + None +} + +fn flatten_object(obj: &Value, fields: &mut Vec) { + let Some(map) = obj.as_object() else { + return; + }; + + for (k, v) in map { + if let Some(s) = v.as_str() + && !s.is_empty() + { + fields.push(EntryField { + name: k.clone(), + value: s.to_owned(), + }); + } + } +} + +fn normalize_totp(totp: &str, name: &str) -> String { + if totp.starts_with("otpauth://") { + totp.to_owned() + } else { + format!("otpauth://totp/{name}?secret={totp}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_individual_vault() { + let json = r#"{ + "encrypted": false, + "folders": [ + { "id": "f1", "name": "Social" } + ], + "items": [ + { + "id": "i1", + "folderId": "f1", + "type": 1, + "name": "Twitter", + "notes": "My Twitter account", + "favorite": false, + "fields": [ + { "name": "handle", "value": "@me", "type": 0 } + ], + "login": { + "uris": [{ "match": null, "uri": "https://twitter.com" }], + "username": "me@example.com", + "password": "secret123", + "totp": "JBSWY3DPEHPK3PXP" + } + } + ] + }"#; + + let importer = BitwardenImporter; + let entries = importer.parse(json).expect("parse"); + assert_eq!(entries.len(), 1); + + let entry = &entries[0]; + assert_eq!(entry.name, "Twitter"); + assert_eq!(entry.folder.as_deref(), Some("Social")); + assert_eq!(entry.password.as_deref(), Some("secret123")); + assert!( + entry + .fields + .iter() + .any(|f| f.name == "username" && f.value == "me@example.com") + ); + assert!( + entry + .fields + .iter() + .any(|f| f.name == "url" && f.value == "https://twitter.com") + ); + assert!( + entry + .fields + .iter() + .any(|f| f.name == "handle" && f.value == "@me") + ); + assert!(entry.otp_uri.as_deref().unwrap().starts_with("otpauth://")); + assert_eq!(entry.notes.as_deref(), Some("My Twitter account")); + } + + #[test] + fn rejects_encrypted_export() { + let json = r#"{"encrypted": true, "items": []}"#; + let importer = BitwardenImporter; + assert!(matches!( + importer.parse(json), + Err(ImportError::EncryptedFile) + )); + } + + #[test] + fn handles_no_folder() { + let json = r#"{ + "encrypted": false, + "items": [ + { + "id": "i1", + "folderId": null, + "type": 1, + "name": "Direct Login", + "login": { "username": "user", "password": "pass" } + } + ] + }"#; + + let importer = BitwardenImporter; + let entries = importer.parse(json).expect("parse"); + assert_eq!(entries.len(), 1); + assert!(entries[0].folder.is_none()); + } + + #[test] + fn handles_secure_note() { + let json = r#"{ + "encrypted": false, + "items": [ + { + "id": "i1", + "type": 2, + "name": "My Note", + "notes": "This is a secure note content" + } + ] + }"#; + + let importer = BitwardenImporter; + let entries = importer.parse(json).expect("parse"); + assert_eq!(entries.len(), 1); + assert!(entries[0].password.is_none()); + assert_eq!( + entries[0].notes.as_deref(), + Some("This is a secure note content") + ); + } + + #[test] + fn handles_card() { + let json = r#"{ + "encrypted": false, + "items": [ + { + "id": "i1", + "type": 3, + "name": "My Card", + "card": { + "brand": "Visa", + "number": "4111111111111111", + "cardholderName": "John Doe" + } + } + ] + }"#; + + let importer = BitwardenImporter; + let entries = importer.parse(json).expect("parse"); + assert_eq!(entries.len(), 1); + assert!( + entries[0] + .fields + .iter() + .any(|f| f.name == "brand" && f.value == "Visa") + ); + } + + #[test] + fn handles_organization_vault() { + let json = r#"{ + "encrypted": false, + "collections": [ + { "id": "c1", "organizationId": "o1", "name": "Team Passwords" } + ], + "items": [ + { + "id": "i1", + "organizationId": "o1", + "collectionIds": ["c1"], + "type": 1, + "name": "Shared Login", + "login": { "username": "admin", "password": "admin123" } + } + ] + }"#; + + let importer = BitwardenImporter; + let entries = importer.parse(json).expect("parse"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].folder.as_deref(), Some("Team Passwords")); + } + + #[test] + fn normalizes_raw_totp_key() { + assert_eq!( + normalize_totp("JBSWY3DPEHPK3PXP", "test"), + "otpauth://totp/test?secret=JBSWY3DPEHPK3PXP" + ); + } + + #[test] + fn preserves_full_otpauth_uri() { + assert_eq!( + normalize_totp("otpauth://totp/example?secret=ABC", "ignored"), + "otpauth://totp/example?secret=ABC" + ); + } + + #[test] + fn handles_multiple_uris() { + let json = r#"{ + "encrypted": false, + "items": [ + { + "id": "i1", + "type": 1, + "name": "Multi URL", + "login": { + "uris": [ + { "match": null, "uri": "https://primary.com" }, + { "match": null, "uri": "https://backup.com" } + ], + "username": "user", + "password": "pass" + } + } + ] + }"#; + + let importer = BitwardenImporter; + let entries = importer.parse(json).expect("parse"); + assert_eq!(entries.len(), 1); + assert!( + entries[0] + .fields + .iter() + .any(|f| f.name == "url" && f.value == "https://primary.com") + ); + assert!( + entries[0] + .fields + .iter() + .any(|f| f.name == "url2" && f.value == "https://backup.com") + ); + } +} diff --git a/src/password_store/importer/mod.rs b/src/password_store/importer/mod.rs new file mode 100644 index 0000000..af1a6e1 --- /dev/null +++ b/src/password_store/importer/mod.rs @@ -0,0 +1,29 @@ +pub mod bitwarden; + +use super::EntryField; + +#[derive(Debug, Clone)] +pub struct ImportEntry { + pub name: String, + pub password: Option, + pub fields: Vec, + pub otp_uri: Option, + pub notes: Option, + pub folder: Option, +} + +pub trait Importer: std::fmt::Debug { + fn parse(&self, data: &str) -> Result, ImportError>; +} + +#[derive(Debug, thiserror::Error)] +pub enum ImportError { + #[error("the import file is encrypted; decrypt it first")] + EncryptedFile, + + #[error("JSON parse error: {0}")] + Json(#[from] serde_json::Error), + + #[error("import file contains no recognizable entries")] + NoEntries, +} diff --git a/src/password_store/mod.rs b/src/password_store/mod.rs index 242740b..49373f3 100644 --- a/src/password_store/mod.rs +++ b/src/password_store/mod.rs @@ -4,6 +4,8 @@ mod edit_entry; mod entry_name; mod git; mod gpg; +mod import; +pub mod importer; mod init_store; mod insert_entry; mod list_entries; @@ -21,6 +23,7 @@ pub use edit_entry::EditEntry; pub use entry_name::EntryName; pub use git::GitCommand; pub use gpg::GpgCommand; +pub use import::{ImportEntries, ImportResult}; pub use init_store::{InitStore, InitStoreResult}; pub use insert_entry::InsertEntry; pub use list_entries::ListEntries; diff --git a/src/password_store/store_directory.rs b/src/password_store/store_directory.rs index b55888d..7746fd5 100644 --- a/src/password_store/store_directory.rs +++ b/src/password_store/store_directory.rs @@ -126,6 +126,9 @@ pub enum PasswordStoreError { #[error("recipient not found: {0}")] RecipientNotFound(String), + #[error("import failed: {0}")] + ImportFailed(String), + #[error("failed to access password store: {0}")] Io(#[from] std::io::Error), } @@ -155,6 +158,7 @@ impl PasswordStoreError { Self::OtpNotFound => "otp_not_found", Self::InvalidOtpUri(_) => "invalid_otp_uri", Self::RecipientNotFound(_) => "recipient_not_found", + Self::ImportFailed(_) => "import_failed", Self::Io(_) => "io_error", } }