From 99a2b6a475acb5abd919cee60c9889bc93c78fa2 Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:14:32 +0200 Subject: [PATCH 01/21] feat(accounts): add persistent IDs aliases and unambiguous selectors --- README.md | 21 ++ src/arguments/add.rs | 13 +- src/arguments/delete.rs | 39 +--- src/arguments/edit.rs | 135 ++++++----- src/arguments/extract.rs | 325 +------------------------- src/arguments/import.rs | 2 +- src/arguments/list.rs | 4 + src/arguments/mod.rs | 1 + src/arguments/selector.rs | 256 ++++++++++++++++++++ src/importers/aegis.rs | 5 + src/importers/authy_remote_debug.rs | 197 ++++++++-------- src/importers/converted.rs | 5 + src/importers/freeotp_plus.rs | 15 ++ src/importers/google_authenticator.rs | 15 ++ src/importers/otp_uri.rs | 5 + src/interface/form.rs | 18 +- src/interface/tests.rs | 5 + src/otp/from_otp_uri.rs | 5 + src/otp/migrations/mod.rs | 97 ++++++-- src/otp/otp_element.rs | 95 +++++++- src/reading.rs | 1 + tests/selection_integration_tests.rs | 110 +++++++++ 22 files changed, 832 insertions(+), 537 deletions(-) create mode 100644 src/arguments/selector.rs create mode 100644 tests/selection_integration_tests.rs diff --git a/README.md b/README.md index 30a0fe20..87dc6e4e 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,27 @@ use `cotp list` or `cotp list --json` when piping output. | `Esc` | Close the current screen, finish searching, clear a filter, or quit | | `q`, `Ctrl+C`, `Ctrl+D` | Quit; account changes prompt you to save or discard | +### Stable account selection + +Every account has a persistent random ID, available in `cotp list --json`. +Existing vaults upgrade automatically while preserving their secrets. All CLI +indices start at 1 and match the list display. Issuer and label selectors use +case-insensitive glob matching; multiple filters must all match. Commands fail +when the selection is ambiguous instead of choosing the first account. + +```sh +cotp add --issuer GitHub --label you --alias work-github +cotp extract --alias work-github +cotp extract --issuer 'Git*' --label you +cotp edit --alias work-github --set-alias github --label new-name +cotp edit --match-issuer GitHub --match-label you --digits 8 +cotp delete --id +``` + +On `edit`, `--issuer` and `--label` still set new values; use `--match-issuer` +and `--match-label` to select by those fields. Aliases are unique ignoring case; +`edit --clear-alias` removes an alias. Account IDs survive renaming and TUI edits. + ### Add and edit accounts Press `a` or click **Add** to enter an issuer, account label, and secret. You can also diff --git a/src/arguments/add.rs b/src/arguments/add.rs index 030978b3..87b76fa3 100644 --- a/src/arguments/add.rs +++ b/src/arguments/add.rs @@ -24,6 +24,10 @@ pub struct AddArgs { #[arg(short = 't', long = "type", default_value = "totp")] pub otp_type: OTPType, + /// Unique account alias + #[arg(long)] + pub alias: Option, + /// Code issuer #[arg(short, long, default_value = "")] pub issuer: String, @@ -70,7 +74,11 @@ pub struct AddArgs { impl SubcommandExecutor for AddArgs { fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { - let otp_element = if self.otp_uri { + if let Some(alias) = &self.alias { + super::selector::validate_alias(&database, alias, None)?; + } + let alias = self.alias.clone(); + let mut otp_element = if self.otp_uri { let mut otp_uri = rpassword::prompt_password("Insert the otp uri: ").unwrap(); let result = OTPElement::from_otp_uri(otp_uri.as_str()); otp_uri.zeroize(); @@ -79,7 +87,8 @@ impl SubcommandExecutor for AddArgs { get_from_args(self)? }; - database.add_element(otp_element); + otp_element.alias = alias; + database.add_element(otp_element)?; Ok(database) } } diff --git a/src/arguments/delete.rs b/src/arguments/delete.rs index 8ba40492..104b68f6 100644 --- a/src/arguments/delete.rs +++ b/src/arguments/delete.rs @@ -14,17 +14,8 @@ use super::SubcommandExecutor; #[derive(Args)] pub struct DeleteArgs { - /// Code Index - #[arg(short, long, required_unless_present_any=["issuer", "label"])] - pub index: Option, - - /// Issuer of the first matching code that will be deleted - #[arg(short = 's', long, required_unless_present_any=["index", "label"])] - pub issuer: Option, - - /// Label of the first matching code that will be deleted - #[arg(short, long, required_unless_present_any=["index","issuer"])] - pub label: Option, + #[command(flatten)] + pub selector: super::selector::AccountSelector, } impl SubcommandExecutor for DeleteArgs { @@ -33,12 +24,7 @@ impl SubcommandExecutor for DeleteArgs { return Err(eyre!("There are no elements to delete")); } - let index_to_delete = self - .index - .and_then(|i| i.checked_sub(1)) - // Match by issues or label if index filter is missing - .or_else(|| get_first_matching_element(&otp_database, &self)) - .ok_or(eyre!("No code has been found using the given arguments"))?; + let index_to_delete = self.selector.select(&otp_database)?; if let Some(element) = otp_database.elements_ref().get(index_to_delete) { print!( @@ -91,22 +77,3 @@ fn read_confirmation_line() -> color_eyre::Result { "Unable to read confirmation answer from standard input or terminal" )) } - -fn get_first_matching_element( - otp_database: &OTPDatabase, - delete_args: &DeleteArgs, -) -> Option { - otp_database - .elements_ref() - .iter() - .enumerate() - .find(|(_, element)| { - element - .issuer - .contains(delete_args.issuer.as_deref().unwrap_or_default()) - && element - .label - .contains(delete_args.label.as_deref().unwrap_or_default()) - }) - .map(|(index, _)| index) -} diff --git a/src/arguments/edit.rs b/src/arguments/edit.rs index 398b57f2..e3749bac 100644 --- a/src/arguments/edit.rs +++ b/src/arguments/edit.rs @@ -1,44 +1,56 @@ use clap::{Args, value_parser}; -use color_eyre::eyre::eyre; use crate::otp::{otp_algorithm::OTPAlgorithm, otp_element::OTPDatabase}; -use super::SubcommandExecutor; +use super::{ + SubcommandExecutor, + selector::{AccountSelector, validate_alias}, +}; #[derive(Args)] pub struct EditArgs { - /// Code Index - #[arg(short, long)] - pub index: usize, - - /// Code issuer + /// One-based account index shown by list + #[arg(short, long, required_unless_present_any = ["id", "alias", "match_issuer", "match_label"])] + pub index: Option, + /// Persistent account identifier + #[arg(long)] + pub id: Option, + /// Existing account alias + #[arg(long)] + pub alias: Option, + /// Select by issuer (case-insensitive glob) + #[arg(long)] + pub match_issuer: Option, + /// Select by label (case-insensitive glob) + #[arg(long)] + pub match_label: Option, + /// Set a unique account alias + #[arg(long, conflicts_with = "clear_alias")] + pub set_alias: Option, + /// Remove the account alias + #[arg(long)] + pub clear_alias: bool, + /// New code issuer #[arg(short = 's', long)] pub issuer: Option, - - /// Code label + /// New code label #[arg(short, long)] pub label: Option, - /// OTP algorithm #[arg(short, long, value_enum)] pub algorithm: Option, - /// Code digits #[arg(short, long, value_parser=value_parser!(u64).range(1..=10))] pub digits: Option, - /// Code period - #[arg(short = 'e', long)] + #[arg(short = 'e', long, value_parser=value_parser!(u64).range(1..))] pub period: Option, - /// HOTP counter #[arg(short, long)] pub counter: Option, - /// Yandex / MOTP pin #[arg(short, long)] pub pin: Option, - /// Change code secret #[arg(short = 'k', long = "change-secret")] pub change_secret: bool, @@ -46,51 +58,56 @@ pub struct EditArgs { impl SubcommandExecutor for EditArgs { fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { + let index = AccountSelector { + index: self.index, + id: self.id, + alias: self.alias, + issuer: self.match_issuer, + label: self.match_label, + query: None, + } + .select(&database)?; + if let Some(alias) = &self.set_alias { + validate_alias(&database, alias, Some(index))?; + } let secret = self .change_secret - .then(|| rpassword::prompt_password("Insert the secret: ").unwrap()); - - // User provides row number from dashboard which is equal to the array index plus one - let index = self.index; - - if let Some(real_index) = index.checked_sub(1) { - if real_index >= database.elements_ref().len() { - return Err(eyre!("{index} is an invalid index")); - } - - match database.mut_element(real_index) { - Some(element) => { - if let Some(v) = self.issuer { - element.issuer = v; - } - if let Some(v) = self.label { - element.label = v; - } - if let Some(v) = self.digits { - element.digits = v; - } - if let Some(v) = self.period { - element.period = v; - } - if let Some(v) = self.algorithm { - element.algorithm = v; - } - if self.counter.is_some() { - element.counter = self.counter; - } - if self.pin.is_some() { - element.pin = self.pin; - } - if let Some(s) = secret { - element.secret = s; - } - database.mark_modified(); - } - None => return Err(eyre!("No element found at index {index}")), - } - Ok(database) - } else { - Err(eyre!("{index} is an invalid index")) + .then(|| rpassword::prompt_password("Insert the secret: ")) + .transpose()?; + let element = database + .mut_element(index) + .expect("selected account exists"); + if let Some(value) = self.issuer { + element.issuer = value; + } + if let Some(value) = self.label { + element.label = value; + } + if let Some(value) = self.digits { + element.digits = value; + } + if let Some(value) = self.period { + element.period = value; + } + if let Some(value) = self.algorithm { + element.algorithm = value; + } + if self.counter.is_some() { + element.counter = self.counter; + } + if self.pin.is_some() { + element.pin = self.pin; + } + if let Some(value) = secret { + element.secret = value; + } + if let Some(value) = self.set_alias { + element.alias = Some(value); + } + if self.clear_alias { + element.alias = None; } + database.mark_modified(); + Ok(database) } } diff --git a/src/arguments/extract.rs b/src/arguments/extract.rs index 0f271303..8d532e61 100644 --- a/src/arguments/extract.rs +++ b/src/arguments/extract.rs @@ -1,329 +1,26 @@ -use crate::otp::otp_element::OTPDatabase; -use crate::{clipboard, otp::otp_element::OTPElement}; +use crate::{clipboard, otp::otp_element::OTPDatabase}; use clap::Args; -use color_eyre::eyre::eyre; -use globset::{GlobBuilder, GlobMatcher}; -use super::SubcommandExecutor; +use super::{SubcommandExecutor, selector::AccountSelector}; #[derive(Args, Default)] pub struct ExtractArgs { - /// Code Index - #[arg(short, long, required_unless_present_any = ["issuer", "label"])] - pub index: Option, - - /// Code issuer, may be a glob pattern - #[arg(short = 's', long, required_unless_present_any = ["index", "label"])] - pub issuer: Option, - - /// Code label, may be a glob pattern - #[arg(short, long, required_unless_present_any = ["index", "issuer"])] - pub label: Option, - + #[command(flatten)] + pub selector: AccountSelector, /// Copy the code to the clipboard #[arg(short, long = "copy-clipboard", default_value_t = false)] pub copy_to_clipboard: bool, } -// Contains glob filters for each field we can filter on -struct ExtractFilterGlob { - issuer_glob: Option, - label_glob: Option, - index: Option, -} - -impl TryFrom for ExtractFilterGlob { - type Error = color_eyre::eyre::ErrReport; - - fn try_from(value: ExtractArgs) -> Result { - let issuer_glob = if let Some(issuer) = value.issuer { - Some(create_matcher(&issuer)?) - } else { - None - }; - - let label_glob = if let Some(label) = value.label { - Some(create_matcher(&label)?) - } else { - None - }; - - Ok(Self { - issuer_glob, - label_glob, - index: value.index, - }) - } -} - -fn create_matcher( - glob: &str, -) -> Result>::Error> { - Ok(GlobBuilder::new(glob) - .case_insensitive(true) - .build()? - .compile_matcher()) -} - impl SubcommandExecutor for ExtractArgs { fn run_command(self, otp_database: OTPDatabase) -> color_eyre::Result { - let copy_to_clipboard = self.copy_to_clipboard; - let globbed: ExtractFilterGlob = self.try_into()?; - - let first_with_filters = find_match(&otp_database, globbed); - - if let Some(otp) = first_with_filters { - let code = otp.get_otp_code()?; - println!("{code}"); - if copy_to_clipboard { - let _ = clipboard::copy_string_to_clipboard(code.as_str())?; - println!("Copied to clipboard"); - } - Ok(otp_database) - } else { - Err(eyre!("No such code found with these fields")) + let index = self.selector.select(&otp_database)?; + let code = otp_database.elements_ref()[index].get_otp_code()?; + if self.copy_to_clipboard { + clipboard::copy_string_to_clipboard(&code)?; + eprintln!("Copied to clipboard"); } - } -} - -fn find_match(otp_database: &OTPDatabase, globbed: ExtractFilterGlob) -> Option<&OTPElement> { - otp_database - .elements - .iter() - .enumerate() - .find(|(index, code)| filter_extract(&globbed, *index, code)) - .map(|(_, code)| code) -} - -fn filter_extract(args: &ExtractFilterGlob, index: usize, candidate: &OTPElement) -> bool { - let match_by_index = args.index.is_none_or(|i| i == index); - - let match_by_issuer = args - .issuer_glob - .as_ref() - .is_none_or(|issuer| issuer.is_match(&candidate.issuer)); - - let match_by_label = args - .label_glob - .as_ref() - .is_none_or(|label| label.is_match(&candidate.label)); - - match_by_index && match_by_issuer && match_by_label -} - -#[cfg(test)] -mod tests { - - use crate::{ - arguments::extract::ExtractArgs, - otp::otp_element::{OTPDatabase, OTPElementBuilder}, - }; - - use super::find_match; - - #[test] - fn test_glob_filtering_good_issuer() { - // Arrange - let mut otp_database = OTPDatabase::default(); - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer") - .label("test-label") - .secret("AA") - .build() - .unwrap(), - ); - - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer2") - .label("test-label2") - .secret("AA") - .build() - .unwrap(), - ); - - let filter = ExtractArgs { - issuer: Some("test-iss*".to_string()), - ..Default::default() - }; - - // Act - let found_match = find_match(&otp_database, filter.try_into().unwrap()); - - // Assert - assert!(found_match.is_some()); - } - - #[test] - fn test_glob_filtering_good_label() { - // Arrange - let mut otp_database = OTPDatabase::default(); - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer") - .label("test-label") - .secret("AA") - .build() - .unwrap(), - ); - - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer2") - .label("test-label2") - .secret("AA") - .build() - .unwrap(), - ); - - let filter = ExtractArgs { - label: Some("test-la*".to_string()), - ..Default::default() - }; - - // Act - let found_match = find_match(&otp_database, filter.try_into().unwrap()); - - // Assert - assert!(found_match.is_some()); - } - - #[test] - fn test_glob_filtering_no_match() { - // Arrange - let mut otp_database = OTPDatabase::default(); - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer") - .label("test-label") - .secret("AA") - .build() - .unwrap(), - ); - - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer2") - .label("test-label2") - .secret("AA") - .build() - .unwrap(), - ); - - let filter = ExtractArgs { - label: Some("test-lala*".to_string()), - ..Default::default() - }; - - // Act - let found_match = find_match(&otp_database, filter.try_into().unwrap()); - - // Assert - assert!(found_match.is_none()); - } - - #[test] - fn test_glob_filtering_multiple_filters_match() { - // Arrange - let mut otp_database = OTPDatabase::default(); - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer") - .label("test-label") - .secret("AA") - .build() - .unwrap(), - ); - - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer2") - .label("test-label2") - .secret("AA") - .build() - .unwrap(), - ); - - let filter = ExtractArgs { - issuer: Some("test*".to_string()), - label: Some("test-la*".to_string()), - ..Default::default() - }; - - // Act - let found_match = find_match(&otp_database, filter.try_into().unwrap()); - - // Assert - assert!(found_match.is_some()); - } - - #[test] - fn test_glob_filtering_multiple_filters_no_match() { - // Arrange - let mut otp_database = OTPDatabase::default(); - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer") - .label("test-label") - .secret("AA") - .build() - .unwrap(), - ); - - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer2") - .label("test-label2") - .secret("AA") - .build() - .unwrap(), - ); - - let filter = ExtractArgs { - issuer: Some("test-no*".to_string()), - label: Some("test-la*".to_string()), - ..Default::default() - }; - - // Act - let found_match = find_match(&otp_database, filter.try_into().unwrap()); - - // Assert - assert!(found_match.is_none()); - } - - #[test] - fn test_glob_filtering_case_insensitive() { - // Arrange - let mut otp_database = OTPDatabase::default(); - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer") - .label("test-label") - .secret("AA") - .build() - .unwrap(), - ); - - otp_database.add_element( - OTPElementBuilder::default() - .issuer("test-issuer2") - .label("test-label2") - .secret("AA") - .build() - .unwrap(), - ); - - let filter = ExtractArgs { - issuer: Some("TeSt-iSS*".to_string()), - ..Default::default() - }; - - // Act - let found_match = find_match(&otp_database, filter.try_into().unwrap()); - - // Assert - assert!(found_match.is_some()); + println!("{code}"); + Ok(otp_database) } } diff --git a/src/arguments/import.rs b/src/arguments/import.rs index c9273e46..afdd0825 100644 --- a/src/arguments/import.rs +++ b/src/arguments/import.rs @@ -105,7 +105,7 @@ impl SubcommandExecutor for ImportArgs { let elements = result.map_err(|e| eyre!("{e}"))?; - database.add_all(elements); + database.add_all(elements)?; Ok(database) } } diff --git a/src/arguments/list.rs b/src/arguments/list.rs index dd53c261..d09b7e65 100644 --- a/src/arguments/list.rs +++ b/src/arguments/list.rs @@ -38,6 +38,8 @@ impl Default for ListFormat { /// Defines JSON structure to output using the --json argument in the list subcommand #[derive(Serialize)] struct JsonOtpList<'a> { + id: &'a str, + alias: Option<&'a str>, issuer: &'a str, label: &'a str, otp_code: String, @@ -49,6 +51,8 @@ impl<'a> TryFrom<&'a OTPElement> for JsonOtpList<'a> { fn try_from(value: &'a OTPElement) -> Result { let otp_code = value.get_otp_code()?; Ok(JsonOtpList { + id: &value.id, + alias: value.alias.as_deref(), issuer: &value.issuer, label: &value.label, otp_code, diff --git a/src/arguments/mod.rs b/src/arguments/mod.rs index 3646cddb..ff0989c7 100644 --- a/src/arguments/mod.rs +++ b/src/arguments/mod.rs @@ -18,6 +18,7 @@ mod extract; mod import; mod list; mod passwd; +pub(crate) mod selector; /// Common trait the all the Subcommands must implement to define the command logic #[enum_dispatch] diff --git a/src/arguments/selector.rs b/src/arguments/selector.rs new file mode 100644 index 00000000..649a022a --- /dev/null +++ b/src/arguments/selector.rs @@ -0,0 +1,256 @@ +//! Shared single-account selection. Indices always match the one-based list display. +use clap::Args; +use color_eyre::eyre::eyre; +use globset::{GlobBuilder, GlobMatcher}; + +use crate::otp::otp_element::{OTPDatabase, OTPElement}; + +#[derive(Args, Clone, Default, Debug)] +#[group(id = "account_selector", required = true, multiple = true)] +pub struct AccountSelector { + /// One-based account index shown by list + #[arg(short, long)] + pub index: Option, + /// Persistent account identifier + #[arg(long)] + pub id: Option, + /// Exact account alias (case-insensitive) + #[arg(long)] + pub alias: Option, + /// Account issuer, optionally a case-insensitive glob + #[arg(short = 's', long)] + pub issuer: Option, + /// Account label, optionally a case-insensitive glob + #[arg(short, long)] + pub label: Option, + /// Account ID, alias, or text in its issuer/label + #[arg(value_name = "QUERY")] + pub query: Option, +} + +impl AccountSelector { + pub fn select(&self, database: &OTPDatabase) -> color_eyre::Result { + if self.index == Some(0) { + return Err(eyre!("Account indices start at 1")); + } + if self.index.is_none() + && self.id.is_none() + && self.alias.is_none() + && self.issuer.is_none() + && self.label.is_none() + && self.query.is_none() + { + return Err(eyre!( + "Specify an account ID, alias, index, issuer, label, or query" + )); + } + if [ + &self.id, + &self.alias, + &self.issuer, + &self.label, + &self.query, + ] + .iter() + .any(|value| value.as_ref().is_some_and(|value| value.trim().is_empty())) + { + return Err(eyre!("Account selectors cannot be empty")); + } + let issuer = self.issuer.as_deref().map(matcher).transpose()?; + let label = self.label.as_deref().map(matcher).transpose()?; + let query = self.query.as_ref().map(|value| value.to_lowercase()); + let matches: Vec = database + .elements_ref() + .iter() + .enumerate() + .filter(|(index, element)| { + self.index.is_none_or(|value| value == index + 1) + && self.id.as_ref().is_none_or(|value| *value == element.id) + && self.alias.as_ref().is_none_or(|value| { + element + .alias + .as_ref() + .is_some_and(|alias| alias.eq_ignore_ascii_case(value)) + }) + && issuer + .as_ref() + .is_none_or(|filter| filter.is_match(&element.issuer)) + && label + .as_ref() + .is_none_or(|filter| filter.is_match(&element.label)) + && query + .as_ref() + .is_none_or(|query| matches_query(element, query)) + }) + .map(|(index, _)| index) + .collect(); + match matches.as_slice() { + [index] => Ok(*index), + [] => Err(eyre!("No account matches these selectors")), + _ => Err(eyre!( + "Ambiguous account selection: {} accounts match; use --id, --alias, or additional selectors", + matches.len() + )), + } + } +} + +fn matcher(pattern: &str) -> color_eyre::Result { + Ok(GlobBuilder::new(pattern) + .case_insensitive(true) + .build()? + .compile_matcher()) +} + +fn matches_query(element: &OTPElement, query: &str) -> bool { + element.id.eq_ignore_ascii_case(query) + || element + .alias + .as_ref() + .is_some_and(|alias| alias.eq_ignore_ascii_case(query)) + || element.issuer.to_lowercase().contains(query) + || element.label.to_lowercase().contains(query) +} + +pub fn validate_alias( + database: &OTPDatabase, + alias: &str, + except: Option, +) -> color_eyre::Result<()> { + if alias.trim().is_empty() { + return Err(eyre!("An alias cannot be empty")); + } + if database + .elements_ref() + .iter() + .enumerate() + .any(|(index, element)| { + Some(index) != except + && element + .alias + .as_ref() + .is_some_and(|existing| existing.eq_ignore_ascii_case(alias)) + }) + { + return Err(eyre!("Alias is already assigned to another account")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::otp::otp_element::OTPElementBuilder; + + fn database() -> OTPDatabase { + OTPDatabase::from(vec![ + OTPElementBuilder::default() + .secret("AA") + .issuer("GitHub") + .label("alice") + .id("first") + .alias(Some("work".into())) + .build() + .unwrap(), + OTPElementBuilder::default() + .secret("AA") + .issuer("GitHub") + .label("bob") + .id("second") + .build() + .unwrap(), + ]) + } + + #[test] + fn indices_match_the_display_and_zero_is_rejected() { + let database = database(); + for (input, output) in [(1, 0), (2, 1)] { + assert_eq!( + AccountSelector { + index: Some(input), + ..Default::default() + } + .select(&database) + .unwrap(), + output + ); + } + for input in [0, 3] { + assert!( + AccountSelector { + index: Some(input), + ..Default::default() + } + .select(&database) + .is_err() + ); + } + } + + #[test] + fn ambiguous_globs_are_rejected_and_filters_intersect() { + let database = database(); + let mut selector = AccountSelector { + issuer: Some("gIt*".into()), + ..Default::default() + }; + assert!( + selector + .select(&database) + .unwrap_err() + .to_string() + .contains("Ambiguous") + ); + selector.label = Some("a*".into()); + assert_eq!(selector.select(&database).unwrap(), 0); + selector.index = Some(2); + assert!(selector.select(&database).is_err()); + } + + #[test] + fn ids_aliases_and_queries_select_accounts() { + let database = database(); + for selector in [ + AccountSelector { + id: Some("first".into()), + ..Default::default() + }, + AccountSelector { + alias: Some("WORK".into()), + ..Default::default() + }, + AccountSelector { + query: Some("ali".into()), + ..Default::default() + }, + ] { + assert_eq!(selector.select(&database).unwrap(), 0); + } + assert!(AccountSelector::default().select(&database).is_err()); + assert!( + AccountSelector { + query: Some(String::new()), + ..Default::default() + } + .select(&database) + .is_err() + ); + assert!( + AccountSelector { + issuer: Some("[".into()), + ..Default::default() + } + .select(&database) + .is_err() + ); + } + + #[test] + fn aliases_are_unique_ignoring_case_but_can_be_kept_by_their_owner() { + let database = database(); + assert!(validate_alias(&database, "Work", None).is_err()); + assert!(validate_alias(&database, "Work", Some(0)).is_ok()); + assert!(validate_alias(&database, "", None).is_err()); + } +} diff --git a/src/importers/aegis.rs b/src/importers/aegis.rs index a510a5a9..ad2d39dc 100644 --- a/src/importers/aegis.rs +++ b/src/importers/aegis.rs @@ -35,6 +35,11 @@ struct AegisElement { impl From for OTPElement { fn from(value: AegisElement) -> Self { OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: value.info.secret, issuer: value.issuer, label: value.name, diff --git a/src/importers/authy_remote_debug.rs b/src/importers/authy_remote_debug.rs index 290e03f7..76ddee24 100644 --- a/src/importers/authy_remote_debug.rs +++ b/src/importers/authy_remote_debug.rs @@ -1,96 +1,101 @@ -/* -Import from JSON file exported from a script executed from remote debugging. -For more information see https://gist.github.com/gboudreau/94bb0c11a6209c82418d01a59d958c93 -*/ - -use crate::otp::{otp_algorithm::OTPAlgorithm, otp_element::OTPElement, otp_type::OTPType}; -use serde::Deserialize; - -const URL_INDEX: usize = 3; -const PARAMETERS_INDEX: usize = 1; -const DIGITS_DEFAULT_VALUE: u64 = 6; - -#[derive(Deserialize)] -struct AuthyExportedJsonElement { - name: String, - secret: String, - uri: String, -} - -// Newtype pattern to bypass compiler check for impl From for Vec -// https://rust-unofficial.github.io/patterns/patterns/behavioural/newtype.html -#[derive(Deserialize)] -pub struct AuthyExportedList(Vec); - -impl AuthyExportedJsonElement { - pub fn get_type(&self) -> String { - let default_value = "totp"; - let args: Vec<&str> = self.uri.split('/').collect(); - String::from(*args.get(2).unwrap_or(&default_value)) - } - - pub fn get_digits(&self) -> u64 { - let args: Vec<&str> = self.uri.split('/').collect(); - args.get(URL_INDEX) - .and_then(|s| { - let mut args: Vec<&str> = s.split('?').collect(); - if args.get(PARAMETERS_INDEX).is_some() { - Some(args.swap_remove(PARAMETERS_INDEX)) - } else { - None - } - }) - .and_then(|s| { - let mut args: Vec<&str> = - s.split('&').filter(|s| s.starts_with("digits=")).collect(); - if !args.is_empty() { - Some(args.swap_remove(0)) - } else { - None - } - }) - .and_then(|s| s.parse::().ok()) - .unwrap_or(DIGITS_DEFAULT_VALUE) - } - - pub fn get_issuer(&self) -> String { - let default_value = ""; - let args: Vec<&str> = self.uri.split('/').collect(); - match args.get(3) { - Some(s) => { - let args: Vec<&str> = s.split('?').collect(); - let issuer = args.first().unwrap_or(&default_value); - match urlencoding::decode(issuer) { - Ok(r) => r.into_owned(), - Err(_e) => (*issuer).to_string(), - } - } - None => String::from(default_value), - } - } -} - -impl From for OTPElement { - fn from(input: AuthyExportedJsonElement) -> Self { - let type_ = OTPType::from(input.get_type().as_str()); - let counter: Option = (type_ == OTPType::Hotp).then_some(0); - let digits = input.get_digits(); - OTPElement { - secret: input.secret.to_uppercase().replace('=', ""), - issuer: input.get_issuer(), - label: input.name, - digits, - type_, - algorithm: OTPAlgorithm::Sha1, - period: 30, - counter, - pin: None, - } - } -} - -impl From for Vec { - fn from(exported_list: AuthyExportedList) -> Self { - exported_list.0.into_iter().map(Into::into).collect() - } -} +/* +Import from JSON file exported from a script executed from remote debugging. +For more information see https://gist.github.com/gboudreau/94bb0c11a6209c82418d01a59d958c93 +*/ + +use crate::otp::{otp_algorithm::OTPAlgorithm, otp_element::OTPElement, otp_type::OTPType}; +use serde::Deserialize; + +const URL_INDEX: usize = 3; +const PARAMETERS_INDEX: usize = 1; +const DIGITS_DEFAULT_VALUE: u64 = 6; + +#[derive(Deserialize)] +struct AuthyExportedJsonElement { + name: String, + secret: String, + uri: String, +} + +// Newtype pattern to bypass compiler check for impl From for Vec +// https://rust-unofficial.github.io/patterns/patterns/behavioural/newtype.html +#[derive(Deserialize)] +pub struct AuthyExportedList(Vec); + +impl AuthyExportedJsonElement { + pub fn get_type(&self) -> String { + let default_value = "totp"; + let args: Vec<&str> = self.uri.split('/').collect(); + String::from(*args.get(2).unwrap_or(&default_value)) + } + + pub fn get_digits(&self) -> u64 { + let args: Vec<&str> = self.uri.split('/').collect(); + args.get(URL_INDEX) + .and_then(|s| { + let mut args: Vec<&str> = s.split('?').collect(); + if args.get(PARAMETERS_INDEX).is_some() { + Some(args.swap_remove(PARAMETERS_INDEX)) + } else { + None + } + }) + .and_then(|s| { + let mut args: Vec<&str> = + s.split('&').filter(|s| s.starts_with("digits=")).collect(); + if !args.is_empty() { + Some(args.swap_remove(0)) + } else { + None + } + }) + .and_then(|s| s.parse::().ok()) + .unwrap_or(DIGITS_DEFAULT_VALUE) + } + + pub fn get_issuer(&self) -> String { + let default_value = ""; + let args: Vec<&str> = self.uri.split('/').collect(); + match args.get(3) { + Some(s) => { + let args: Vec<&str> = s.split('?').collect(); + let issuer = args.first().unwrap_or(&default_value); + match urlencoding::decode(issuer) { + Ok(r) => r.into_owned(), + Err(_e) => (*issuer).to_string(), + } + } + None => String::from(default_value), + } + } +} + +impl From for OTPElement { + fn from(input: AuthyExportedJsonElement) -> Self { + let type_ = OTPType::from(input.get_type().as_str()); + let counter: Option = (type_ == OTPType::Hotp).then_some(0); + let digits = input.get_digits(); + OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, + secret: input.secret.to_uppercase().replace('=', ""), + issuer: input.get_issuer(), + label: input.name, + digits, + type_, + algorithm: OTPAlgorithm::Sha1, + period: 30, + counter, + pin: None, + } + } +} + +impl From for Vec { + fn from(exported_list: AuthyExportedList) -> Self { + exported_list.0.into_iter().map(Into::into).collect() + } +} diff --git a/src/importers/converted.rs b/src/importers/converted.rs index 9ad3ede4..1cce920a 100644 --- a/src/importers/converted.rs +++ b/src/importers/converted.rs @@ -19,6 +19,11 @@ impl From for OTPElement { let counter: Option = (OTPType::from(converted_json.type_.as_str()) == OTPType::Hotp) .then_some(converted_json.counter); OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: converted_json.secret, issuer: converted_json.issuer.unwrap_or_default(), label: converted_json.label.unwrap_or_default(), diff --git a/src/importers/freeotp_plus.rs b/src/importers/freeotp_plus.rs index fab6e723..ef17a2c3 100644 --- a/src/importers/freeotp_plus.rs +++ b/src/importers/freeotp_plus.rs @@ -52,6 +52,11 @@ impl From for OTPElement { None }; OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, counter, secret: encode_secret(&token.secret), issuer: token.issuer_ext, @@ -120,6 +125,11 @@ mod tests { assert_eq!( vec![ OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "AAAAAAAAAAAAAAAA".to_string(), issuer: "Example2".to_string(), label: "Label2".to_string(), @@ -131,6 +141,11 @@ mod tests { pin: None }, OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "AAAAAAAA".to_string(), issuer: "Example1".to_string(), label: "Label1".to_string(), diff --git a/src/importers/google_authenticator.rs b/src/importers/google_authenticator.rs index 7287a336..9dafc480 100644 --- a/src/importers/google_authenticator.rs +++ b/src/importers/google_authenticator.rs @@ -148,6 +148,11 @@ fn into_otp_element(params: OtpParameters) -> Result { let counter = (type_ == OTPType::Hotp).then_some(params.counter.max(0) as u64); Ok(OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: BASE32_NOPAD.encode(¶ms.secret), issuer: params.issuer, label: params.name, @@ -214,6 +219,11 @@ mod tests { assert_eq!( vec![OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "JBSWY3DP".to_string(), issuer: "Example".to_string(), label: "alice@google.com".to_string(), @@ -237,6 +247,11 @@ mod tests { assert_eq!( vec![OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "JBSWY3DP".to_string(), issuer: "Acme".to_string(), label: "bob".to_string(), diff --git a/src/importers/otp_uri.rs b/src/importers/otp_uri.rs index 761dd859..25ab9711 100644 --- a/src/importers/otp_uri.rs +++ b/src/importers/otp_uri.rs @@ -28,6 +28,11 @@ mod tests { fn test_conversion() { //Arrange let expected_element = OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: String::from("AA"), issuer: String::default(), label: String::from("test"), diff --git a/src/interface/form.rs b/src/interface/form.rs index 0ff24224..ddce7be6 100644 --- a/src/interface/form.rs +++ b/src/interface/form.rs @@ -331,6 +331,19 @@ impl AccountForm { None }; let element = OTPElement { + id: self + .original + .as_ref() + .map(|e| e.id.clone()) + .unwrap_or_default(), + alias: self.original.as_ref().and_then(|e| e.alias.clone()), + tags: self + .original + .as_ref() + .map(|e| e.tags.clone()) + .unwrap_or_default(), + favorite: self.original.as_ref().is_some_and(|e| e.favorite), + order: self.original.as_ref().and_then(|e| e.order), secret: if kind == OTPType::Motp { secret.to_lowercase() } else { @@ -395,7 +408,10 @@ impl App<'_> { *existing = element; self.database.mark_modified(); } else { - self.database.add_element(element); + if let Err(error) = self.database.add_element(element) { + form.error = Some(format!("Could not add account: {error}")); + return; + } } self.cancel_form(); self.search_query.clear(); diff --git a/src/interface/tests.rs b/src/interface/tests.rs index 8eca81b1..ccfecc48 100644 --- a/src/interface/tests.rs +++ b/src/interface/tests.rs @@ -14,6 +14,11 @@ use ratatui::{Terminal, backend::TestBackend, buffer::Buffer}; fn account(issuer: &str, label: &str) -> OTPElement { OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ".into(), issuer: issuer.into(), label: label.into(), diff --git a/src/otp/from_otp_uri.rs b/src/otp/from_otp_uri.rs index 04400ff1..9958e301 100644 --- a/src/otp/from_otp_uri.rs +++ b/src/otp/from_otp_uri.rs @@ -45,6 +45,11 @@ impl FromOtpUri for OTPElement { .and_then(|(_k, v)| v.parse::().ok()); Ok(OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret, issuer, label, diff --git a/src/otp/migrations/mod.rs b/src/otp/migrations/mod.rs index 18b04265..24f9c615 100644 --- a/src/otp/migrations/mod.rs +++ b/src/otp/migrations/mod.rs @@ -1,27 +1,82 @@ -use super::otp_element::OTPDatabase; -struct Migration<'a> { - to_version: u16, // Database version which we are migrating on - migration_function: &'a dyn Fn(&mut OTPDatabase) -> color_eyre::Result<()>, // Function to execute the migration -} -const MIGRATIONS_LIST: [Migration; 1] = [Migration { - to_version: 2, - migration_function: &migrate_to_2, -}]; +use color_eyre::eyre::eyre; -fn migrate_to_2(database: &mut OTPDatabase) -> color_eyre::Result<()> { - database.version = 2; - Ok(()) -} +use super::otp_element::{CURRENT_DATABASE_VERSION, OTPDatabase}; +/// Upgrade legacy vaults without changing account credentials. IDs are also repaired +/// after imports, which can contain missing or duplicate identifiers. pub fn migrate(database: &mut OTPDatabase) -> color_eyre::Result<()> { - let mut binding = MIGRATIONS_LIST; - let migrations = binding.as_mut(); - migrations.sort_unstable_by_key(|c1| c1.to_version); - for i in migrations { - if database.version < i.to_version { - // Do the migration - (i.migration_function)(database)?; - } + if database.version > CURRENT_DATABASE_VERSION { + return Err(eyre!( + "This vault uses a newer database format; upgrade cotp" + )); + } + let changed = database.version < CURRENT_DATABASE_VERSION; + database.ensure_account_ids()?; + database.version = CURRENT_DATABASE_VERSION; + if changed { + database.mark_modified(); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::otp::otp_element::OTPElementBuilder; + + #[test] + fn legacy_accounts_keep_secrets_and_receive_persistent_unique_ids() { + let element = OTPElementBuilder::default() + .secret("JBSWY3DPEHPK3PXP") + .issuer("Example") + .label("alice") + .counter(Some(17)) + .build() + .unwrap(); + let mut database = OTPDatabase::from(vec![element.clone(), element]); + migrate(&mut database).unwrap(); + assert!(database.is_modified()); + assert_eq!(database.version, CURRENT_DATABASE_VERSION); + assert_ne!(database.elements[0].id, database.elements[1].id); + assert_eq!(database.elements[0].secret, "JBSWY3DPEHPK3PXP"); + assert_eq!(database.elements[0].counter, Some(17)); + let serialized = serde_json::to_string(&database).unwrap(); + let mut restored: OTPDatabase = serde_json::from_str(&serialized).unwrap(); + migrate(&mut restored).unwrap(); + assert!(!restored.is_modified()); + assert_eq!(database.elements, restored.elements); + } + + #[test] + fn version_two_without_metadata_deserializes() { + let mut database: OTPDatabase = serde_json::from_str(r#"{"version":2,"elements":[{"secret":"AA","issuer":"Old","label":"account","digits":6,"type":"TOTP","algorithm":"SHA1","period":30,"counter":null,"pin":null}]}"#).unwrap(); + migrate(&mut database).unwrap(); + assert_eq!(database.elements[0].id.len(), 32); + assert!(database.elements[0].tags.is_empty()); + assert!(database.elements[0].alias.is_none()); + } + + #[test] + fn imported_duplicate_ids_are_repaired_without_changing_existing_id() { + let element = OTPElementBuilder::default() + .id("stable-id") + .secret("AA") + .issuer("Example") + .label("alice") + .build() + .unwrap(); + let mut database = OTPDatabase::from(vec![element.clone(), element]); + migrate(&mut database).unwrap(); + assert_eq!(database.elements[0].id, "stable-id"); + assert_ne!(database.elements[1].id, "stable-id"); + } + + #[test] + fn newer_database_format_is_rejected() { + let mut database = OTPDatabase { + version: CURRENT_DATABASE_VERSION + 1, + ..Default::default() + }; + assert!(migrate(&mut database).is_err()); + } +} diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index fd22bc27..a95aa18c 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -1,6 +1,6 @@ use color_eyre::eyre::{ErrReport, eyre}; use derive_builder::Builder; -use std::vec; +use std::{collections::HashSet, vec}; use crate::crypto::cryptography::{argon_derive_key, encrypt_string_with_key, gen_salt}; use crate::otp::otp_error::OtpError; @@ -21,7 +21,7 @@ use super::{ otp_type::OTPType, }; -pub const CURRENT_DATABASE_VERSION: u16 = 2; +pub const CURRENT_DATABASE_VERSION: u16 = 3; #[derive(Serialize, Deserialize, PartialEq, Hash)] pub struct OTPDatabase { @@ -79,14 +79,35 @@ impl OTPDatabase { Ok((key, salt)) } - pub fn add_all(&mut self, mut elements: Vec) { - self.mark_modified(); + pub fn add_all(&mut self, mut elements: Vec) -> color_eyre::Result<()> { + let mut used: HashSet = self + .elements + .iter() + .map(|element| element.id.clone()) + .collect(); + for element in &mut elements { + assign_account_id(element, &mut used)?; + } self.elements.append(&mut elements); + self.mark_modified(); + Ok(()) } - pub fn add_element(&mut self, element: OTPElement) { - self.mark_modified(); - self.elements.push(element); + pub fn add_element(&mut self, element: OTPElement) -> color_eyre::Result<()> { + self.add_all(vec![element]) + } + + /// Give every account a unique identifier, preserving IDs already assigned. + pub fn ensure_account_ids(&mut self) -> color_eyre::Result<()> { + let mut used = HashSet::new(); + let mut changed = false; + for element in &mut self.elements { + changed |= assign_account_id(element, &mut used)?; + } + if changed { + self.mark_modified(); + } + Ok(()) } pub fn mark_modified(&mut self) { @@ -127,6 +148,22 @@ impl OTPDatabase { build_fn(validate = "Self::validate", error = "ErrReport") )] pub struct OTPElement { + /// Stable vault-local identifier, assigned when the database is migrated or saved. + #[serde(default)] + #[builder(default)] + pub id: String, + #[serde(default)] + #[builder(default)] + pub alias: Option, + #[serde(default)] + #[builder(default)] + pub tags: Vec, + #[serde(default)] + #[builder(default)] + pub favorite: bool, + #[serde(default)] + #[builder(default)] + pub order: Option, #[builder(setter(custom))] pub secret: String, pub issuer: String, @@ -146,6 +183,25 @@ pub struct OTPElement { pub pin: Option, } +fn assign_account_id( + element: &mut OTPElement, + used: &mut HashSet, +) -> color_eyre::Result { + if !element.id.is_empty() && used.insert(element.id.clone()) { + return Ok(false); + } + loop { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes) + .map_err(|error| eyre!("Cannot generate account identifier: {error}"))?; + let id = hex::encode(bytes); + if used.insert(id.clone()) { + element.id = id; + return Ok(true); + } + } +} + static ALLOWED_DIGITS_RANGE: std::ops::RangeInclusive = 1..=10; impl OTPElement { @@ -296,6 +352,11 @@ mod test { #[test] fn test_serialization_otp_uri_full_element() { let otp_element = OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: String::from("xr5gh44x7bprcqgrdtulafeevt5rxqlbh5wvked22re43dh2d4mapv5g"), issuer: String::from("IssuerText"), label: String::from("LabelText"), @@ -315,6 +376,11 @@ mod test { #[test] fn test_serialization_otp_uri_no_issuer() { let otp_element = OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: String::from("xr5gh44x7bprcqgrdtulafeevt5rxqlbh5wvked22re43dh2d4mapv5g"), issuer: String::new(), label: String::from("LabelText"), @@ -334,6 +400,11 @@ mod test { #[test] fn test_deserialization_otp_uri() { let expected = OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "xr5gh44x7bprcqgrdtulafeevt5rxqlbh5wvked22re43dh2d4mapv5g".to_uppercase(), issuer: String::from("IssuerText"), label: String::from("LabelText"), @@ -361,6 +432,11 @@ mod test { let invalid_digits_value = 11; let element = OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "xr5gh44x7bprcqgrdtulafeevt5rxqlbh5wvked22re43dh2d4mapv5g".to_uppercase(), issuer: String::from("IssuerText"), label: String::from("LabelText"), @@ -385,6 +461,11 @@ mod test { let invalid_digits_value = 10; let element = OTPElement { + id: String::new(), + alias: None, + tags: Vec::new(), + favorite: false, + order: None, secret: "xr5gh44x7bprcqgrdtulafeevt5rxqlbh5wvked22re43dh2d4mapv5g".to_uppercase(), issuer: String::from("IssuerText"), label: String::from("LabelText"), diff --git a/src/reading.rs b/src/reading.rs index eb752fed..39d834dc 100644 --- a/src/reading.rs +++ b/src/reading.rs @@ -51,6 +51,7 @@ pub fn read_from_file(password: &str) -> color_eyre::Result { .or_else(|_| serde_json::from_str::>(&contents).map(Into::into)) .map_err(ErrReport::from)?; contents.zeroize(); + crate::otp::migrations::migrate(&mut database)?; database.sort(); Ok((database, key, salt)) } diff --git a/tests/selection_integration_tests.rs b/tests/selection_integration_tests.rs new file mode 100644 index 00000000..721e9970 --- /dev/null +++ b/tests/selection_integration_tests.rs @@ -0,0 +1,110 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use predicates::str::contains; + +fn command(path: &std::path::Path) -> assert_cmd::Command { + let mut command = cargo_bin_cmd!("cotp"); + command + .args(["--password-stdin", "--database-path"]) + .arg(path); + command +} + +#[test] +fn account_identity_survives_edit_and_selectors_do_not_choose_ambiguous_accounts() { + let dir = assert_fs::TempDir::new().unwrap(); + let path = dir.path().join("accounts.cotp"); + std::fs::copy("test_samples/cli_integration_test/empty_database", &path).unwrap(); + for (label, alias, counter) in [("alice", "work", "0"), ("bob", "personal", "1")] { + command(&path) + .args([ + "add", + "--issuer", + "Example", + "--label", + label, + "--alias", + alias, + "--type", + "hotp", + "--counter", + counter, + "--secret-stdin", + ]) + .write_stdin("12345678\nGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ\n") + .assert() + .success(); + } + let output = command(&path) + .args(["list", "--json"]) + .write_stdin("12345678\n") + .assert() + .success() + .get_output() + .stdout + .clone(); + let list: serde_json::Value = serde_json::from_slice(&output).unwrap(); + let id = list + .as_array() + .unwrap() + .iter() + .find(|e| e["alias"] == "work") + .unwrap()["id"] + .as_str() + .unwrap() + .to_owned(); + assert_eq!(id.len(), 32); + command(&path) + .args(["extract", "--alias", "WORK"]) + .write_stdin("12345678\n") + .assert() + .success() + .stdout(contains("755224")); + command(&path) + .args(["extract", "--issuer", "example"]) + .write_stdin("12345678\n") + .assert() + .failure() + .stderr(contains("Ambiguous")); + command(&path) + .args(["extract", "--index", "0"]) + .write_stdin("12345678\n") + .assert() + .failure() + .stderr(contains("start at 1")); + command(&path) + .args([ + "edit", + "--id", + &id, + "--issuer", + "Renamed", + "--set-alias", + "primary", + ]) + .write_stdin("12345678\n") + .assert() + .success(); + let output = command(&path) + .args(["list", "--json"]) + .write_stdin("12345678\n") + .assert() + .success() + .get_output() + .stdout + .clone(); + let list: serde_json::Value = serde_json::from_slice(&output).unwrap(); + let account = list + .as_array() + .unwrap() + .iter() + .find(|e| e["id"] == id) + .unwrap(); + assert_eq!(account["issuer"], "Renamed"); + assert_eq!(account["alias"], "primary"); + command(&path) + .args(["delete", "--issuer", "*"]) + .write_stdin("12345678\n") + .assert() + .failure() + .stderr(contains("Ambiguous")); +} From e8fd513489f250d2d3414892fd28a9f2bfeb9bc0 Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:14:38 +0200 Subject: [PATCH 02/21] feat(security): validate vault envelopes and add conformance coverage --- .github/workflows/deploy.yml | 11 ++ Cargo.lock | 178 ++++++++++++++++++++++++++++++- Cargo.toml | 1 + SECURITY.md | 36 +++++++ docs/vault-format.md | 29 +++++ src/crypto/cryptography.rs | 55 +++++++++- src/crypto/encrypted_database.rs | 64 +++++------ src/otp/algorithms/hotp_maker.rs | 25 +++++ src/otp/algorithms/totp_maker.rs | 78 +++++++++++++- src/otp/otp_error.rs | 6 +- 10 files changed, 442 insertions(+), 41 deletions(-) create mode 100644 SECURITY.md create mode 100644 docs/vault-format.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4e43b70e..ef8646b3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -88,6 +88,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + id-token: write + attestations: write steps: - name: Checkout sources uses: actions/checkout@v7 @@ -128,6 +130,15 @@ jobs: fi done + - name: Create release checksums + working-directory: dist + run: sha256sum *.tar.xz *.zip > SHA256SUMS + + - name: Attest release artifacts + uses: actions/attest-build-provenance@v3 + with: + subject-path: dist/* + - name: Upload binaries to release uses: svenstaro/upload-release-action@v2 with: diff --git a/Cargo.lock b/Cargo.lock index fd00ddd5..40a3ec8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,7 +215,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", ] [[package]] @@ -224,6 +233,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -270,6 +289,30 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "bstr" version = "1.13.1" @@ -351,6 +394,7 @@ dependencies = [ "cfg-if", "cipher", "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -526,6 +570,12 @@ dependencies = [ "x11-clipboard", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cotp" version = "1.11.0" @@ -549,6 +599,7 @@ dependencies = [ "hmac", "md-5", "predicates", + "proptest", "prost", "qrcode", "ratatui", @@ -982,7 +1033,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex", ] @@ -1352,6 +1403,16 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "indoc" version = "2.0.8" @@ -1939,7 +2000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.8", ] [[package]] @@ -2048,6 +2109,15 @@ dependencies = [ "termtree", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -2057,6 +2127,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" +dependencies = [ + "bit-set 0.11.1", + "bit-vec 0.10.1", + "bitflags 2.13.2", + "chacha20", + "core_detect", + "num-traits", + "rand 0.10.3", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "prost" version = "0.14.4" @@ -2104,6 +2194,12 @@ dependencies = [ "image", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.47" @@ -2134,6 +2230,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -2146,6 +2252,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_xorshift" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "ratatui" version = "0.30.2" @@ -2375,6 +2490,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -2902,6 +3029,36 @@ dependencies = [ "zerovec", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2966,6 +3123,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.27" @@ -3432,6 +3595,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index bdc09498..5099cc33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ tui-input = { version = "0.15.5", default-features = false, features = ["crosste tempfile = "3.27.0" [dev-dependencies] +proptest = "1" assert_cmd = "2.2.2" assert_fs = "1.1.4" predicates = "3.1.4" diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..bef54453 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,36 @@ +# Security + +Report suspected vulnerabilities privately to commoncargo@tutanota.com. Include the +cotp version, operating system, reproduction using synthetic data, and expected +behavior. Never attach a real vault, password, OTP secret, URI, or backup code. + +## Threat model + +The encrypted vault protects secrets at rest when the password is unknown. It does +not protect an unlocked session from malware running as the same user, a compromised +terminal, clipboard history, screen capture, or privileged memory inspection. OTP +codes and QR/URI exports are sensitive. The application makes no claim of a completed +independent security audit. + +Password-based operation is offline. Optional integrations have their own trust +boundaries and require explicit configuration. Back up the encrypted vault before +upgrading; losing the password and all usable backups loses access to its secrets. + +See [vault format](docs/vault-format.md) for the encryption and versioning contract. +The test suite covers all RFC 4226 Appendix D and RFC 6238 Appendix B vectors and +property-based malformed-envelope inputs. Run `cargo test malformed_tests` for the +latter; `PROPTEST_CASES=10000` increases the case count. Never run experimental fuzzing against a real vault. + +## Verify release provenance + +Releases produced by the updated Deploy workflow include SHA256SUMS and GitHub +artifact attestations signed using the workflow's OIDC identity. Download an archive +and check it with: + +```sh +sha256sum --check SHA256SUMS --ignore-missing +gh attestation verify cotp-VERSION-PLATFORM.tar.xz --repo replydev/cotp +``` + +Attestations bind the artifact digest to the repository/workflow that built it; +they do not constitute a security audit. Older releases may not have attestations. diff --git a/docs/vault-format.md b/docs/vault-format.md new file mode 100644 index 00000000..6bde3c6d --- /dev/null +++ b/docs/vault-format.md @@ -0,0 +1,29 @@ +# Vault format + +The database is a UTF-8 JSON envelope with `version`, `nonce`, `salt`, and `cipher`. +Envelope version 1 uses standard padded Base64 for the binary fields: + +- `salt`: 16 random bytes, freshly generated on password change. +- `nonce`: 24 random bytes, freshly generated on every encryption. +- `cipher`: XChaCha20-Poly1305 ciphertext with its authentication tag. + +The 32-byte encryption key is derived using Argon2id version 0x13 with 32768 KiB +memory, four iterations and four lanes. The encrypted payload is JSON containing a +separate schema `version` and an `elements` array. Historical schema 1 may instead +be a bare array. Schema migrations preserve OTP secrets and parameters. The +cryptographic envelope version is separate from the payload schema version; do not +change KDF parameters without a new envelope version and migration support. + +An account stores its issuer, label, Base32 secret, type, HMAC algorithm, digit +count, period, optional HOTP counter and optional scheme-specific PIN. Never log +the payload. Plaintext portability exports must be treated like passwords. + +Unknown envelope versions, malformed Base64 and invalid nonce/salt lengths are +rejected. Authentication failure can mean a wrong password or damaged ciphertext; +no plaintext is returned in either case. The envelope metadata is not additional +authenticated data in version 1; its version and lengths are checked before use. + +Writes use a temporary file in the destination directory, sync its content, then +atomically replace the destination. On Unix the directory is synced as well. +Atomic replacement prevents partial-file writes; transaction conflict handling is +a separate layer. Filesystem durability still depends on the filesystem and hardware. diff --git a/src/crypto/cryptography.rs b/src/crypto/cryptography.rs index 3ca47f8f..e25bc912 100644 --- a/src/crypto/cryptography.rs +++ b/src/crypto/cryptography.rs @@ -3,6 +3,7 @@ use chacha20poly1305::aead::Aead; use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce}; use color_eyre::eyre::{ErrReport, eyre}; use data_encoding::BASE64; +use zeroize::Zeroizing; use super::encrypted_database::EncryptedDatabase; @@ -62,15 +63,23 @@ pub fn decrypt_string( //encrypted text is an encrypted database json serialized object let encrypted_database: EncryptedDatabase = serde_json::from_str(encrypted_text) .map_err(|e| eyre!("Error during encrypted database deserialization: {e}"))?; + if encrypted_database.version() != 1 { + return Err(eyre!("Unsupported encrypted database version")); + } let nonce = BASE64 .decode(encrypted_database.nonce().as_bytes()) - .expect("Cannot decode Base64 nonce"); + .map_err(|_| eyre!("Invalid Base64 nonce"))?; let cipher_text = BASE64 .decode(encrypted_database.cipher().as_bytes()) - .expect("Cannot decode Base64 cipher"); - let salt = BASE64.decode(encrypted_database.salt().as_bytes()).unwrap(); + .map_err(|_| eyre!("Invalid Base64 ciphertext"))?; + let salt = BASE64 + .decode(encrypted_database.salt().as_bytes()) + .map_err(|_| eyre!("Invalid Base64 salt"))?; + if nonce.len() != XCHACHA20_POLY1305_NONCE_LENGTH || salt.len() != ARGON2ID_SALT_LENGTH { + return Err(eyre!("Invalid nonce or salt length")); + } - let key: Vec = argon_derive_key(password.as_bytes(), salt.as_slice())?; + let key = Zeroizing::new(argon_derive_key(password.as_bytes(), salt.as_slice())?); let aead = XChaCha20Poly1305::new_from_slice(&key) .map_err(|e| eyre!("Invalid encryption key length: {e}"))?; @@ -80,7 +89,7 @@ pub fn decrypt_string( .decrypt(&nonce, cipher_text.as_slice()) .map_err(|_| eyre!("Wrong password"))?; let from_utf8 = String::from_utf8(decrypted).map_err(ErrReport::from)?; - Ok((from_utf8, key, salt)) + Ok((from_utf8, key.to_vec(), salt)) } #[cfg(test)] @@ -99,3 +108,39 @@ mod tests { assert_eq!(String::from("Secret data@#[]ò"), decrypted); } } + +#[cfg(test)] +mod malformed_tests { + use super::*; + use proptest::prelude::*; + + proptest! { + #[test] + fn malformed_json_never_panics(input in ".{0,2048}") { + let _ = decrypt_string(&input, "test password"); + } + #[test] + fn malformed_envelopes_never_panic(nonce in ".{0,40}", salt in ".{0,30}", cipher in ".{0,100}") { + let input = serde_json::json!({"version":1,"nonce":nonce,"salt":salt,"cipher":cipher}); + let _ = decrypt_string(&input.to_string(), "test password"); + } + } + + #[test] + fn tampering_and_wrong_password_fail_authentication() { + let salt = gen_salt().unwrap(); + let key = argon_derive_key(b"correct password", &salt).unwrap(); + let encrypted = encrypt_string_with_key("private vault", &key, &salt).unwrap(); + let serialized = serde_json::to_string(&encrypted).unwrap(); + assert!(decrypt_string(&serialized, "wrong password").is_err()); + let mut value: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + let mut cipher = BASE64 + .decode(value["cipher"].as_str().unwrap().as_bytes()) + .unwrap(); + cipher[0] ^= 1; + value["cipher"] = BASE64.encode(&cipher).into(); + assert!(decrypt_string(&value.to_string(), "correct password").is_err()); + value["version"] = 65535.into(); + assert!(decrypt_string(&value.to_string(), "correct password").is_err()); + } +} diff --git a/src/crypto/encrypted_database.rs b/src/crypto/encrypted_database.rs index 28f2ef9c..b5469efe 100644 --- a/src/crypto/encrypted_database.rs +++ b/src/crypto/encrypted_database.rs @@ -1,30 +1,34 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize)] -pub struct EncryptedDatabase { - version: u16, - nonce: String, - salt: String, - cipher: String, -} - -impl EncryptedDatabase { - pub fn new(version: u16, nonce: String, salt: String, cipher: String) -> EncryptedDatabase { - EncryptedDatabase { - version, - nonce, - salt, - cipher, - } - } - - pub fn nonce(&self) -> &str { - &self.nonce - } - pub fn salt(&self) -> &str { - &self.salt - } - pub fn cipher(&self) -> &str { - &self.cipher - } -} +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct EncryptedDatabase { + version: u16, + nonce: String, + salt: String, + cipher: String, +} + +impl EncryptedDatabase { + pub fn new(version: u16, nonce: String, salt: String, cipher: String) -> EncryptedDatabase { + EncryptedDatabase { + version, + nonce, + salt, + cipher, + } + } + + pub fn version(&self) -> u16 { + self.version + } + + pub fn nonce(&self) -> &str { + &self.nonce + } + pub fn salt(&self) -> &str { + &self.salt + } + pub fn cipher(&self) -> &str { + &self.cipher + } +} diff --git a/src/otp/algorithms/hotp_maker.rs b/src/otp/algorithms/hotp_maker.rs index c160b66e..d4d2e21c 100644 --- a/src/otp/algorithms/hotp_maker.rs +++ b/src/otp/algorithms/hotp_maker.rs @@ -73,3 +73,28 @@ mod tests { ); } } + +#[cfg(test)] +mod rfc_vectors { + use super::*; + use crate::otp::otp_element::format_code; + + #[test] + fn all_rfc4226_vectors() { + let secret = BASE32_NOPAD.encode(b"12345678901234567890"); + let expected = [ + "755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583", + "399871", "520489", + ]; + for (counter, expected) in expected.into_iter().enumerate() { + assert_eq!( + format_code( + 6, + hotp(&secret, OTPAlgorithm::Sha1, counter as u64).unwrap() + ) + .unwrap(), + expected + ); + } + } +} diff --git a/src/otp/algorithms/totp_maker.rs b/src/otp/algorithms/totp_maker.rs index c82d4157..60abdae1 100644 --- a/src/otp/algorithms/totp_maker.rs +++ b/src/otp/algorithms/totp_maker.rs @@ -8,7 +8,7 @@ use super::hotp_maker::hotp; pub fn totp(secret: &str, algorithm: OTPAlgorithm, period: u64) -> Result { let time = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) - .unwrap() + .map_err(|_| OtpError::InvalidTime)? .as_secs(); generate_totp(secret, algorithm, time, period, 0) } @@ -20,7 +20,11 @@ fn generate_totp( time_step: u64, skew: i64, ) -> Result { - hotp(secret, algorithm, ((time as i64 + skew) as u64) / time_step) + if time_step == 0 { + return Err(OtpError::InvalidPeriod); + } + let adjusted = time.checked_add_signed(skew).ok_or(OtpError::InvalidTime)?; + hotp(secret, algorithm, adjusted / time_step) } #[cfg(test)] @@ -56,3 +60,73 @@ mod tests { assert_eq!("295439", code) } } + +#[cfg(test)] +mod rfc_vectors { + use super::*; + use crate::otp::otp_element::format_code; + use data_encoding::BASE32_NOPAD; + + // RFC 6238, Appendix B. Each hash uses its specified secret length. + #[test] + fn all_rfc6238_vectors() { + let times = [ + 59, + 1111111109, + 1111111111, + 1234567890, + 2000000000, + 20000000000, + ]; + let cases = [ + ( + OTPAlgorithm::Sha1, + 20, + [ + "94287082", "07081804", "14050471", "89005924", "69279037", "65353130", + ], + ), + ( + OTPAlgorithm::Sha256, + 32, + [ + "46119246", "68084774", "67062674", "91819424", "90698825", "77737706", + ], + ), + ( + OTPAlgorithm::Sha512, + 64, + [ + "90693936", "25091201", "99943326", "93441116", "38618901", "47863826", + ], + ), + ]; + for (algorithm, len, expected) in cases { + let bytes: Vec = b"1234567890".iter().copied().cycle().take(len).collect(); + let secret = BASE32_NOPAD.encode(&bytes); + for (time, expected) in times.into_iter().zip(expected) { + assert_eq!( + format_code(8, generate_totp(&secret, algorithm, time, 30, 0).unwrap()) + .unwrap(), + expected + ); + } + } + } + + #[test] + fn invalid_time_parameters_are_errors() { + assert_eq!( + generate_totp("AA", OTPAlgorithm::Sha1, 1, 0, 0), + Err(OtpError::InvalidPeriod) + ); + assert_eq!( + generate_totp("AA", OTPAlgorithm::Sha1, 0, 30, -1), + Err(OtpError::InvalidTime) + ); + assert_eq!( + generate_totp("AA", OTPAlgorithm::Sha1, u64::MAX, 30, 1), + Err(OtpError::InvalidTime) + ); + } +} diff --git a/src/otp/otp_error.rs b/src/otp/otp_error.rs index f8aefd9d..2742af07 100644 --- a/src/otp/otp_error.rs +++ b/src/otp/otp_error.rs @@ -9,7 +9,9 @@ pub enum OtpError { MissingCounter, // Missing counter for HOTP codes InvalidOffset, // Invalid offset InvalidDigest, // Invalid digest - InvalidDigits, // Invalid Digits value (too high or low) + InvalidPeriod, + InvalidTime, + InvalidDigits, // Invalid Digits value (too high or low) } impl Display for OtpError { @@ -23,6 +25,8 @@ impl Display for OtpError { OtpError::InvalidDigest => f.write_str("Invalid digest"), OtpError::InvalidOffset => f.write_str("Invalid offset"), OtpError::ShortSecret => f.write_str("Secret length less than 16 bytes"), + OtpError::InvalidPeriod => f.write_str("Period must be greater than zero"), + OtpError::InvalidTime => f.write_str("Time is outside the supported range"), OtpError::InvalidDigits => f.write_str("Digits value too high or low"), } } From 1de98df83135504fb6c74d6f6720afca598511fa Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:15:01 +0200 Subject: [PATCH 03/21] feat(import): enroll local QR images and plan repeatable validated imports --- Cargo.lock | 136 ++++++++++- Cargo.toml | 2 + docs/imports.md | 25 ++ src/arguments/export.rs | 8 +- src/arguments/import.rs | 181 ++++++++++---- src/exporters/mod.rs | 34 +-- src/importers/authy_remote_debug.rs | 124 +++------- src/importers/enrollment.rs | 331 ++++++++++++++++++++++++++ src/importers/freeotp_plus.rs | 2 +- src/importers/google_authenticator.rs | 34 ++- src/importers/mod.rs | 2 + src/otp/from_otp_uri.rs | 184 +++++++------- tests/import_integration_tests.rs | 77 ++++++ 13 files changed, 870 insertions(+), 270 deletions(-) create mode 100644 docs/imports.md create mode 100644 src/importers/enrollment.rs create mode 100644 tests/import_integration_tests.rs diff --git a/Cargo.lock b/Cargo.lock index fd00ddd5..4af985b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -547,12 +547,14 @@ dependencies = [ "globset", "hex", "hmac", + "image", "md-5", "predicates", "prost", "qrcode", "ratatui", "rpassword", + "rqrr", "rust-argon2", "scrypt", "serde", @@ -591,6 +593,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -992,6 +1003,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1021,6 +1041,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -1075,6 +1105,34 @@ dependencies = [ "slab", ] +[[package]] +name = "g2gen" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc" +dependencies = [ + "g2poly", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "g2p" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad" +dependencies = [ + "g2gen", + "g2poly", +] + +[[package]] +name = "g2poly" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" + [[package]] name = "generic-array" version = "0.14.7" @@ -1158,6 +1216,17 @@ dependencies = [ "walkdir", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1344,6 +1413,9 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", + "png", + "zune-core", + "zune-jpeg", ] [[package]] @@ -1421,7 +1493,7 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209" dependencies = [ - "hashbrown", + "hashbrown 0.17.1", "portable-atomic", "thiserror 2.0.21", ] @@ -1523,13 +1595,22 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru" version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" dependencies = [ - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -1613,6 +1694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1976,6 +2058,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "poly1305" version = "0.9.1" @@ -2171,10 +2266,10 @@ dependencies = [ "bitflags 2.13.2", "compact_str", "critical-section", - "hashbrown", + "hashbrown 0.17.1", "itertools", "kasuari", - "lru", + "lru 0.18.5", "palette", "serde", "strum", @@ -2234,7 +2329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ "bitflags 2.13.2", - "hashbrown", + "hashbrown 0.17.1", "indoc", "instability", "itertools", @@ -2306,6 +2401,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rqrr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbe87d9e8db95652c25ded2418150e00b08c2fde09e23ec15896d2c470c6631" +dependencies = [ + "g2p", + "lru 0.16.4", +] + [[package]] name = "rtoolbox" version = "0.0.6" @@ -2552,6 +2657,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "siphasher" version = "1.0.4" @@ -3589,3 +3700,18 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index bdc09498..a7c184f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,8 @@ globset = "0.4.20" prost = "0.14.4" tui-input = { version = "0.15.5", default-features = false, features = ["crossterm"] } tempfile = "3.27.0" +rqrr = { version = "0.10", default-features = false } +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } [dev-dependencies] assert_cmd = "2.2.2" diff --git a/docs/imports.md b/docs/imports.md new file mode 100644 index 00000000..7b42e0ee --- /dev/null +++ b/docs/imports.md @@ -0,0 +1,25 @@ +# Enrollment, migration, and plaintext export + +Import PNG/JPEG QR screenshots, newline-separated OTP URIs, Google Authenticator migration URIs, or supported JSON backups directly. QR decoding runs locally and scans every QR code in the image. JSON format detection covers cotp, andOTP, Aegis, FreeOTP+, converted backups, Authy remote-debug exports, and OTP URI lists. Existing explicit format flags and `--path` remain available. + +```sh +cotp import screenshot.png --dry-run +cotp import accounts.json --duplicates skip +cotp import accounts.json --duplicates replace +cotp import accounts.json --duplicates keep-both +cotp import - < otp-uris.txt +cotp import --aegis --path aegis.json +``` + +Imports display account names and parameters on stderr before saving. Interactive imports request confirmation; `--yes` accepts the preview. A dry run never changes existing vault contents. Imports reject the entire batch if any entry is invalid unless `--skip-invalid` is supplied. Errors identify source entries without printing secrets. PNG/JPEG inputs are limited to 32 MiB and bounded decoded image dimensions/memory. + +Repeated imports skip duplicate credentials by default. Identity comprises the normalized secret, OTP scheme, algorithm, digit count, period, and PIN; account display names do not affect identity. Replacement preserves the greater HOTP counter, so an older backup cannot roll the counter backwards. `keep-both` deliberately adds duplicate credentials. + +Plaintext exports now require explicit acknowledgment: + +```sh +cotp export --plaintext --path migration.json +cotp export --plaintext --otp-uri --path otp-uris.json +``` + +The default filename is `exported.plaintext.json`. Exports contain authentication secrets. Files are created privately and atomically, and existing paths are never overwritten. Use the encrypted backup workflow for routine backups. diff --git a/src/arguments/export.rs b/src/arguments/export.rs index 04fb1818..9df4ebc0 100644 --- a/src/arguments/export.rs +++ b/src/arguments/export.rs @@ -13,6 +13,10 @@ use super::SubcommandExecutor; #[derive(Args)] pub struct ExportArgs { + /// Acknowledge that exported files contain unencrypted authentication secrets + #[arg(long, required = true)] + pub plaintext: bool, + /// Export file path #[arg(short, long, default_value = ".")] pub path: PathBuf, @@ -57,7 +61,7 @@ impl SubcommandExecutor for ExportArgs { fn run_command(self, database: OTPDatabase) -> color_eyre::Result { let export_format = self.format.unwrap_or_default(); let exported_path = if self.path.is_dir() { - self.path.join("exported.cotp") + self.path.join("exported.plaintext.json") } else { self.path }; @@ -77,7 +81,7 @@ impl SubcommandExecutor for ExportArgs { unreachable!("Unreachable code"); } .map(|path| { - println!( + eprintln!( "Exported to path: {}", path.to_str().unwrap_or("Failed to encode path") ); diff --git a/src/arguments/import.rs b/src/arguments/import.rs index c9273e46..f36f56ea 100644 --- a/src/arguments/import.rs +++ b/src/arguments/import.rs @@ -1,33 +1,44 @@ -use std::path::PathBuf; - -use clap::Args; -use color_eyre::eyre::eyre; - +use super::SubcommandExecutor; use crate::{ - exporters::otp_uri::OtpUriList, importers::{ - aegis::AegisJson, aegis_encrypted::AegisEncryptedDatabase, - authy_remote_debug::AuthyExportedList, converted::ConvertedJsonList, - freeotp_plus::FreeOTPPlusJson, google_authenticator::import_from_google_authenticator, - importer::import_from_path, + aegis_encrypted::AegisEncryptedDatabase, + enrollment::{self, DuplicatePolicy, ImportReport}, }, - otp::otp_element::{OTPDatabase, OTPElement}, + otp::otp_element::OTPDatabase, +}; +use clap::Args; +use color_eyre::eyre::{Result, eyre}; +use std::{ + io::{self, IsTerminal, Read, Write}, + path::PathBuf, }; - -use super::SubcommandExecutor; #[derive(Args)] pub struct ImportArgs { #[command(flatten)] pub backup_type: BackupType, - - /// Backup file path - #[arg(short, long)] - pub path: PathBuf, + /// Backup, QR image, or URI text file; use - to read standard input + #[arg(value_name = "INPUT", conflicts_with = "path")] + pub input: Option, + /// Backup file path (legacy alias for positional INPUT) + #[arg(short, long, required_unless_present = "input")] + pub path: Option, + /// Preview changes without modifying the vault + #[arg(long)] + pub dry_run: bool, + /// How to handle accounts with the same secret and OTP parameters + #[arg(long, value_enum, default_value = "skip")] + pub duplicates: DuplicatePolicy, + /// Import valid entries even when other entries are invalid + #[arg(long)] + pub skip_invalid: bool, + /// Accept the displayed preview without confirmation + #[arg(long, short = 'y')] + pub yes: bool, } #[derive(Args)] -#[group(required = true, multiple = false)] +#[group(required = false, multiple = false)] pub struct BackupType { /// Import from cotp backup #[arg(short, long)] @@ -75,37 +86,115 @@ pub struct BackupType { pub otp_uri: bool, } -impl SubcommandExecutor for ImportArgs { - fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { - let path = self.path; - - let backup_type = self.backup_type; +impl BackupType { + fn selected(&self) -> Option<&'static str> { + [ + (self.cotp, "cotp"), + (self.andotp, "andotp"), + (self.aegis, "aegis"), + (self.aegis_encrypted, "aegis-encrypted"), + (self.freeotp_plus, "freeotp-plus"), + (self.authy_exported, "authy-exported"), + (self.google_authenticator, "google-authenticator"), + ( + self.authy || self.microsoft_authenticator || self.freeotp, + "converted", + ), + (self.otp_uri, "otp-uri"), + ] + .into_iter() + .find_map(|(selected, name)| selected.then_some(name)) + } +} - let result = if backup_type.cotp { - import_from_path::(path) - } else if backup_type.andotp { - import_from_path::>(path) - } else if backup_type.aegis { - import_from_path::(path) - } else if backup_type.aegis_encrypted { - import_from_path::(path) - } else if backup_type.freeotp_plus { - import_from_path::(path) - } else if backup_type.authy_exported { - import_from_path::(path) - } else if backup_type.google_authenticator { - import_from_google_authenticator(path) - } else if backup_type.authy || backup_type.microsoft_authenticator || backup_type.freeotp { - import_from_path::(path) - } else if backup_type.otp_uri { - import_from_path::(path) +impl SubcommandExecutor for ImportArgs { + fn run_command(self, mut database: OTPDatabase) -> Result { + let path = self + .input + .or(self.path) + .ok_or_else(|| eyre!("Import input is required"))?; + let mut bytes = zeroize::Zeroizing::new(Vec::new()); + if path.as_os_str() == "-" { + io::stdin() + .take(32 * 1024 * 1024 + 1) + .read_to_end(&mut bytes)?; + } else { + std::fs::File::open(&path)? + .take(32 * 1024 * 1024 + 1) + .read_to_end(&mut bytes)?; + } + if bytes.len() > 32 * 1024 * 1024 { + return Err(eyre!("Import input exceeds 32 MiB")); + } + let report = if image::guess_format(&bytes).is_ok() { + enrollment::decode_qr(&bytes)? } else { - return Err(eyre!("Invalid arguments provided")); + let content = std::str::from_utf8(&bytes) + .map_err(|_| eyre!("Input is not UTF-8 text or a supported QR image"))? + .trim(); + if content.starts_with("otpauth") || self.backup_type.google_authenticator { + enrollment::parse_uris(content) + } else { + let value: serde_json::Value = serde_json::from_str(content).map_err(|_| { + eyre!("Invalid JSON; expected a backup, OTP URI text, or QR image") + })?; + let format = match self.backup_type.selected() { + Some(format) => format, + None => enrollment::detect_format(&value)?, + }; + if format == "aegis-encrypted" { + let mut report = ImportReport::default(); + report.record(1, enrollment::convert::(value)); + report + } else { + enrollment::parse_json(value, format)? + } + } }; - - let elements = result.map_err(|e| eyre!("{e}"))?; - - database.add_all(elements); + for error in &report.errors { + eprintln!("{error}"); + } + if !report.errors.is_empty() && !self.skip_invalid { + return Err(eyre!( + "Import rejected: {} invalid entries; use --skip-invalid to accept valid entries", + report.errors.len() + )); + } + if report.elements.is_empty() { + return Err(eyre!("No valid accounts found in import")); + } + for element in &report.elements { + eprintln!( + "{} / {} ({}, {} digits)", + element.issuer.escape_default(), + element.label.escape_default(), + element.type_, + element.digits + ); + } + eprintln!( + "{} valid accounts; duplicate policy: {:?}{}", + report.elements.len(), + self.duplicates, + if self.dry_run { " (dry run)" } else { "" } + ); + if self.dry_run { + return Ok(database); + } + if !self.yes && io::stdin().is_terminal() { + eprint!("Apply import? [y/N] "); + io::stderr().flush()?; + let mut answer = String::new(); + io::stdin().read_line(&mut answer)?; + if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") { + return Err(eyre!("Import cancelled")); + } + } + let (added, replaced, skipped) = + enrollment::apply(&mut database, report.elements, self.duplicates); + eprintln!( + "Import complete: {added} added, {replaced} replaced, {skipped} duplicates skipped" + ); Ok(database) } } diff --git a/src/exporters/mod.rs b/src/exporters/mod.rs index 04ee935f..0c8925a2 100644 --- a/src/exporters/mod.rs +++ b/src/exporters/mod.rs @@ -1,7 +1,6 @@ -use std::{fs::File, io::Write, path::PathBuf}; +use std::{io::Write, path::PathBuf}; use serde::Serialize; -use zeroize::Zeroize; pub mod andotp; pub mod freeotp_plus; @@ -11,18 +10,23 @@ pub fn do_export(to_be_saved: &T, exported_path: PathBuf) -> Result { - if contents == "[]" { - return Err("No contents to export, skipping...".to_owned()); - } - let mut file = File::create(&exported_path).expect("Cannot create file"); - let contents_bytes = contents.as_bytes(); - file.write_all(contents_bytes) - .expect("Failed to write contents"); - contents.zeroize(); - Ok(exported_path) - } - Err(e) => Err(format!("{e:?}")), + let contents = + zeroize::Zeroizing::new(serde_json::to_vec(to_be_saved).map_err(|e| e.to_string())?); + if contents.as_slice() == b"[]" { + return Err("No contents to export, skipping...".to_owned()); } + let parent = exported_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| std::path::Path::new(".")); + let mut file = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + file.write_all(&contents).map_err(|e| e.to_string())?; + file.as_file().sync_all().map_err(|e| e.to_string())?; + file.persist_noclobber(&exported_path).map_err(|e| { + format!( + "Cannot create export (existing files are never overwritten): {}", + e.error + ) + })?; + Ok(exported_path) } diff --git a/src/importers/authy_remote_debug.rs b/src/importers/authy_remote_debug.rs index 290e03f7..04f40d55 100644 --- a/src/importers/authy_remote_debug.rs +++ b/src/importers/authy_remote_debug.rs @@ -1,96 +1,28 @@ -/* -Import from JSON file exported from a script executed from remote debugging. -For more information see https://gist.github.com/gboudreau/94bb0c11a6209c82418d01a59d958c93 -*/ - -use crate::otp::{otp_algorithm::OTPAlgorithm, otp_element::OTPElement, otp_type::OTPType}; -use serde::Deserialize; - -const URL_INDEX: usize = 3; -const PARAMETERS_INDEX: usize = 1; -const DIGITS_DEFAULT_VALUE: u64 = 6; - -#[derive(Deserialize)] -struct AuthyExportedJsonElement { - name: String, - secret: String, - uri: String, -} - -// Newtype pattern to bypass compiler check for impl From for Vec -// https://rust-unofficial.github.io/patterns/patterns/behavioural/newtype.html -#[derive(Deserialize)] -pub struct AuthyExportedList(Vec); - -impl AuthyExportedJsonElement { - pub fn get_type(&self) -> String { - let default_value = "totp"; - let args: Vec<&str> = self.uri.split('/').collect(); - String::from(*args.get(2).unwrap_or(&default_value)) - } - - pub fn get_digits(&self) -> u64 { - let args: Vec<&str> = self.uri.split('/').collect(); - args.get(URL_INDEX) - .and_then(|s| { - let mut args: Vec<&str> = s.split('?').collect(); - if args.get(PARAMETERS_INDEX).is_some() { - Some(args.swap_remove(PARAMETERS_INDEX)) - } else { - None - } - }) - .and_then(|s| { - let mut args: Vec<&str> = - s.split('&').filter(|s| s.starts_with("digits=")).collect(); - if !args.is_empty() { - Some(args.swap_remove(0)) - } else { - None - } - }) - .and_then(|s| s.parse::().ok()) - .unwrap_or(DIGITS_DEFAULT_VALUE) - } - - pub fn get_issuer(&self) -> String { - let default_value = ""; - let args: Vec<&str> = self.uri.split('/').collect(); - match args.get(3) { - Some(s) => { - let args: Vec<&str> = s.split('?').collect(); - let issuer = args.first().unwrap_or(&default_value); - match urlencoding::decode(issuer) { - Ok(r) => r.into_owned(), - Err(_e) => (*issuer).to_string(), - } - } - None => String::from(default_value), - } - } -} - -impl From for OTPElement { - fn from(input: AuthyExportedJsonElement) -> Self { - let type_ = OTPType::from(input.get_type().as_str()); - let counter: Option = (type_ == OTPType::Hotp).then_some(0); - let digits = input.get_digits(); - OTPElement { - secret: input.secret.to_uppercase().replace('=', ""), - issuer: input.get_issuer(), - label: input.name, - digits, - type_, - algorithm: OTPAlgorithm::Sha1, - period: 30, - counter, - pin: None, - } - } -} - -impl From for Vec { - fn from(exported_list: AuthyExportedList) -> Self { - exported_list.0.into_iter().map(Into::into).collect() - } -} +//! Import the URI-bearing JSON produced by Authy's remote-debug export script. +use crate::otp::{from_otp_uri::FromOtpUri, otp_element::OTPElement}; +use color_eyre::eyre::ErrReport; +use serde::Deserialize; + +#[derive(Deserialize)] +struct AuthyExportedJsonElement { + name: String, + uri: String, +} + +#[derive(Deserialize)] +pub struct AuthyExportedList(Vec); + +impl TryFrom for Vec { + type Error = ErrReport; + fn try_from(exported: AuthyExportedList) -> Result { + exported + .0 + .into_iter() + .map(|entry| { + let mut element = OTPElement::from_otp_uri(&entry.uri)?; + element.label = entry.name.clone(); + Ok(element) + }) + .collect() + } +} diff --git a/src/importers/enrollment.rs b/src/importers/enrollment.rs new file mode 100644 index 00000000..8e2c031f --- /dev/null +++ b/src/importers/enrollment.rs @@ -0,0 +1,331 @@ +//! Local-only enrollment and import planning. Never include secret input in diagnostics. +use crate::otp::{ + from_otp_uri::FromOtpUri, + otp_element::{OTPDatabase, OTPElement}, + otp_type::OTPType, +}; +use clap::ValueEnum; +use color_eyre::eyre::{Result, eyre}; +use serde_json::Value; + +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +pub enum DuplicatePolicy { + #[default] + Skip, + Replace, + KeepBoth, +} + +#[derive(Default)] +pub struct ImportReport { + pub elements: Vec, + pub errors: Vec, +} + +impl ImportReport { + pub fn record(&mut self, index: usize, result: Result>) { + match result { + Ok(elements) => { + for (entry, mut element) in elements.into_iter().enumerate() { + match validate(&mut element) { + Ok(()) => self.elements.push(element), + Err(error) => self + .errors + .push(format!("Entry {index}.{}: {error}", entry + 1)), + } + } + } + // Deserializers may echo invalid input (including secrets). Keep diagnostics redacted. + Err(_) => self.errors.push(format!( + "Entry {index}: invalid or unsupported account data" + )), + } + } +} + +pub fn validate(element: &mut OTPElement) -> Result<()> { + element.secret = element.secret.trim().replace(' ', ""); + if element.type_ == OTPType::Motp { + element.secret.make_ascii_lowercase(); + hex::decode(&element.secret).map_err(|_| eyre!("invalid hex secret"))?; + } else { + element.secret.make_ascii_uppercase(); + element.secret = element.secret.trim_end_matches('=').to_owned(); + data_encoding::BASE32_NOPAD + .decode(element.secret.as_bytes()) + .map_err(|_| eyre!("invalid base32 secret"))?; + } + if element.secret.is_empty() { + return Err(eyre!("empty secret")); + } + if !(1..=10).contains(&element.digits) { + return Err(eyre!("digits must be between 1 and 10")); + } + if element.period == 0 { + return Err(eyre!("period must be positive")); + } + if element.type_ == OTPType::Hotp && element.counter.is_none() { + return Err(eyre!("HOTP counter is required")); + } + if matches!(element.type_, OTPType::Motp | OTPType::Yandex) && element.pin.is_none() { + return Err(eyre!("PIN is required")); + } + Ok(()) +} + +pub fn parse_uris(content: &str) -> ImportReport { + let mut report = ImportReport::default(); + for (index, uri) in content.split_whitespace().enumerate() { + let result = if uri.starts_with("otpauth-migration://") { + super::google_authenticator::parse_migration_uri(uri) + } else if uri.starts_with("otpauth://") { + OTPElement::from_otp_uri(uri).map(|e| vec![e]) + } else { + Err(eyre!("unsupported URI")) + }; + report.record(index + 1, result); + } + report +} + +pub fn decode_qr(bytes: &[u8]) -> Result { + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(16384); + limits.max_image_height = Some(16384); + limits.max_alloc = Some(128 * 1024 * 1024); + reader.limits(limits); + let gray = reader.decode()?.to_luma8(); + let mut prepared = rqrr::PreparedImage::prepare_from_greyscale( + gray.width() as usize, + gray.height() as usize, + |x, y| gray.get_pixel(x as u32, y as u32).0[0], + ); + let grids = prepared.detect_grids(); + if grids.is_empty() { + return Err(eyre!("No QR code detected in image")); + } + let mut report = ImportReport::default(); + for (index, grid) in grids.into_iter().enumerate() { + match grid.decode() { + Ok((_, text)) => { + let decoded = parse_uris(&text); + report.elements.extend(decoded.elements); + report.errors.extend( + decoded + .errors + .into_iter() + .map(|e| format!("QR {}: {e}", index + 1)), + ); + } + Err(_) => report + .errors + .push(format!("QR {}: could not decode QR data", index + 1)), + } + } + Ok(report) +} + +/// Decode container entries independently, preserving source entry numbers on errors. +pub fn parse_json(value: Value, format: &str) -> Result { + let mut report = ImportReport::default(); + let (pointer, wrapper): (&str, fn(Value) -> Value) = match format { + "cotp" => ( + "/elements", + |v| serde_json::json!({"version":2,"elements":[v]}), + ), + "andotp" | "converted" | "authy-exported" => ("", |v| serde_json::json!([v])), + "aegis" => ("/db/entries", |v| serde_json::json!({"db":{"entries":[v]}})), + "freeotp-plus" => ( + "/tokens", + |v| serde_json::json!({"tokenOrder":[],"tokens":[v]}), + ), + "otp-uri" => ("/items", |v| serde_json::json!({"items":[v]})), + _ => return Err(eyre!("Unsupported import format")), + }; + let entries = value + .pointer(pointer) + .and_then(Value::as_array) + .ok_or_else(|| eyre!("Import file does not contain the expected account list"))?; + for (index, value) in entries.iter().enumerate() { + let value = wrapper(value.clone()); + let result = match format { + "cotp" => convert::(value), + "andotp" => convert::>(value), + "converted" => convert::(value), + "aegis" => convert::(value), + "freeotp-plus" => convert::(value), + "authy-exported" => convert::(value), + "otp-uri" => convert::(value), + _ => unreachable!(), + }; + report.record(index + 1, result); + } + Ok(report) +} + +pub fn convert(value: Value) -> Result> +where + T: serde::de::DeserializeOwned + TryInto>, + T::Error: std::fmt::Debug, +{ + serde_json::from_value::(value)? + .try_into() + .map_err(|_| eyre!("Invalid account data")) +} + +pub fn detect_format(value: &Value) -> Result<&'static str> { + if value.get("elements").is_some() { + Ok("cotp") + } else if value.pointer("/db/entries").is_some() { + Ok("aegis") + } else if value.get("db").is_some_and(Value::is_string) { + Ok("aegis-encrypted") + } else if value.get("tokens").is_some() { + Ok("freeotp-plus") + } else if value.get("items").is_some() { + Ok("otp-uri") + } else if let Some(first) = value.as_array().and_then(|a| a.first()) { + if first.get("period").is_some() { + Ok("andotp") + } else if first.get("algorithm").is_some() { + Ok("converted") + } else if first.get("uri").is_some() { + Ok("authy-exported") + } else { + Err(eyre!( + "Cannot detect JSON format; specify an import format flag" + )) + } + } else { + Err(eyre!( + "Cannot detect JSON format; specify an import format flag" + )) + } +} + +fn same_account(a: &OTPElement, b: &OTPElement) -> bool { + a.secret + .trim_end_matches('=') + .eq_ignore_ascii_case(b.secret.trim_end_matches('=')) + && a.type_ == b.type_ + && a.algorithm == b.algorithm + && a.digits == b.digits + && a.period == b.period + && a.pin == b.pin +} + +pub fn apply( + database: &mut OTPDatabase, + elements: Vec, + policy: DuplicatePolicy, +) -> (usize, usize, usize) { + let (mut added, mut replaced, mut skipped) = (0, 0, 0); + for mut element in elements { + let duplicate = database + .elements_ref() + .iter() + .position(|old| same_account(old, &element)); + match (duplicate, policy) { + (Some(_), DuplicatePolicy::Skip) => skipped += 1, + (Some(index), DuplicatePolicy::Replace) => { + let old = database.mut_element(index).unwrap(); + // Importing an older backup must never roll an HOTP counter backwards. + if element.type_ == OTPType::Hotp { + element.counter = old.counter.max(element.counter); + } + *old = element; + database.mark_modified(); + replaced += 1; + } + _ => { + database.add_element(element); + added += 1; + } + } + } + (added, replaced, skipped) +} + +#[cfg(test)] +mod tests { + use super::*; + const URI: &str = "otpauth://totp/Example:alice?secret=JBSWY3DP"; + #[test] + fn detects_and_decodes_every_qr_in_screenshot() { + let qr = qrcode::QrCode::new(URI) + .unwrap() + .render::>() + .min_dimensions(256, 256) + .build(); + let mut screenshot = + image::GrayImage::from_pixel(qr.width() * 2 + 40, qr.height(), image::Luma([255])); + image::imageops::overlay(&mut screenshot, &qr, 0, 0); + image::imageops::overlay(&mut screenshot, &qr, i64::from(qr.width() + 40), 0); + let mut bytes = std::io::Cursor::new(Vec::new()); + screenshot + .write_to(&mut bytes, image::ImageFormat::Png) + .unwrap(); + let report = decode_qr(bytes.get_ref()).unwrap(); + assert!(report.errors.is_empty()); + assert_eq!(report.elements.len(), 2); + } + #[test] + fn repeated_import_is_idempotent_and_hotp_never_rolls_back() { + let mut database = OTPDatabase::default(); + let uri = "otpauth://hotp/Example:alice?secret=JBSWY3DP&counter=99"; + assert_eq!( + apply( + &mut database, + parse_uris(uri).elements, + DuplicatePolicy::Skip + ), + (1, 0, 0) + ); + assert_eq!( + apply( + &mut database, + parse_uris(uri).elements, + DuplicatePolicy::Skip + ), + (0, 0, 1) + ); + let old_uri = uri.replace("99", "5"); + assert_eq!( + apply( + &mut database, + parse_uris(&old_uri).elements, + DuplicatePolicy::Replace + ), + (0, 1, 0) + ); + assert_eq!(database.elements_ref()[0].counter, Some(99)); + assert_eq!( + apply( + &mut database, + parse_uris(uri).elements, + DuplicatePolicy::KeepBoth + ), + (1, 0, 0) + ); + } + #[test] + fn invalid_entries_are_reported_without_exposing_input() { + let report = parse_uris(&format!("{URI}\notpauth://totp/hidden?secret=TOPSECRET")); + assert_eq!(report.elements.len(), 1); + assert_eq!(report.errors.len(), 1); + assert!(!report.errors[0].contains("TOPSECRET")); + } + #[test] + fn json_entries_fail_independently() { + let report = parse_json(serde_json::json!({"items":[URI,42]}), "otp-uri").unwrap(); + assert_eq!(report.elements.len(), 1); + assert_eq!(report.errors.len(), 1); + } + #[test] + fn freeotp_hotp_counter_survives_import() { + let report = parse_json(serde_json::json!({"tokens":[{"type":"HOTP","algo":"SHA1","counter":42,"digits":6,"issuerExt":"Example","label":"alice","period":30,"secret":[72,101,108,108,111]}]}),"freeotp-plus").unwrap(); + assert!(report.errors.is_empty()); + assert_eq!(report.elements[0].counter, Some(42)); + } +} diff --git a/src/importers/freeotp_plus.rs b/src/importers/freeotp_plus.rs index fab6e723..011c5b1a 100644 --- a/src/importers/freeotp_plus.rs +++ b/src/importers/freeotp_plus.rs @@ -46,7 +46,7 @@ pub struct FreeOTPElement { impl From for OTPElement { fn from(token: FreeOTPElement) -> Self { - let counter: Option = if token.algo.to_uppercase().as_str() == "HOTP" { + let counter: Option = if token.r#type.to_uppercase().as_str() == "HOTP" { Some(token.counter) } else { None diff --git a/src/importers/google_authenticator.rs b/src/importers/google_authenticator.rs index 7287a336..b7677b4e 100644 --- a/src/importers/google_authenticator.rs +++ b/src/importers/google_authenticator.rs @@ -12,8 +12,6 @@ //! the hand-declared message definitions below (Google's schema is stable and //! tiny, so no `.proto`/`protoc` build step is needed). -use std::{fs::read_to_string, path::PathBuf}; - use base64::{Engine as _, engine::general_purpose}; use color_eyre::eyre::{Result, eyre}; use data_encoding::BASE32_NOPAD; @@ -60,15 +58,9 @@ const ALGORITHM_MD5: i32 = 4; const DIGIT_COUNT_EIGHT: i32 = 2; const OTP_TYPE_HOTP: i32 = 1; -/// Reads a file containing one or more `otpauth-migration://` URIs and decodes -/// every OTP parameter contained in them. -pub fn import_from_google_authenticator(path: PathBuf) -> Result> { - let content = read_to_string(path)?; - import_from_string(&content) -} - /// Parses every `otpauth-migration://` URI found in `content` (tokens are /// split on whitespace, so both single- and multi-line inputs work). +#[cfg(test)] fn import_from_string(content: &str) -> Result> { let uris: Vec<&str> = content .split_whitespace() @@ -91,7 +83,7 @@ fn import_from_string(content: &str) -> Result> { } /// Decodes a single `otpauth-migration://offline?data=...` URI. -fn parse_migration_uri(uri: &str) -> Result> { +pub(crate) fn parse_migration_uri(uri: &str) -> Result> { let parsed = Url::parse(uri)?; if parsed.scheme() != MIGRATION_SCHEME { @@ -130,20 +122,24 @@ fn into_otp_element(params: OtpParameters) -> Result { ALGORITHM_SHA512 => OTPAlgorithm::Sha512, ALGORITHM_MD5 => OTPAlgorithm::Md5, // 0 (unspecified) and 1 (SHA1) both map to SHA1. - _ => OTPAlgorithm::Sha1, + 0 | 1 => OTPAlgorithm::Sha1, + _ => return Err(eyre!("Unsupported migration algorithm")), }; - let digits = if params.digits == DIGIT_COUNT_EIGHT { - 8 - } else { - 6 + let digits = match params.digits { + DIGIT_COUNT_EIGHT => 8, + 0 | 1 => 6, + _ => return Err(eyre!("Unsupported migration digits")), }; - let type_ = if params.otp_type == OTP_TYPE_HOTP { - OTPType::Hotp - } else { - OTPType::Totp + let type_ = match params.otp_type { + OTP_TYPE_HOTP => OTPType::Hotp, + 0 | 2 => OTPType::Totp, + _ => return Err(eyre!("Unsupported migration OTP type")), }; + if params.counter < 0 { + return Err(eyre!("Negative HOTP counter")); + } let counter = (type_ == OTPType::Hotp).then_some(params.counter.max(0) as u64); diff --git a/src/importers/mod.rs b/src/importers/mod.rs index 24f27de8..b1a70bb0 100644 --- a/src/importers/mod.rs +++ b/src/importers/mod.rs @@ -2,7 +2,9 @@ pub mod aegis; pub mod aegis_encrypted; pub mod authy_remote_debug; pub mod converted; +pub mod enrollment; pub mod freeotp_plus; pub mod google_authenticator; +#[cfg(test)] pub mod importer; pub mod otp_uri; diff --git a/src/otp/from_otp_uri.rs b/src/otp/from_otp_uri.rs index 04400ff1..c0aeb135 100644 --- a/src/otp/from_otp_uri.rs +++ b/src/otp/from_otp_uri.rs @@ -1,104 +1,116 @@ -use color_eyre::eyre::ErrReport; +use color_eyre::eyre::{Result, eyre}; use url::Url; use super::{otp_algorithm::OTPAlgorithm, otp_element::OTPElement, otp_type::OTPType}; pub trait FromOtpUri: Sized { - fn from_otp_uri(otp_uri: &str) -> color_eyre::Result; + fn from_otp_uri(otp_uri: &str) -> Result; } impl FromOtpUri for OTPElement { - fn from_otp_uri(otp_uri: &str) -> color_eyre::Result { - let decoded = urlencoding::decode(otp_uri).map_err(ErrReport::from)?; - let parsed_uri = Url::parse(&decoded).map_err(ErrReport::from)?; - - let otp_type = parsed_uri - .host_str() - .map_or_else(|| "TOTP".to_string(), str::to_uppercase); - - let (issuer, label) = get_issuer_and_label(&parsed_uri)?; - - let secret = parsed_uri - .query_pairs() - .find(|(k, _v)| k == "secret") - .map(|(_k, v)| v.to_uppercase()) - .ok_or(ErrReport::msg("Secret not found in OTP Uri"))?; - - let algorithm = parsed_uri - .query_pairs() - .find(|(k, _v)| k == "algorithm") - .map_or_else(|| "SHA1".to_string(), |(_k, v)| v.to_uppercase()); - - let digits = parsed_uri - .query_pairs() - .find(|(k, _v)| k == "digits") - .map_or(6, |(_k, v)| v.parse::().unwrap_or(6)); - - let period = parsed_uri - .query_pairs() - .find(|(k, _v)| k == "period") - .map_or(30, |(_k, v)| v.parse::().unwrap_or(30)); - - let counter = parsed_uri - .query_pairs() - .find(|(k, _v)| k == "counter") - .and_then(|(_k, v)| v.parse::().ok()); - - Ok(OTPElement { + fn from_otp_uri(otp_uri: &str) -> Result { + let uri = Url::parse(otp_uri).map_err(|_| eyre!("Invalid OTP URI"))?; + if uri.scheme() != "otpauth" { + return Err(eyre!("Expected otpauth:// URI")); + } + let type_ = match uri.host_str().unwrap_or("").to_ascii_lowercase().as_str() { + "totp" => OTPType::Totp, + "hotp" => OTPType::Hotp, + "steam" => OTPType::Steam, + "yandex" => OTPType::Yandex, + "motp" => OTPType::Motp, + _ => return Err(eyre!("Unsupported OTP type")), + }; + let mut params = std::collections::HashMap::new(); + for (key, value) in uri.query_pairs() { + if params + .insert(key.into_owned(), value.into_owned()) + .is_some() + { + return Err(eyre!("Duplicate OTP URI parameter")); + } + } + let label = urlencoding::decode(uri.path().trim_start_matches('/')) + .map_err(|_| eyre!("Invalid OTP label encoding"))?; + let (issuer, label) = label + .split_once(':') + .map(|(i, l)| (i.to_owned(), l.to_owned())) + .unwrap_or_else(|| { + ( + params.get("issuer").cloned().unwrap_or_default(), + label.into_owned(), + ) + }); + let secret = params + .get("secret") + .cloned() + .ok_or_else(|| eyre!("Secret not found in OTP URI"))?; + let algorithm = match params + .get("algorithm") + .map(|s| s.to_ascii_uppercase()) + .as_deref() + .unwrap_or("SHA1") + { + "SHA1" => OTPAlgorithm::Sha1, + "SHA256" => OTPAlgorithm::Sha256, + "SHA512" => OTPAlgorithm::Sha512, + "MD5" => OTPAlgorithm::Md5, + _ => return Err(eyre!("Unsupported OTP algorithm")), + }; + let number = |name: &str, default| -> Result { + params + .get(name) + .map(|s| { + s.parse::() + .map_err(|_| eyre!("Invalid numeric OTP parameter: {name}")) + }) + .unwrap_or(Ok(default)) + }; + let counter = if params.contains_key("counter") { + Some(number("counter", 0)?) + } else { + None + }; + let mut element = OTPElement { secret, issuer, label, - digits, - type_: OTPType::from(otp_type.as_str()), - algorithm: OTPAlgorithm::from(algorithm.as_str()), - period, + digits: number("digits", 6)?, + type_, + algorithm, + period: number("period", 30)?, counter, - pin: None, - }) + pin: params.get("pin").cloned(), + }; + crate::importers::enrollment::validate(&mut element)?; + Ok(element) } } -fn get(parsed_uri: &Url) -> color_eyre::Result> { - let first_segment: Vec = parsed_uri - .path_segments() - .map(Iterator::collect::>) - .ok_or(ErrReport::msg("Failed to collect path segments"))? - .first() - .ok_or(ErrReport::msg("No path segments found"))? - .split(':') - .collect::>() - .into_iter() - .map(std::borrow::ToOwned::to_owned) - .collect(); - Ok(first_segment) -} - -fn get_issuer_and_label(parsed_uri: &Url) -> color_eyre::Result<(String, String)> { - // Find the first path segments, OTP Uris should not have others - let first_segment = get(parsed_uri)?; - - let first = first_segment.first().and_then(|v| { - urlencoding::decode(v.as_str()) - .map(std::borrow::Cow::into_owned) - .ok() - }); - - let second = first_segment.get(1).and_then(|v| { - urlencoding::decode(v) - .map(std::borrow::Cow::into_owned) - .ok() - }); - - match (first, second) { - (Some(i), Some(l)) => Ok((i, l)), - (Some(l), None) => { - let issuer = parsed_uri - .query_pairs() - .find(|(k, _v)| k == "issuer") - .map(|(_k, v)| v.to_string()) - .unwrap_or_default(); - Ok((issuer, l)) +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_invalid_parameters() { + for query in [ + "period=0", + "period=no", + "digits=11", + "algorithm=SHA42", + "secret=AA", + ] { + assert!( + OTPElement::from_otp_uri(&format!("otpauth://totp/test?secret=JBSWY3DP&{query}")) + .is_err() + ); } - _ => Err(ErrReport::msg("No label found in OTP uri")), + assert!(OTPElement::from_otp_uri("otpauth://hotp/test?secret=JBSWY3DP").is_err()); + assert!(OTPElement::from_otp_uri("otpauth://weird/test?secret=JBSWY3DP").is_err()); + } + #[test] + fn percent_encoded_ampersand_stays_in_label() { + let account = + OTPElement::from_otp_uri("otpauth://totp/Example:user%26name?secret=JBSWY3DP").unwrap(); + assert_eq!(account.label, "user&name"); } } diff --git a/tests/import_integration_tests.rs b/tests/import_integration_tests.rs new file mode 100644 index 00000000..dc854325 --- /dev/null +++ b/tests/import_integration_tests.rs @@ -0,0 +1,77 @@ +use assert_cmd::cargo::cargo_bin_cmd; + +fn database(dir: &tempfile::TempDir) -> std::path::PathBuf { + let path = dir.path().join("db.cotp"); + std::fs::copy("test_samples/cli_integration_test/empty_database", &path).unwrap(); + path +} + +#[test] +fn dry_run_and_rejected_entries_leave_vault_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let db = database(&dir); + let before = std::fs::read(&db).unwrap(); + let input = dir.path().join("uris.txt"); + std::fs::write(&input, "otpauth://totp/Example:alice?secret=JBSWY3DP").unwrap(); + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&db) + .arg("import") + .arg(&input) + .arg("--dry-run") + .write_stdin("12345678\n") + .assert() + .success(); + assert_eq!(std::fs::read(&db).unwrap(), before); + std::fs::write( + &input, + "otpauth://totp/Example:alice?secret=JBSWY3DP\notpauth://totp/hidden?secret=INVALIDSECRET", + ) + .unwrap(); + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&db) + .arg("import") + .arg(&input) + .write_stdin("12345678\n") + .assert() + .failure() + .stderr(predicates::str::contains("Import rejected")); + assert_eq!(std::fs::read(&db).unwrap(), before); +} + +#[test] +fn stdin_import_is_repeatable_and_plaintext_export_is_explicit() { + let dir = tempfile::tempdir().unwrap(); + let db = database(&dir); + for _ in 0..2 { + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&db) + .args(["import", "-"]) + .write_stdin("12345678\notpauth://hotp/Example:alice?secret=JBSWY3DP&counter=42\n") + .assert() + .success(); + } + let export = dir.path().join("export.json"); + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&db) + .args(["export", "--path"]) + .arg(&export) + .write_stdin("12345678\n") + .assert() + .failure(); + assert!(!export.exists()); + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&db) + .args(["export", "--plaintext", "--path"]) + .arg(&export) + .write_stdin("12345678\n") + .assert() + .success(); + let value: serde_json::Value = serde_json::from_slice(&std::fs::read(export).unwrap()).unwrap(); + assert_eq!(value["elements"].as_array().unwrap().len(), 1); + assert_eq!(value["elements"][0]["counter"], 42); +} From 0c6254d22d215b9f83d7132af15124b256924e8c Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:15:20 +0200 Subject: [PATCH 04/21] feat: protect vault transactions and add encrypted backups --- README.md | 20 +++ src/arguments/mod.rs | 4 +- src/backup.rs | 257 +++++++++++++++++++++++++++++++ src/interface/handlers/mod.rs | 10 +- src/main.rs | 40 ++++- src/otp/otp_element.rs | 3 +- src/persistence.rs | 270 +++++++++++++++++++++++++++++++++ src/reading.rs | 15 +- src/utils.rs | 5 +- tests/add_integration_tests.rs | 7 +- 10 files changed, 604 insertions(+), 27 deletions(-) create mode 100644 src/backup.rs create mode 100644 src/persistence.rs diff --git a/README.md b/README.md index 30a0fe20..36f8c712 100644 --- a/README.md +++ b/README.md @@ -231,3 +231,23 @@ python3 ci/render_ui_demo.py The recording uses the actual dashboard widgets and keyboard/mouse handlers, with synthetic accounts and masked secrets. Tutorial captions appear below the application. + +### Encrypted backups and concurrent use + +`cotp backup create ~/backups/cotp --keep 10` creates a private encrypted vault +backup and optionally retains the newest ten cotp backup files in that directory. +`cotp backup list ~/backups/cotp` lists backups without unlocking the vault. +Use `cotp backup verify FILE` to authenticate and validate a backup with its +original password. `cotp -d /path/to/vault backup restore FILE` restores a verified +backup; `--force` is required to replace an existing vault. The original backup +password remains in effect. Restoring an older backup can roll back HOTP counters; +resynchronize affected accounts with the service before consuming codes. +`--password-stdin` also works for backup verification and restoration. + +Vault writes take a cross-process lock and reject stale snapshots, so an open +dashboard cannot silently overwrite another command's changes. If a save reports +that another process changed the vault, reopen it before retrying your edits. +Copying an HOTP code commits the incremented counter before releasing the code; +if the dashboard has unsaved edits, save those first. Displayed HOTP codes are +previews. A clipboard failure after successful persistence still consumes the +counter; this prevents a previously released code from being issued again. diff --git a/src/arguments/mod.rs b/src/arguments/mod.rs index 3646cddb..c1659fb4 100644 --- a/src/arguments/mod.rs +++ b/src/arguments/mod.rs @@ -40,7 +40,7 @@ DASHBOARD )] pub struct CotpArgs { #[command(subcommand)] - command: Option, + pub(crate) command: Option, /// Fetch the password from standard input #[arg(long = "password-stdin", default_value_t = false)] pub password_from_stdin: bool, @@ -69,6 +69,8 @@ pub enum CotpSubcommands { Extract(ExtractArgs), /// Change database password Passwd(PasswdArgs), + /// Create, verify, list, or restore encrypted backups + Backup(crate::backup::BackupArgs), } pub fn args_parser(matches: CotpArgs, read_result: OTPDatabase) -> color_eyre::Result { diff --git a/src/backup.rs b/src/backup.rs new file mode 100644 index 00000000..494bd565 --- /dev/null +++ b/src/backup.rs @@ -0,0 +1,257 @@ +//! Backups are authenticated encrypted vault files, never plaintext exports. +use crate::{ + crypto::{cryptography::decrypt_string, encrypted_database::EncryptedDatabase}, + otp::{ + migrations::migrate, + otp_element::{OTPDatabase, OTPElement}, + }, + persistence::VaultLock, +}; +use clap::{Args, Subcommand}; +use color_eyre::eyre::{Result, eyre}; +use std::{ + fs, + io::{self, BufRead}, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; +use zeroize::Zeroizing; + +#[derive(Args)] +pub struct BackupArgs { + #[command(subcommand)] + pub action: BackupAction, +} + +#[derive(Subcommand)] +pub enum BackupAction { + /// Copy the encrypted vault into a new private backup file + Create { + directory: PathBuf, + /// Keep only this many cotp backups in the directory (at least one) + #[arg(long, value_parser = clap::value_parser!(u16).range(1..))] + keep: Option, + }, + /// List encrypted backup files without unlocking the vault + List { directory: PathBuf }, + /// Authenticate and validate a backup using its original password + Verify { file: PathBuf }, + /// Restore an authenticated backup; its original password remains in effect + Restore { + file: PathBuf, + /// Replace an existing vault (may roll back HOTP counters) + #[arg(long)] + force: bool, + }, +} + +impl crate::arguments::SubcommandExecutor for BackupArgs { + fn run_command(self, _: OTPDatabase) -> Result { + Err(eyre!("Backup commands must run before opening the vault")) + } +} + +pub fn run(args: &BackupArgs, vault: &Path, password_stdin: bool) -> Result<()> { + match &args.action { + BackupAction::Create { directory, keep } => { + let path = create(vault, directory)?; + if let Some(keep) = keep { + prune(directory, usize::from(*keep))?; + } + println!("{}", path.display()); + } + BackupAction::List { directory } => { + for path in list(directory)? { + println!("{}", path.display()); + } + } + BackupAction::Verify { file } => { + let password = password(password_stdin)?; + let bytes = fs::read(file)?; + let database = validate(&bytes, &password)?; + println!( + "Verified encrypted backup: {} accounts", + database.elements_ref().len() + ); + } + BackupAction::Restore { file, force } => { + let password = password(password_stdin)?; + restore(file, vault, &password, *force)?; + eprintln!( + "Encrypted backup restored. Use the backup's original password to unlock it." + ); + } + } + Ok(()) +} + +fn password(from_stdin: bool) -> Result> { + let password = if from_stdin { + io::stdin() + .lock() + .lines() + .next() + .ok_or_else(|| eyre!("Missing backup password on stdin"))?? + } else { + rpassword::prompt_password("Backup password: ")? + }; + Ok(Zeroizing::new(password)) +} + +fn validate(bytes: &[u8], password: &str) -> Result { + let text = std::str::from_utf8(bytes)?; + let (plaintext, key, _salt) = decrypt_string(text, password)?; + let plaintext = Zeroizing::new(plaintext); + let _key = Zeroizing::new(key); + let mut database: OTPDatabase = serde_json::from_str(&plaintext) + .or_else(|_| serde_json::from_str::>(&plaintext).map(Into::into))?; + if database.version > crate::otp::otp_element::CURRENT_DATABASE_VERSION { + return Err(eyre!("Backup uses a newer unsupported vault format")); + } + migrate(&mut database)?; + for element in database.elements_ref() { + element + .get_otp_code() + .map_err(|_| eyre!("Backup contains an invalid OTP account"))?; + } + Ok(database) +} + +fn private_directory(path: &Path) -> Result<()> { + if !path.exists() { + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(path)?; + } + Ok(()) +} + +fn create(vault: &Path, directory: &Path) -> Result { + let bytes = { + let _lock = VaultLock::acquire(vault)?; + fs::read(vault)? + }; + // Reject accidentally pointing backup at a plaintext export. + let _: EncryptedDatabase = serde_json::from_slice(&bytes)?; + private_directory(directory)?; + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let path = directory.join(format!("cotp-{timestamp:030}.cotp-backup")); + // No-clobber persist prevents replacing an existing backup on a timestamp collision. + let mut temporary = tempfile::Builder::new() + .prefix(".cotp-backup-") + .tempfile_in(directory)?; + use std::io::Write; + temporary.write_all(&bytes)?; + temporary.as_file().sync_all()?; + temporary.persist_noclobber(&path).map_err(|e| e.error)?; + #[cfg(unix)] + fs::File::open(directory)?.sync_all()?; + Ok(path) +} + +fn list(directory: &Path) -> Result> { + let mut paths = vec![]; + for entry in fs::read_dir(directory)? { + let entry = entry?; + if entry.file_type()?.is_file() + && entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with("cotp-") && name.ends_with(".cotp-backup")) + { + paths.push(entry.path()); + } + } + paths.sort(); + Ok(paths) +} + +fn prune(directory: &Path, keep: usize) -> Result<()> { + let paths = list(directory)?; + let remove = paths.len().saturating_sub(keep); + for path in paths.into_iter().take(remove) { + fs::remove_file(path)?; + } + Ok(()) +} + +fn restore(file: &Path, vault: &Path, password: &str, force: bool) -> Result<()> { + let bytes = fs::read(file)?; + validate(&bytes, password)?; + if let Some(parent) = vault.parent().filter(|p| !p.as_os_str().is_empty()) { + private_directory(parent)?; + } + let _lock = VaultLock::acquire(vault)?; + if vault.exists() && !force { + return Err(eyre!( + "Vault already exists. Use --force to replace it; restoring may roll back HOTP counters." + )); + } + crate::database_file::write(vault, &bytes)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::cryptography::{argon_derive_key, encrypt_string_with_key}; + fn encrypted() -> Vec { + let salt = [42; 16]; + let key = argon_derive_key(b"backup password", &salt).unwrap(); + serde_json::to_vec( + &encrypt_string_with_key( + &serde_json::to_string(&OTPDatabase::default()).unwrap(), + &key, + &salt, + ) + .unwrap(), + ) + .unwrap() + } + #[test] + fn encrypted_round_trip_and_failed_restore_preserves_destination() { + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().join("vault"); + let original = encrypted(); + fs::write(&vault, &original).unwrap(); + let backup = create(&vault, &dir.path().join("backups")).unwrap(); + assert_eq!(fs::read(&backup).unwrap(), original); + assert!(validate(&original, "backup password").is_ok()); + fs::write(&vault, b"existing").unwrap(); + assert!(restore(&backup, &vault, "wrong password", true).is_err()); + assert_eq!(fs::read(&vault).unwrap(), b"existing"); + assert!(restore(&backup, &vault, "backup password", false).is_err()); + assert_eq!(fs::read(&vault).unwrap(), b"existing"); + restore(&backup, &vault, "backup password", true).unwrap(); + assert_eq!(fs::read(&vault).unwrap(), original); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&backup).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + #[test] + fn retention_only_removes_matching_backup_files() { + let dir = tempfile::tempdir().unwrap(); + for name in [ + "cotp-0001.cotp-backup", + "cotp-0002.cotp-backup", + "cotp-0003.cotp-backup", + "keep.json", + ] { + fs::write(dir.path().join(name), b"data").unwrap(); + } + prune(dir.path(), 2).unwrap(); + assert!(!dir.path().join("cotp-0001.cotp-backup").exists()); + assert!(dir.path().join("keep.json").exists()); + assert_eq!(list(dir.path()).unwrap().len(), 2); + } +} diff --git a/src/interface/handlers/mod.rs b/src/interface/handlers/mod.rs index 988e9cd8..25f96acf 100644 --- a/src/interface/handlers/mod.rs +++ b/src/interface/handlers/mod.rs @@ -135,11 +135,17 @@ pub(crate) fn copy_selected(app: &mut App, uri: bool) { let result = app .table .selected_index() - .and_then(|index| app.database.get_element(index)) .ok_or_else(|| "No account selected".to_string()) - .and_then(|element| { + .and_then(|index| { + let element = app + .database + .get_element(index) + .ok_or_else(|| "No account selected".to_string())?; if uri { Ok(element.get_otpauth_uri()) + } else if element.type_ == crate::otp::otp_type::OTPType::Hotp { + crate::persistence::consume_hotp(app.database, index) + .map_err(|error| error.to_string()) } else { code_for(element) } diff --git a/src/main.rs b/src/main.rs index 716e22cd..85470521 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ use std::{ use zeroize::Zeroize; mod arguments; +mod backup; mod clipboard; mod crypto; mod database_file; @@ -26,14 +27,26 @@ mod importers; mod interface; mod otp; mod path; +mod persistence; mod reading; mod utils; fn init(args: &CotpArgs) -> color_eyre::Result { - init_path(args); + let path = init_path(args); + if !path.exists() + && !matches!( + args.command, + None | Some(arguments::CotpSubcommands::Add(_) | arguments::CotpSubcommands::Import(_)) + ) + { + return Err(eyre!( + "Vault does not exist. Create it with cotp add or cotp import." + )); + } match utils::init_app() { Ok(first_run) => { + persistence::initialize(&path)?; if first_run { // Let's initialize the database file let mut pw = utils::verified_password("Choose a password: ", 8); @@ -59,27 +72,40 @@ fn main() -> AppResult<()> { color_eyre::install()?; let cotp_args: CotpArgs = CotpArgs::parse(); - let (database, mut key, salt) = match init(&cotp_args) { + if let Some(arguments::CotpSubcommands::Backup(args)) = &cotp_args.command { + let path = init_path(&cotp_args); + return backup::run(args, &path, cotp_args.password_from_stdin).map_err(|e| e.into()); + } + let (mut database, key, salt) = match init(&cotp_args) { Ok(v) => v, Err(e) => { - println!("{e}"); + eprintln!("{e}"); std::process::exit(-1); } }; + let mut key = zeroize::Zeroizing::new(key); + persistence::unlock(&key, &salt)?; + // Complete format upgrades before commands so transactional HOTP starts clean. + otp::migrations::migrate(&mut database)?; + if database.is_modified() { + database.save(&key, &salt)?; + } + key.zeroize(); + let mut reowned_database = match args_parser(cotp_args, database) { Ok(d) => d, Err(e) => { eprintln!("An error occurred: {e}"); - key.zeroize(); + persistence::lock(); std::process::exit(-2) } }; let error_code = if reowned_database.is_modified() { - match reowned_database.save(&key, &salt) { + match persistence::persist(&mut reowned_database) { Ok(()) => { - println!("Modifications have been persisted"); + eprintln!("Modifications have been persisted"); 0 } Err(e) => { @@ -90,7 +116,7 @@ fn main() -> AppResult<()> { } else { 0 }; - key.zeroize(); + persistence::lock(); std::process::exit(error_code) } diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index fd22bc27..d1e985fe 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -68,7 +68,7 @@ impl OTPDatabase { let json: &str = &serde_json::to_string(&self)?; let encrypted = encrypt_string_with_key(json, key, salt)?; let content = serde_json::to_vec(&encrypted)?; - crate::database_file::write(DATABASE_PATH.get().unwrap(), &content)?; + crate::persistence::write_database(DATABASE_PATH.get().unwrap(), &content)?; Ok(()) } @@ -76,6 +76,7 @@ impl OTPDatabase { let salt = gen_salt()?; let key = argon_derive_key(password.as_bytes(), &salt)?; self.save(&key, &salt)?; + crate::persistence::unlock(&key, &salt)?; Ok((key, salt)) } diff --git a/src/persistence.rs b/src/persistence.rs new file mode 100644 index 00000000..b7b72a8a --- /dev/null +++ b/src/persistence.rs @@ -0,0 +1,270 @@ +//! Optimistic vault transactions protected by an advisory cross-process lock. +use crate::otp::{otp_element::OTPDatabase, otp_type::OTPType}; +use color_eyre::eyre::{Result, eyre}; +use std::{ + fs::{self, File, OpenOptions}, + path::{Path, PathBuf}, + sync::Mutex, +}; +use zeroize::Zeroizing; + +static SESSION: Mutex> = Mutex::new(None); + +pub(crate) struct VaultLock { + _file: File, +} +impl VaultLock { + pub(crate) fn acquire(path: &Path) -> Result { + let path = normalized_path(path)?; + let mut lock_name = path.as_os_str().to_os_string(); + lock_name.push(".lock"); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(PathBuf::from(lock_name))?; + file.try_lock() + .map_err(|e| eyre!("Vault is busy; another process is writing it: {e}"))?; + Ok(Self { _file: file }) + } +} + +fn normalized_path(path: &Path) -> Result { + match path.canonicalize() { + Ok(path) => Ok(path), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + Ok(parent.canonicalize()?.join( + path.file_name() + .ok_or_else(|| eyre!("Invalid vault path"))?, + )) + } + Err(e) => Err(e.into()), + } +} + +fn read_optional(path: &Path) -> Result>> { + match fs::read(path) { + Ok(content) => Ok(Some(content)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +struct Session { + path: PathBuf, + snapshot: Option>, + key: Option>>, + salt: Vec, +} +impl Session { + fn open(path: &Path) -> Result { + let path = normalized_path(path)?; + let _lock = VaultLock::acquire(&path)?; + let snapshot = read_optional(&path)?; + Ok(Self { + path, + snapshot, + key: None, + salt: vec![], + }) + } + fn write(&mut self, content: &[u8]) -> Result<()> { + let _lock = VaultLock::acquire(&self.path)?; + if read_optional(&self.path)? != self.snapshot { + return Err(eyre!( + "Vault changed in another process. Reopen the vault and retry; your changes were not written." + )); + } + crate::database_file::write(&self.path, content)?; + self.snapshot = Some(content.to_vec()); + Ok(()) + } +} + +pub fn initialize(path: &Path) -> Result<()> { + *SESSION + .lock() + .map_err(|_| eyre!("Vault session unavailable"))? = Some(Session::open(path)?); + Ok(()) +} + +pub fn unlock(key: &[u8], salt: &[u8]) -> Result<()> { + let mut session = SESSION + .lock() + .map_err(|_| eyre!("Vault session unavailable"))?; + let session = session + .as_mut() + .ok_or_else(|| eyre!("Vault session not initialized"))?; + session.key = Some(Zeroizing::new(key.to_vec())); + session.salt = salt.to_vec(); + Ok(()) +} + +pub fn lock() { + if let Ok(mut session) = SESSION.lock() + && let Some(session) = session.as_mut() + { + session.key = None; + session.salt.clear(); + } +} + +pub fn write_database(path: &Path, content: &[u8]) -> Result<()> { + let mut session = SESSION + .lock() + .map_err(|_| eyre!("Vault session unavailable"))?; + let session = session + .as_mut() + .ok_or_else(|| eyre!("Vault session not initialized"))?; + if normalized_path(path)? != session.path { + return Err(eyre!("Vault path changed during session")); + } + session.write(content) +} + +pub fn persist(database: &mut OTPDatabase) -> Result<()> { + let (key, salt) = { + let session = SESSION + .lock() + .map_err(|_| eyre!("Vault session unavailable"))?; + let session = session + .as_ref() + .ok_or_else(|| eyre!("Vault session not initialized"))?; + let key = session + .key + .as_ref() + .ok_or_else(|| eyre!("Vault is locked"))?; + (Zeroizing::new(key.to_vec()), session.salt.clone()) + }; + database.save(&key, &salt) +} + +/// Persist the next counter before handing a consumable HOTP code to a caller. +/// Pending user edits remain pending and are never silently committed by copying. +pub fn consume_hotp(database: &mut OTPDatabase, index: usize) -> Result { + consume_with(database, index, persist) +} + +fn consume_with( + database: &mut OTPDatabase, + index: usize, + save: impl FnOnce(&mut OTPDatabase) -> Result<()>, +) -> Result { + if database.is_modified() { + return Err(eyre!("Save pending changes before consuming HOTP")); + } + let element = database + .mut_element(index) + .ok_or_else(|| eyre!("Account not found"))?; + if element.type_ != OTPType::Hotp { + return Err(eyre!("Selected account is not HOTP")); + } + let counter = element + .counter + .ok_or_else(|| eyre!("Missing HOTP counter"))?; + let next = counter + .checked_add(1) + .ok_or_else(|| eyre!("HOTP counter exhausted"))?; + let code = Zeroizing::new(element.get_otp_code()?); + element.counter = Some(next); + database.mark_modified(); + if let Err(error) = save(database) { + database.mut_element(index).unwrap().counter = Some(counter); + database.needs_modification = false; + return Err(error); + } + Ok(code.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::otp::otp_element::OTPElementBuilder; + + #[test] + fn contention_and_stale_writes_preserve_winner() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + fs::write(&path, b"initial").unwrap(); + let mut first = Session::open(&path).unwrap(); + let mut second = Session::open(&path).unwrap(); + let lock = VaultLock::acquire(&path).unwrap(); + assert!(VaultLock::acquire(&path).is_err()); + assert!(first.write(b"blocked").is_err()); + drop(lock); + first.write(b"winner").unwrap(); + assert!(second.write(b"stale").is_err()); + assert_eq!(fs::read(&path).unwrap(), b"winner"); + first.write(b"second commit").unwrap(); + } + + #[test] + fn lock_child() { + if let Some(path) = std::env::var_os("COTP_LOCK_TEST_PATH") { + assert!(VaultLock::acquire(Path::new(&path)).is_err()); + } + } + + #[test] + fn lock_excludes_another_process() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + let _lock = VaultLock::acquire(&path).unwrap(); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "persistence::tests::lock_child"]) + .env("COTP_LOCK_TEST_PATH", path) + .status() + .unwrap(); + assert!(status.success()); + } + + fn hotp_database() -> OTPDatabase { + let element = OTPElementBuilder::default() + .secret("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + .issuer("test") + .label("test") + .type_(OTPType::Hotp) + .counter(0) + .build() + .unwrap(); + OTPDatabase { + elements: vec![element], + ..Default::default() + } + } + + #[test] + fn hotp_failure_rolls_back_and_success_commits_before_release() { + let mut db = hotp_database(); + assert!(consume_with(&mut db, 0, |_| Err(eyre!("Disk full"))).is_err()); + assert_eq!(db.elements[0].counter, Some(0)); + assert!(!db.is_modified()); + let result = consume_with(&mut db, 0, |db| { + assert_eq!(db.elements[0].counter, Some(1)); + db.needs_modification = false; + Ok(()) + }) + .unwrap(); + assert_eq!(result, "755224"); + assert_eq!(db.elements[0].counter, Some(1)); + } + + #[test] + fn pending_changes_and_exhausted_counter_are_not_consumed() { + let mut db = hotp_database(); + db.mark_modified(); + assert!(consume_with(&mut db, 0, |_| panic!("must not save")).is_err()); + assert!(db.is_modified()); + assert_eq!(db.elements[0].counter, Some(0)); + db.needs_modification = false; + db.elements[0].counter = Some(u64::MAX); + assert!(consume_with(&mut db, 0, |_| panic!("must not save")).is_err()); + } +} diff --git a/src/reading.rs b/src/reading.rs index eb752fed..ac0ca93d 100644 --- a/src/reading.rs +++ b/src/reading.rs @@ -31,14 +31,9 @@ pub fn read_decrypted_text(password: &str) -> color_eyre::Result<(String, Vec Err(eyre!( - "Your database file was empty, please restart to create a new one.", - )), - Err(_) => Err(eyre!( - "Your database file is empty, please remove it manually and restart.", - )), - }; + return Err(eyre!( + "Vault file is empty. Restore a verified backup; the file was not changed." + )); } //rust close files at the end of the function crypto::cryptography::decrypt_string(&encrypted_contents, password) @@ -57,7 +52,3 @@ pub fn read_from_file(password: &str) -> color_eyre::Result { Err(e) => Err(e), } } - -fn delete_db() -> io::Result<()> { - std::fs::remove_file(DATABASE_PATH.get().unwrap()) -} diff --git a/src/utils.rs b/src/utils.rs index 3cca40ce..3f6175a6 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -2,7 +2,10 @@ use crate::path::DATABASE_PATH; pub fn init_app() -> Result { let db_path = DATABASE_PATH.get().unwrap(); // Safe to unwrap because we initialize - let db_dir = db_path.parent().unwrap(); + let db_dir = db_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(std::path::Path::new(".")); if !db_dir.exists() { if let Err(_e) = std::fs::create_dir_all(db_dir) { return Err(()); diff --git a/tests/add_integration_tests.rs b/tests/add_integration_tests.rs index aac57fbd..b41b8d9b 100644 --- a/tests/add_integration_tests.rs +++ b/tests/add_integration_tests.rs @@ -57,8 +57,8 @@ AA // Assert assertion .success() - .stderr(is_empty()) - .stdout(eq("Modifications have been persisted\n")); + .stdout(is_empty()) + .stderr(eq("Modifications have been persisted\n")); } #[cfg(unix)] @@ -101,7 +101,8 @@ AA )); assert_eq!(std::fs::read(&database).unwrap(), original); - assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2); + assert!(dir.path().join("db.cotp.lock").is_file()); cargo_bin_cmd!("cotp") .arg("--password-stdin") .arg("--database-path") From 14034a64c15d99c6a1e657f9b598727ac4a9af5c Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:16:11 +0200 Subject: [PATCH 05/21] feat(shell): add configuration profiles completions manuals and headless builds --- Cargo.lock | 27 +++++++++ Cargo.toml | 8 ++- docs/configuration.md | 38 +++++++++++++ src/arguments/mod.rs | 13 ++++- src/arguments/shell.rs | 57 +++++++++++++++++++ src/clipboard.rs | 15 ++++- src/config.rs | 98 ++++++++++++++++++++++++++++++++ src/interface/app.rs | 2 +- src/main.rs | 7 ++- tests/shell_integration_tests.rs | 40 +++++++++++++ 10 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 docs/configuration.md create mode 100644 src/arguments/shell.rs create mode 100644 src/config.rs create mode 100644 tests/shell_integration_tests.rs diff --git a/Cargo.lock b/Cargo.lock index fd00ddd5..8bbb9a09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -398,6 +398,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.7" @@ -416,6 +425,16 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "clipboard-win" version = "3.1.1" @@ -536,6 +555,8 @@ dependencies = [ "base64 0.23.1", "chacha20poly1305", "clap", + "clap_complete", + "clap_mangen", "color-eyre", "copypasta-ext", "crossterm", @@ -2295,6 +2316,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rpassword" version = "7.5.4" diff --git a/Cargo.toml b/Cargo.toml index bdc09498..821ad13c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,14 +26,20 @@ panic = "abort" debug = false strip = "symbols" +[features] +default = ["desktop-clipboard"] +desktop-clipboard = ["dep:copypasta-ext"] + [dependencies] serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.154" dirs = "7.0.0" rpassword = "7.5.4" data-encoding = "2.11.1" -copypasta-ext = "0.4.4" +copypasta-ext = { version = "0.4.4", optional = true } zeroize = { version = "1.9.1", features = ["zeroize_derive"] } +clap_complete = "4" +clap_mangen = "0.2" clap = { version = "4.6.7", features = ["derive"] } hmac = "0.13.0" sha1 = "0.11.0" diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..7997d6a7 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,38 @@ +# Configuration and shell integration + +Run `cotp completions bash`, `cotp completions zsh`, `cotp completions fish`, or +`cotp completions powershell` and install the output using your shell's completion +loader. `cotp man` writes a roff manual to stdout; `cotp man --directory ./man` +generates pages for all commands. Neither command needs a vault or password. + +Settings are JSON at the platform configuration directory under `cotp/config.json`. +Use `--config FILE` or `COTP_CONFIG` to choose another file. Unknown settings are +errors to catch spelling mistakes. Paths are literal: expand `~` in your shell or +use absolute paths in JSON. Relative vault paths resolve from the working directory. + +```json +{ + "profiles": {"personal": "/home/me/.local/share/cotp/personal.cotp", "work": "/home/me/.local/share/cotp/work.cotp"}, + "default_profile": "personal", + "hide_codes": true, + "idle_timeout_seconds": 300, + "clipboard_seconds": 30, + "keychain": false +} +``` + +Choose a profile with `cotp --profile work` or `COTP_PROFILE=work cotp`. +Vault path precedence is `--database-path`, `COTP_DB_PATH`, selected profile, +configuration `database_path`, then the existing portable/platform default. +Explicit unknown profile names fail instead of opening the wrong vault. +Zero disables inactivity locking or clipboard expiration. Keychain integration is +opt-in; the password prompt remains the default. + +For a build without native desktop clipboard dependencies: + +```sh +cargo build --release --locked --no-default-features +``` + +This build retains the TUI and SSH OSC52 clipboard support. Local desktop copying +requires the default `desktop-clipboard` feature. diff --git a/src/arguments/mod.rs b/src/arguments/mod.rs index 3646cddb..e483563a 100644 --- a/src/arguments/mod.rs +++ b/src/arguments/mod.rs @@ -18,6 +18,7 @@ mod extract; mod import; mod list; mod passwd; +pub mod shell; /// Common trait the all the Subcommands must implement to define the command logic #[enum_dispatch] @@ -40,7 +41,13 @@ DASHBOARD )] pub struct CotpArgs { #[command(subcommand)] - command: Option, + pub(crate) command: Option, + /// Read settings from this JSON file + #[arg(long, global = true)] + pub config: Option, + /// Choose a named vault profile + #[arg(long, global = true)] + pub profile: Option, /// Fetch the password from standard input #[arg(long = "password-stdin", default_value_t = false)] pub password_from_stdin: bool, @@ -53,6 +60,10 @@ pub struct CotpArgs { #[derive(Subcommand)] #[enum_dispatch(SubcommandExecutor)] pub enum CotpSubcommands { + /// Generate shell completion scripts without unlocking a vault + Completions(shell::CompletionsArgs), + /// Generate manual pages without unlocking a vault + Man(shell::ManArgs), /// Add new OTP code Add(AddArgs), /// Edit an existing OTP Code diff --git a/src/arguments/shell.rs b/src/arguments/shell.rs new file mode 100644 index 00000000..65e6d61b --- /dev/null +++ b/src/arguments/shell.rs @@ -0,0 +1,57 @@ +use super::{CotpArgs, CotpSubcommands, SubcommandExecutor}; +use crate::otp::otp_element::OTPDatabase; +use clap::{Args, CommandFactory}; +use std::path::PathBuf; + +#[derive(Args)] +pub struct CompletionsArgs { + #[arg(value_enum)] + shell: clap_complete::Shell, +} + +#[derive(Args)] +pub struct ManArgs { + /// Directory for the main page and all subcommand pages; stdout if omitted + #[arg(long)] + directory: Option, +} + +pub fn handle(args: &CotpArgs) -> color_eyre::Result { + match &args.command { + Some(CotpSubcommands::Completions(args)) => { + clap_complete::generate( + args.shell, + &mut CotpArgs::command(), + "cotp", + &mut std::io::stdout(), + ); + } + Some(CotpSubcommands::Man(args)) => { + let command = CotpArgs::command(); + if let Some(directory) = &args.directory { + std::fs::create_dir_all(directory)?; + clap_mangen::generate_to(command, directory)?; + } else { + clap_mangen::Man::new(command).render(&mut std::io::stdout())?; + } + } + _ => return Ok(false), + } + Ok(true) +} + +// These variants are handled before vault initialization. +impl SubcommandExecutor for CompletionsArgs { + fn run_command(self, _: OTPDatabase) -> color_eyre::Result { + Err(color_eyre::eyre::eyre!( + "Completion generation must run before vault initialization" + )) + } +} +impl SubcommandExecutor for ManArgs { + fn run_command(self, _: OTPDatabase) -> color_eyre::Result { + Err(color_eyre::eyre::eyre!( + "Manual generation must run before vault initialization" + )) + } +} diff --git a/src/clipboard.rs b/src/clipboard.rs index 2efea28d..ada7d512 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -1,9 +1,12 @@ use base64::{Engine as _, engine::general_purpose}; use color_eyre::eyre::eyre; +#[cfg(feature = "desktop-clipboard")] use copypasta_ext::prelude::*; -#[cfg(target_os = "linux")] +#[cfg(all(feature = "desktop-clipboard", target_os = "linux"))] use copypasta_ext::wayland_bin::WaylandBinClipboardContext; +#[cfg(feature = "desktop-clipboard")] use copypasta_ext::x11_bin::ClipboardContext as BinClipboardContext; +#[cfg(feature = "desktop-clipboard")] use copypasta_ext::x11_fork::ClipboardContext as ForkClipboardContext; use crossterm::style::Print; use std::{env, io}; @@ -36,7 +39,7 @@ fn ssh_clipboard(content: &str) -> bool { ) .is_ok() } -#[cfg(target_os = "linux")] +#[cfg(all(feature = "desktop-clipboard", target_os = "linux"))] fn wayland_clipboard(content: &str) -> bool { env_var_set("WAYLAND_DISPLAY") && WaylandBinClipboardContext::new() @@ -44,11 +47,12 @@ fn wayland_clipboard(content: &str) -> bool { .is_ok() } -#[cfg(not(target_os = "linux"))] +#[cfg(not(all(feature = "desktop-clipboard", target_os = "linux")))] fn wayland_clipboard(_content: &str) -> bool { false } +#[cfg(feature = "desktop-clipboard")] fn other_platform_clipboard(content: &str) -> bool { BinClipboardContext::new() .and_then(|mut ctx| ctx.set_contents(content.to_owned())) @@ -63,3 +67,8 @@ fn env_var_set(env_var: &str) -> bool { .map(|v| !v.trim().is_empty()) .unwrap_or(false) } + +#[cfg(not(feature = "desktop-clipboard"))] +fn other_platform_clipboard(_content: &str) -> bool { + false +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 00000000..eb5d272a --- /dev/null +++ b/src/config.rs @@ -0,0 +1,98 @@ +use crate::arguments::CotpArgs; +use color_eyre::eyre::{WrapErr, eyre}; +use serde::Deserialize; +use std::{collections::BTreeMap, path::PathBuf, sync::OnceLock}; + +#[derive(Debug, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub database_path: Option, + pub profiles: BTreeMap, + pub default_profile: Option, + pub idle_timeout_seconds: u64, + pub clipboard_seconds: u64, + pub hide_codes: bool, + pub keychain: bool, +} + +impl Default for Settings { + fn default() -> Self { + Self { + database_path: None, + profiles: BTreeMap::new(), + default_profile: None, + idle_timeout_seconds: 300, + clipboard_seconds: 30, + hide_codes: false, + keychain: false, + } + } +} + +static SETTINGS: OnceLock = OnceLock::new(); + +pub fn settings() -> &'static Settings { + SETTINGS.get_or_init(Settings::default) +} + +pub fn load(args: &mut CotpArgs) -> color_eyre::Result<()> { + let explicit = args + .config + .clone() + .or_else(|| std::env::var_os("COTP_CONFIG").map(PathBuf::from)); + let path = explicit + .clone() + .or_else(|| dirs::config_dir().map(|p| p.join("cotp/config.json"))); + let config: Settings = match path { + Some(path) => match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .wrap_err_with(|| format!("Invalid configuration: {}", path.display()))?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound && explicit.is_none() => { + Settings::default() + } + Err(e) => { + return Err(e) + .wrap_err_with(|| format!("Cannot read configuration: {}", path.display())); + } + }, + None => Settings::default(), + }; + let profile = args + .profile + .clone() + .or_else(|| std::env::var("COTP_PROFILE").ok()) + .or_else(|| config.default_profile.clone()); + let configured_path = profile + .as_ref() + .map(|name| { + config + .profiles + .get(name) + .cloned() + .ok_or_else(|| eyre!("Unknown vault profile: {name}")) + }) + .transpose()? + .or_else(|| config.database_path.clone()); + if args.database_path.is_none() && std::env::var_os("COTP_DB_PATH").is_none() { + args.database_path = configured_path.map(|p| p.to_string_lossy().into_owned()); + } + SETTINGS + .set(config) + .map_err(|_| eyre!("Configuration already loaded"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn configuration_validates_names_and_preserves_defaults() { + let config: Settings = + serde_json::from_str(r#"{"profiles":{"work":"/vault/work.cotp"},"hide_codes":true}"#) + .unwrap(); + assert_eq!(config.profiles["work"], PathBuf::from("/vault/work.cotp")); + assert_eq!(config.idle_timeout_seconds, 300); + assert!(config.hide_codes); + assert!(serde_json::from_str::(r#"{"hid_codes":true}"#).is_err()); + } +} diff --git a/src/interface/app.rs b/src/interface/app.rs index ce4a6d54..6dcd93bc 100644 --- a/src/interface/app.rs +++ b/src/interface/app.rs @@ -47,7 +47,7 @@ impl<'a> App<'a> { }, popup_scroll: 0, notification: None, - hide_codes: false, + hide_codes: crate::config::settings().hide_codes, now: unix_seconds(), form: None, hitboxes: Vec::new(), diff --git a/src/main.rs b/src/main.rs index 716e22cd..b9604956 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ use zeroize::Zeroize; mod arguments; mod clipboard; +mod config; mod crypto; mod database_file; mod exporters; @@ -58,7 +59,11 @@ fn init(args: &CotpArgs) -> color_eyre::Result { fn main() -> AppResult<()> { color_eyre::install()?; - let cotp_args: CotpArgs = CotpArgs::parse(); + let mut cotp_args: CotpArgs = CotpArgs::parse(); + if arguments::shell::handle(&cotp_args)? { + return Ok(()); + } + config::load(&mut cotp_args)?; let (database, mut key, salt) = match init(&cotp_args) { Ok(v) => v, Err(e) => { diff --git a/tests/shell_integration_tests.rs b/tests/shell_integration_tests.rs new file mode 100644 index 00000000..8739348e --- /dev/null +++ b/tests/shell_integration_tests.rs @@ -0,0 +1,40 @@ +use assert_cmd::Command; +use predicates::prelude::*; + +#[test] +fn completions_and_manual_do_not_open_a_vault() { + let dir = assert_fs::TempDir::new().unwrap(); + for shell in ["bash", "zsh", "fish", "powershell", "elvish"] { + Command::new(assert_cmd::cargo::cargo_bin!("cotp")) + .current_dir(dir.path()) + .args(["completions", shell]) + .assert() + .success() + .stdout(predicate::str::contains("cotp")); + } + Command::new(assert_cmd::cargo::cargo_bin!("cotp")) + .current_dir(dir.path()) + .arg("man") + .assert() + .success() + .stdout(predicate::str::contains(".TH")); + assert!(!dir.path().join("db.cotp").exists()); +} + +#[test] +fn explicit_unknown_profile_is_an_error() { + let dir = assert_fs::TempDir::new().unwrap(); + let config = dir.path().join("config.json"); + std::fs::write(&config, "{}").unwrap(); + Command::new(assert_cmd::cargo::cargo_bin!("cotp")) + .args([ + "--config", + config.to_str().unwrap(), + "--profile", + "missing", + "list", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("Unknown vault profile")); +} From 328ff8b3953f370a29ee0106de681b525273ab74 Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:19:04 +0200 Subject: [PATCH 06/21] feat(cli): checkpoint scripting and code command implementation --- Cargo.lock | 2 +- src/arguments/add.rs | 8 +- src/arguments/code.rs | 176 +++++++++++++++++++++++++++++++ src/arguments/delete.rs | 12 ++- src/arguments/edit.rs | 2 +- src/arguments/extract.rs | 9 +- src/arguments/import.rs | 5 +- src/arguments/list.rs | 143 +++++++++---------------- src/arguments/mod.rs | 24 ++++- src/arguments/passwd.rs | 29 +++-- src/backup.rs | 29 ++--- src/clipboard.rs | 2 +- src/importers/aegis_encrypted.rs | 5 +- src/main.rs | 143 +++++++++++++------------ src/reading.rs | 20 ---- src/utils.rs | 99 +++++++++++++---- 16 files changed, 468 insertions(+), 240 deletions(-) create mode 100644 src/arguments/code.rs diff --git a/Cargo.lock b/Cargo.lock index 22bdc229..33ce3639 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1482,7 +1482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] diff --git a/src/arguments/add.rs b/src/arguments/add.rs index 87b76fa3..25f479e1 100644 --- a/src/arguments/add.rs +++ b/src/arguments/add.rs @@ -79,7 +79,11 @@ impl SubcommandExecutor for AddArgs { } let alias = self.alias.clone(); let mut otp_element = if self.otp_uri { - let mut otp_uri = rpassword::prompt_password("Insert the otp uri: ").unwrap(); + let mut otp_uri = if self.take_secret_from_stdin { + crate::utils::stdin_line()? + } else { + crate::utils::prompt_password("Insert the otp uri: ")? + }; let result = OTPElement::from_otp_uri(otp_uri.as_str()); otp_uri.zeroize(); result? @@ -101,7 +105,7 @@ fn get_from_args(matches: AddArgs) -> color_eyre::Result { Err(eyre::eyre!("Error during reading from stdin")) } } else { - rpassword::prompt_password("Insert the secret: ").map_err(ErrReport::from) + crate::utils::prompt_password("Insert the secret: ") }?; map_args_to_code(secret, matches) } diff --git a/src/arguments/code.rs b/src/arguments/code.rs new file mode 100644 index 00000000..605561f1 --- /dev/null +++ b/src/arguments/code.rs @@ -0,0 +1,176 @@ +use super::{SubcommandExecutor, selector::AccountSelector}; +use crate::{ + clipboard, + otp::{ + otp_element::{OTPDatabase, OTPElement}, + otp_type::OTPType, + }, +}; +use clap::Args; +use color_eyre::eyre::eyre; +use serde::Serialize; +use std::{ + io::{self, Write}, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +#[derive(Args)] +pub struct CodeArgs { + #[command(flatten)] + pub selector: AccountSelector, + /// Emit one JSON object per code, including expiry in Unix seconds + #[arg(long)] + pub json: bool, + /// Wait until the code has this much validity left (e.g. 10s) + #[arg(long, default_value="0s", value_parser=parse_duration)] + pub min_validity: Duration, + /// Emit a new code at each time interval until interrupted + #[arg(long)] + pub watch: bool, + /// Copy the generated code to the clipboard + #[arg(long, alias = "copy-clipboard")] + pub copy: bool, + /// Suppress code output when copying + #[arg(long, requires = "copy", conflicts_with = "json")] + pub quiet: bool, + /// Show the current HOTP code without advancing its counter + #[arg(long)] + pub preview: bool, +} + +#[derive(Serialize)] +struct CodeOutput<'a> { + id: &'a str, + issuer: &'a str, + label: &'a str, + code: &'a str, + expires_at: Option, + remaining_seconds: Option, +} + +pub fn parse_duration(value: &str) -> Result { + let value = value.strip_suffix('s').unwrap_or(value); + value + .parse::() + .map(Duration::from_secs) + .map_err(|_| "Use a non-negative number of seconds, for example 10s".into()) +} + +pub(crate) fn period(element: &OTPElement) -> color_eyre::Result> { + match element.type_ { + OTPType::Hotp => Ok(None), + OTPType::Steam => Ok(Some(30)), + _ if element.period == 0 => Err(eyre!("Account has an invalid zero period")), + _ => Ok(Some(element.period)), + } +} + +fn now() -> color_eyre::Result { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?) +} + +fn timing(time: Duration, period: u64) -> color_eyre::Result<(u64, Duration)> { + let expires = (time.as_secs() / period) + .checked_add(1) + .and_then(|next| next.checked_mul(period)) + .ok_or_else(|| eyre!("Code expiry overflows the clock"))?; + Ok((expires, Duration::from_secs(expires).saturating_sub(time))) +} + +impl SubcommandExecutor for CodeArgs { + fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { + let index = self.selector.select(&database)?; + let period = period(&database.elements_ref()[index])?; + if period.is_none() && (self.watch || !self.min_validity.is_zero()) { + return Err(eyre!( + "HOTP has no time interval; --watch and --min-validity are unavailable" + )); + } + if period.is_some_and(|period| self.min_validity >= Duration::from_secs(period)) { + return Err(eyre!( + "Minimum validity must be shorter than the account period" + )); + } + let mut previous_expiry = None; + loop { + let before = now()?; + let timing_before = period.map(|period| timing(before, period)).transpose()?; + if let Some((expiry, remaining)) = timing_before + && (remaining < self.min_validity || previous_expiry == Some(expiry)) + { + thread::sleep(remaining.min(Duration::from_millis(200))); + continue; + } + let code = if period.is_none() && !self.preview { + crate::persistence::consume_hotp(&mut database, index)? + } else { + database.elements_ref()[index].get_otp_code()? + }; + let timing_after = period.map(|period| timing(now()?, period)).transpose()?; + // A code generated across a rollover must never be paired with the next expiry. + if timing_before.map(|value| value.0) != timing_after.map(|value| value.0) + || timing_after.is_some_and(|(_, remaining)| remaining < self.min_validity) + { + continue; + } + if self.copy { + clipboard::copy_string_to_clipboard(&code)?; + } + if !self.quiet { + let mut stdout = io::stdout().lock(); + if self.json { + let element = &database.elements_ref()[index]; + serde_json::to_writer( + &mut stdout, + &CodeOutput { + id: &element.id, + issuer: &element.issuer, + label: &element.label, + code: &code, + expires_at: timing_after.map(|value| value.0), + remaining_seconds: timing_after.map(|value| value.1.as_secs()), + }, + )?; + writeln!(stdout)?; + } else { + writeln!(stdout, "{code}")?; + } + stdout.flush()?; + } + if !self.watch { + break; + } + previous_expiry = timing_after.map(|value| value.0); + } + Ok(database) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn timing_respects_boundaries_without_overstating_validity() { + assert_eq!( + timing(Duration::from_secs(60), 30).unwrap(), + (90, Duration::from_secs(30)) + ); + assert_eq!( + timing(Duration::from_millis(89999), 30).unwrap(), + (90, Duration::from_millis(1)) + ); + assert_eq!( + timing(Duration::from_secs(90), 30).unwrap(), + (120, Duration::from_secs(30)) + ); + } + #[test] + fn durations_have_an_explicit_safe_grammar() { + assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10)); + assert_eq!(parse_duration("0").unwrap(), Duration::ZERO); + for invalid in ["-1s", "NaN", "10m", "18446744073709551616s"] { + assert!(parse_duration(invalid).is_err()); + } + } +} diff --git a/src/arguments/delete.rs b/src/arguments/delete.rs index 104b68f6..6962b678 100644 --- a/src/arguments/delete.rs +++ b/src/arguments/delete.rs @@ -14,6 +14,9 @@ use super::SubcommandExecutor; #[derive(Args)] pub struct DeleteArgs { + /// Delete the selected account without prompting + #[arg(long, short = 'y')] + pub yes: bool, #[command(flatten)] pub selector: super::selector::AccountSelector, } @@ -27,13 +30,18 @@ impl SubcommandExecutor for DeleteArgs { let index_to_delete = self.selector.select(&otp_database)?; if let Some(element) = otp_database.elements_ref().get(index_to_delete) { - print!( + if self.yes { + otp_database.delete_element(index_to_delete); + return Ok(otp_database); + } + crate::utils::ensure_interactive()?; + eprint!( "Are you sure you want to delete the {}th code ({}, {}) [Y,N]: ", index_to_delete + 1, element.issuer, element.label ); - io::stdout().flush()?; + io::stderr().flush()?; let output = read_confirmation_line()?; diff --git a/src/arguments/edit.rs b/src/arguments/edit.rs index e3749bac..da59b533 100644 --- a/src/arguments/edit.rs +++ b/src/arguments/edit.rs @@ -72,7 +72,7 @@ impl SubcommandExecutor for EditArgs { } let secret = self .change_secret - .then(|| rpassword::prompt_password("Insert the secret: ")) + .then(|| crate::utils::prompt_password("Insert the secret: ")) .transpose()?; let element = database .mut_element(index) diff --git a/src/arguments/extract.rs b/src/arguments/extract.rs index 8d532e61..1dd8f906 100644 --- a/src/arguments/extract.rs +++ b/src/arguments/extract.rs @@ -13,9 +13,14 @@ pub struct ExtractArgs { } impl SubcommandExecutor for ExtractArgs { - fn run_command(self, otp_database: OTPDatabase) -> color_eyre::Result { + fn run_command(self, mut otp_database: OTPDatabase) -> color_eyre::Result { let index = self.selector.select(&otp_database)?; - let code = otp_database.elements_ref()[index].get_otp_code()?; + let code = + if otp_database.elements_ref()[index].type_ == crate::otp::otp_type::OTPType::Hotp { + crate::persistence::consume_hotp(&mut otp_database, index)? + } else { + otp_database.elements_ref()[index].get_otp_code()? + }; if self.copy_to_clipboard { clipboard::copy_string_to_clipboard(&code)?; eprintln!("Copied to clipboard"); diff --git a/src/arguments/import.rs b/src/arguments/import.rs index 6a1abecd..d7ea87f9 100644 --- a/src/arguments/import.rs +++ b/src/arguments/import.rs @@ -9,7 +9,7 @@ use crate::{ use clap::Args; use color_eyre::eyre::{Result, eyre}; use std::{ - io::{self, IsTerminal, Read, Write}, + io::{self, Read, Write}, path::PathBuf, }; @@ -181,7 +181,8 @@ impl SubcommandExecutor for ImportArgs { if self.dry_run { return Ok(database); } - if !self.yes && io::stdin().is_terminal() { + if !self.yes { + crate::utils::ensure_interactive()?; eprint!("Apply import? [y/N] "); io::stderr().flush()?; let mut answer = String::new(); diff --git a/src/arguments/list.rs b/src/arguments/list.rs index d09b7e65..2f82bdda 100644 --- a/src/arguments/list.rs +++ b/src/arguments/list.rs @@ -1,26 +1,27 @@ use clap::Args; -use color_eyre::eyre::{Result, eyre}; +use color_eyre::eyre::Result; use serde::Serialize; - -use crate::otp::otp_element::{OTPDatabase, OTPElement}; +use std::io::{self, Write}; use super::SubcommandExecutor; +use crate::otp::otp_element::OTPDatabase; #[derive(Args)] pub struct ListArgs { /// List output format #[command(flatten)] pub format: Option, + /// List account metadata without generating or exposing codes + #[arg(long)] + pub metadata_only: bool, } -/// Defines the output formats of the list subcommand #[derive(Args)] #[group(required = false, multiple = false)] pub struct ListFormat { /// List OTP codes in plain format #[arg(short, long)] pub plain: bool, - /// List OTP codes in JSON format #[arg(short = 'e', long)] pub json: bool, @@ -35,106 +36,64 @@ impl Default for ListFormat { } } -/// Defines JSON structure to output using the --json argument in the list subcommand #[derive(Serialize)] struct JsonOtpList<'a> { + index: usize, id: &'a str, alias: Option<&'a str>, issuer: &'a str, label: &'a str, - otp_code: String, + #[serde(skip_serializing_if = "Option::is_none")] + otp_code: Option, } -impl<'a> TryFrom<&'a OTPElement> for JsonOtpList<'a> { - type Error = color_eyre::eyre::Error; - - fn try_from(value: &'a OTPElement) -> Result { - let otp_code = value.get_otp_code()?; - Ok(JsonOtpList { - id: &value.id, - alias: value.alias.as_deref(), - issuer: &value.issuer, - label: &value.label, - otp_code, - }) - } -} - -const NO_ISSUER_TEXT: &str = ""; - impl SubcommandExecutor for ListArgs { - fn run_command(self, otp_database: OTPDatabase) -> color_eyre::Result { + fn run_command(self, database: OTPDatabase) -> Result { + let elements = database + .elements_ref() + .iter() + .enumerate() + .map(|(index, element)| { + Ok(JsonOtpList { + index: index + 1, + id: &element.id, + alias: element.alias.as_deref(), + issuer: &element.issuer, + label: &element.label, + otp_code: if self.metadata_only { + None + } else { + Some(element.get_otp_code()?) + }, + }) + }) + .collect::>>()?; + let mut stdout = io::stdout().lock(); if self.format.unwrap_or_default().json { - let json_elements = otp_database - .elements - .iter() - .map(TryInto::try_into) - .collect::>>()?; - - let stringified = serde_json::to_string_pretty(&json_elements) - .map_err(|e| eyre!("Error during JSON serialization: {:?}", e))?; - print!("{stringified}"); + serde_json::to_writer_pretty(&mut stdout, &elements)?; + writeln!(stdout)?; } else { - if otp_database.elements.is_empty() { - println!("No elements to list"); - return Ok(otp_database); + write!(stdout, "Index\tID\tAlias\tIssuer\tLabel")?; + if !self.metadata_only { + write!(stdout, "\tOTP")?; } - let issuer_width = calculate_width(&otp_database, |element| { - let issuer_length = element.issuer.chars().count(); - if issuer_length > 0 { - issuer_length - } else { - NO_ISSUER_TEXT.chars().count() + writeln!(stdout)?; + for element in elements { + write!( + stdout, + "{}\t{}\t{}\t{}\t{}", + element.index, + element.id.escape_default(), + element.alias.unwrap_or("").escape_default(), + element.issuer.escape_default(), + element.label.escape_default() + )?; + if let Some(code) = element.otp_code { + write!(stdout, "\t{code}")?; } - }); - - let label_width = - calculate_width(&otp_database, |element| element.label.chars().count()); - - println!( - "{0: <6} {1} {2} {3: <10}", - "Index", - "Issuer".to_owned() + " ".repeat(issuer_width - 6).as_ref(), - "Label".to_owned() + " ".repeat(label_width - 5).as_ref(), - "OTP", - ); - otp_database - .elements - .iter() - .enumerate() - .for_each(|(index, e)| { - println!( - "{0: <6} {1} {2} {3: <10}", - index + 1, - if e.issuer.is_empty() { - NO_ISSUER_TEXT.to_owned() - + " " - .repeat(issuer_width - NO_ISSUER_TEXT.chars().count()) - .as_str() - } else { - e.issuer.clone() - + " ".repeat(issuer_width - e.issuer.chars().count()).as_str() - }, - e.label.clone() - + " ".repeat(label_width - e.label.chars().count()).as_str(), - e.get_otp_code().unwrap_or("ERROR".to_string()) - ); - }); + writeln!(stdout)?; + } } - - Ok(otp_database) + Ok(database) } } - -fn calculate_width(otp_database: &OTPDatabase, get_number_of_chars: F) -> usize -where - F: Fn(&OTPElement) -> usize, -{ - otp_database - .elements - .iter() - .map(get_number_of_chars) - .max() - .unwrap_or_default() - + 3 -} diff --git a/src/arguments/mod.rs b/src/arguments/mod.rs index a1b44eaf..59b501fb 100644 --- a/src/arguments/mod.rs +++ b/src/arguments/mod.rs @@ -11,12 +11,13 @@ use self::{ }; mod add; +pub(crate) mod code; mod delete; mod edit; mod export; mod extract; mod import; -mod list; +pub(crate) mod list; mod passwd; pub(crate) mod selector; @@ -42,8 +43,14 @@ DASHBOARD pub struct CotpArgs { #[command(subcommand)] pub(crate) command: Option, + /// Disable all interactive prompts + #[arg(long, global = true)] + pub no_input: bool, + /// Read the vault password from this descriptor, leaving stdin for account data + #[arg(long, global = true, conflicts_with = "password_from_stdin")] + pub password_fd: Option, /// Fetch the password from standard input - #[arg(long = "password-stdin", default_value_t = false)] + #[arg(long = "password-stdin", default_value_t = false, global = true)] pub password_from_stdin: bool, /// Set the database path #[arg(short = 'd', long = "database-path")] @@ -54,6 +61,10 @@ pub struct CotpArgs { #[derive(Subcommand)] #[enum_dispatch(SubcommandExecutor)] pub enum CotpSubcommands { + /// Create a new encrypted vault, including in scripts with --password-stdin/--password-fd + Init(InitArgs), + /// Generate one uniquely selected account code (HOTP advances unless --preview) + Code(code::CodeArgs), /// Add new OTP code Add(AddArgs), /// Edit an existing OTP Code @@ -74,6 +85,15 @@ pub enum CotpSubcommands { Backup(crate::backup::BackupArgs), } +#[derive(clap::Args)] +pub struct InitArgs; +impl SubcommandExecutor for InitArgs { + fn run_command(self, database: OTPDatabase) -> color_eyre::Result { + eprintln!("Encrypted vault initialized"); + Ok(database) + } +} + pub fn args_parser(matches: CotpArgs, read_result: OTPDatabase) -> color_eyre::Result { if let Some(command) = matches.command { command.run_command(read_result) diff --git a/src/arguments/passwd.rs b/src/arguments/passwd.rs index 1ca741fb..f55e4530 100644 --- a/src/arguments/passwd.rs +++ b/src/arguments/passwd.rs @@ -1,18 +1,29 @@ -use clap::Args; -use zeroize::Zeroize; - -use crate::{otp::otp_element::OTPDatabase, utils}; - use super::SubcommandExecutor; +use crate::{otp::otp_element::OTPDatabase, utils}; +use clap::Args; +use color_eyre::eyre::eyre; +use zeroize::Zeroizing; #[derive(Args)] -pub struct PasswdArgs; +pub struct PasswdArgs { + /// Read the new password from this descriptor (Unix) + #[arg(long)] + pub new_password_fd: Option, +} impl SubcommandExecutor for PasswdArgs { fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { - let mut new_password = utils::verified_password("New password: ", 8); - database.save_with_pw(&new_password)?; - new_password.zeroize(); + let new_password = Zeroizing::new(if let Some(fd) = self.new_password_fd { + utils::password_from_fd(fd)? + } else { + utils::verified_password("New password: ", 8)? + }); + if new_password.chars().count() < 8 { + return Err(eyre!("New password must contain at least 8 characters")); + } + let (key, salt) = database.save_with_pw(&new_password)?; + let key = Zeroizing::new(key); + crate::persistence::unlock(&key, &salt)?; Ok(database) } } diff --git a/src/backup.rs b/src/backup.rs index 494bd565..571fe577 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -11,7 +11,6 @@ use clap::{Args, Subcommand}; use color_eyre::eyre::{Result, eyre}; use std::{ fs, - io::{self, BufRead}, path::{Path, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; @@ -51,7 +50,12 @@ impl crate::arguments::SubcommandExecutor for BackupArgs { } } -pub fn run(args: &BackupArgs, vault: &Path, password_stdin: bool) -> Result<()> { +pub fn run( + args: &BackupArgs, + vault: &Path, + password_stdin: bool, + password_fd: Option, +) -> Result<()> { match &args.action { BackupAction::Create { directory, keep } => { let path = create(vault, directory)?; @@ -66,7 +70,7 @@ pub fn run(args: &BackupArgs, vault: &Path, password_stdin: bool) -> Result<()> } } BackupAction::Verify { file } => { - let password = password(password_stdin)?; + let password = password(password_stdin, password_fd)?; let bytes = fs::read(file)?; let database = validate(&bytes, &password)?; println!( @@ -75,7 +79,7 @@ pub fn run(args: &BackupArgs, vault: &Path, password_stdin: bool) -> Result<()> ); } BackupAction::Restore { file, force } => { - let password = password(password_stdin)?; + let password = password(password_stdin, password_fd)?; restore(file, vault, &password, *force)?; eprintln!( "Encrypted backup restored. Use the backup's original password to unlock it." @@ -85,17 +89,14 @@ pub fn run(args: &BackupArgs, vault: &Path, password_stdin: bool) -> Result<()> Ok(()) } -fn password(from_stdin: bool) -> Result> { - let password = if from_stdin { - io::stdin() - .lock() - .lines() - .next() - .ok_or_else(|| eyre!("Missing backup password on stdin"))?? +fn password(from_stdin: bool, fd: Option) -> Result> { + Ok(Zeroizing::new(if from_stdin { + crate::utils::stdin_line()? + } else if let Some(fd) = fd { + crate::utils::password_from_fd(fd)? } else { - rpassword::prompt_password("Backup password: ")? - }; - Ok(Zeroizing::new(password)) + crate::utils::prompt_password("Backup password: ")? + })) } fn validate(bytes: &[u8], password: &str) -> Result { diff --git a/src/clipboard.rs b/src/clipboard.rs index 2efea28d..1d46dadd 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -28,7 +28,7 @@ fn ssh_clipboard(content: &str) -> bool { // We do not use copypasta_ext::osc52 module because we have enabled terminal raw mode, so we print with crossterm utilities // Check https://github.com/timvisee/rust-clipboard-ext/blob/371df19d2f961882a21c957f396d1e24548d1f28/src/osc52.rs#L92 && crossterm::execute!( - io::stdout(), + io::stderr(), Print(format!( "\x1B]52;c;{}\x07", general_purpose::STANDARD.encode(content) diff --git a/src/importers/aegis_encrypted.rs b/src/importers/aegis_encrypted.rs index 2132663a..bdf7b6c2 100644 --- a/src/importers/aegis_encrypted.rs +++ b/src/importers/aegis_encrypted.rs @@ -47,7 +47,8 @@ impl TryFrom for Vec { type Error = String; fn try_from(aegis_encrypted: AegisEncryptedDatabase) -> Result { - let mut password = utils::password("Insert your Aegis password: ", 0); + let mut password = + utils::password("Insert your Aegis password: ", 0).map_err(|e| e.to_string())?; let master_key: Option> = get_master_key(&aegis_encrypted, &password); password.zeroize(); @@ -97,7 +98,7 @@ fn get_master_key(aegis_encrypted: &AegisEncryptedDatabase, password: &str) -> O master_key = Some(value); break; } - Err(e) => println!("{e}"), + Err(e) => eprintln!("{e}"), } } master_key diff --git a/src/main.rs b/src/main.rs index 85470521..103a369c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,15 +6,12 @@ use interface::app::AppResult; use interface::event::{Event, EventHandler}; use interface::handlers::{handle_key_events, handle_mouse_events, handle_paste}; use interface::ui::Tui; -use otp::otp_element::{CURRENT_DATABASE_VERSION, OTPDatabase}; +use otp::otp_element::OTPDatabase; use path::init_path; use ratatui::Terminal; use ratatui::prelude::CrosstermBackend; -use reading::{ReadResult, get_elements_from_input, get_elements_from_stdin}; -use std::{ - io::{self, IsTerminal}, - vec, -}; +use reading::ReadResult; +use std::io::{self, IsTerminal}; use zeroize::Zeroize; mod arguments; @@ -33,94 +30,100 @@ mod utils; fn init(args: &CotpArgs) -> color_eyre::Result { let path = init_path(args); + let explicit_init = matches!(args.command, Some(arguments::CotpSubcommands::Init(_))); + if explicit_init && path.exists() { + return Err(eyre!("Vault already exists; init never overwrites a vault")); + } if !path.exists() && !matches!( args.command, - None | Some(arguments::CotpSubcommands::Add(_) | arguments::CotpSubcommands::Import(_)) + None | Some( + arguments::CotpSubcommands::Init(_) + | arguments::CotpSubcommands::Add(_) + | arguments::CotpSubcommands::Import(_) + ) ) { return Err(eyre!( - "Vault does not exist. Create it with cotp add or cotp import." + "Vault does not exist. Create it with cotp init, add, or import." )); } - - match utils::init_app() { - Ok(first_run) => { - persistence::initialize(&path)?; - if first_run { - // Let's initialize the database file - let mut pw = utils::verified_password("Choose a password: ", 8); - let mut database = OTPDatabase { - version: CURRENT_DATABASE_VERSION, - elements: vec![], - ..Default::default() - }; - let save_result = database.save_with_pw(&pw); - pw.zeroize(); - save_result.map(|(key, salt)| (database, key, salt.to_vec())) - } else if args.password_from_stdin { - get_elements_from_stdin() - } else { - get_elements_from_input() - } + let first_run = utils::init_app().map_err(|()| eyre!("Could not create vault directory"))?; + persistence::initialize(&path)?; + let explicit_password = if args.password_from_stdin { + Some(utils::stdin_line()?) + } else { + args.password_fd.map(utils::password_from_fd).transpose()? + }; + let password = zeroize::Zeroizing::new(match explicit_password { + Some(password) => password, + None if first_run => utils::verified_password("Choose a password: ", 8)?, + None => utils::password("Password: ", 0)?, + }); + if first_run { + if password.chars().count() < 8 { + return Err(eyre!( + "New vault password must contain at least 8 characters" + )); } - Err(()) => Err(eyre!("An error occurred during database creation")), + let mut database = OTPDatabase::default(); + let (key, salt) = database.save_with_pw(&password)?; + Ok((database, key, salt.to_vec())) + } else { + reading::read_from_file(&password) } } -fn main() -> AppResult<()> { - color_eyre::install()?; - - let cotp_args: CotpArgs = CotpArgs::parse(); +fn run(cotp_args: CotpArgs) -> Result<(), (u8, color_eyre::eyre::Report)> { + utils::set_no_input(cotp_args.no_input); if let Some(arguments::CotpSubcommands::Backup(args)) = &cotp_args.command { let path = init_path(&cotp_args); - return backup::run(args, &path, cotp_args.password_from_stdin).map_err(|e| e.into()); + return backup::run( + args, + &path, + cotp_args.password_from_stdin, + cotp_args.password_fd, + ) + .map_err(|error| (1, error)); } - let (mut database, key, salt) = match init(&cotp_args) { - Ok(v) => v, - Err(e) => { - eprintln!("{e}"); - std::process::exit(-1); - } - }; - + let (mut database, key, salt) = init(&cotp_args).map_err(|error| (1, error))?; let mut key = zeroize::Zeroizing::new(key); - persistence::unlock(&key, &salt)?; - // Complete format upgrades before commands so transactional HOTP starts clean. - otp::migrations::migrate(&mut database)?; + persistence::unlock(&key, &salt).map_err(|error| (1, error))?; if database.is_modified() { - database.save(&key, &salt)?; + database.save(&key, &salt).map_err(|error| (1, error))?; } key.zeroize(); + let mut database = args_parser(cotp_args, database).map_err(|error| (3, error))?; + if database.is_modified() { + persistence::persist(&mut database).map_err(|error| { + ( + 1, + eyre!("An error occurred during database overwriting: {error}"), + ) + })?; + eprintln!("Modifications have been persisted"); + } + Ok(()) +} - let mut reowned_database = match args_parser(cotp_args, database) { - Ok(d) => d, - Err(e) => { - eprintln!("An error occurred: {e}"); - persistence::lock(); - std::process::exit(-2) - } - }; - - let error_code = if reowned_database.is_modified() { - match persistence::persist(&mut reowned_database) { - Ok(()) => { - eprintln!("Modifications have been persisted"); - 0 - } - Err(e) => { - eprintln!("An error occurred during database overwriting: {e}"); - -1 - } - } - } else { - 0 - }; +fn main() -> std::process::ExitCode { + if let Err(error) = color_eyre::install() { + eprintln!("{error}"); + return std::process::ExitCode::FAILURE; + } + let result = run(CotpArgs::parse()); persistence::lock(); - std::process::exit(error_code) + match result { + Ok(()) => std::process::ExitCode::SUCCESS, + Err((code, error)) => { + eprintln!("{error}"); + std::process::ExitCode::from(code) + } + } } fn dashboard(mut database: OTPDatabase) -> AppResult { + utils::ensure_interactive()?; if !io::stderr().is_terminal() || !io::stdin().is_terminal() { if database.elements_ref().is_empty() { println!("No codes, type \"cotp -h\" to get help"); diff --git a/src/reading.rs b/src/reading.rs index 17f2b669..fda40c9c 100644 --- a/src/reading.rs +++ b/src/reading.rs @@ -1,32 +1,12 @@ use crate::crypto; use crate::otp::otp_element::{OTPDatabase, OTPElement}; use crate::path::DATABASE_PATH; -use crate::utils; use color_eyre::eyre::{ErrReport, eyre}; use std::fs::read_to_string; -use std::io::{self, BufRead}; use zeroize::Zeroize; pub type ReadResult = (OTPDatabase, Vec, Vec); -pub fn get_elements_from_input() -> color_eyre::Result { - let pw = utils::password("Password: ", 8); - get_elements_with_password(pw) -} - -pub fn get_elements_from_stdin() -> color_eyre::Result { - if let Some(password) = io::stdin().lock().lines().next() { - return get_elements_with_password(password?); - } - Err(eyre!("Failure during stdin reading")) -} - -fn get_elements_with_password(mut password: String) -> color_eyre::Result { - let (elements, key, salt) = read_from_file(&password)?; - password.zeroize(); - Ok((elements, key, salt)) -} - pub fn read_decrypted_text(password: &str) -> color_eyre::Result<(String, Vec, Vec)> { let encrypted_contents = read_to_string(DATABASE_PATH.get().unwrap()).map_err(ErrReport::from)?; diff --git a/src/utils.rs b/src/utils.rs index 3f6175a6..9444532b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,39 +1,98 @@ use crate::path::DATABASE_PATH; +use color_eyre::eyre::eyre; +use std::io::{self, BufRead, IsTerminal}; +use std::sync::atomic::{AtomicBool, Ordering}; +use zeroize::Zeroize; -pub fn init_app() -> Result { - let db_path = DATABASE_PATH.get().unwrap(); // Safe to unwrap because we initialize - let db_dir = db_path - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .unwrap_or(std::path::Path::new(".")); - if !db_dir.exists() { - if let Err(_e) = std::fs::create_dir_all(db_dir) { - return Err(()); +static NO_INPUT: AtomicBool = AtomicBool::new(false); + +pub fn set_no_input(value: bool) { + NO_INPUT.store(value, Ordering::Relaxed); +} + +pub fn ensure_interactive() -> color_eyre::Result<()> { + if NO_INPUT.load(Ordering::Relaxed) || !io::stdin().is_terminal() { + return Err(eyre!( + "Interactive input is disabled; provide explicit input options (see --help)" + )); + } + Ok(()) +} + +pub fn prompt_password(message: &str) -> color_eyre::Result { + ensure_interactive()?; + Ok(rpassword::prompt_password(message)?) +} + +pub fn stdin_line() -> color_eyre::Result { + read_line(&mut io::stdin().lock()) +} + +pub fn read_line(reader: &mut impl BufRead) -> color_eyre::Result { + let mut value = String::new(); + if reader.read_line(&mut value)? == 0 { + return Err(eyre!("Input ended before a value was supplied")); + } + if value.ends_with('\n') { + value.pop(); + if value.ends_with('\r') { + value.pop(); } - return Ok(true); + } + Ok(value) +} + +pub fn password_from_fd(fd: u32) -> color_eyre::Result { + if fd == 0 { + return stdin_line(); + } + #[cfg(unix)] + { + read_line(&mut io::BufReader::new(std::fs::File::open(format!( + "/dev/fd/{fd}" + ))?)) + } + #[cfg(not(unix))] + { + let _ = fd; + Err(eyre!( + "Password file descriptors are supported on Unix; use --password-stdin on this platform" + )) + } +} + +pub fn init_app() -> Result { + let db_path = DATABASE_PATH.get().unwrap(); + let db_dir = db_path.parent().unwrap(); + if !db_dir.as_os_str().is_empty() && !db_dir.exists() { + std::fs::create_dir_all(db_dir).map_err(|_| ())?; } Ok(!db_path.exists()) } -pub fn password(message: &str, minimum_length: usize) -> String { +pub fn password(message: &str, minimum_length: usize) -> color_eyre::Result { loop { - let password = rpassword::prompt_password(message).unwrap(); + let mut password = prompt_password(message)?; if password.chars().count() < minimum_length { - println!("Please insert a password with at least {minimum_length} digits."); + password.zeroize(); + eprintln!("Please insert a password with at least {minimum_length} characters."); continue; } - return password; + return Ok(password); } } -pub fn verified_password(message: &str, minimum_length: usize) -> String { +pub fn verified_password(message: &str, minimum_length: usize) -> color_eyre::Result { loop { - let password = password(message, minimum_length); - let verify_password = rpassword::prompt_password("Retype the same password: ").unwrap(); - if password != verify_password { - println!("Passwords do not match"); + let mut password = password(message, minimum_length)?; + let mut verify_password = prompt_password("Retype the same password: ")?; + let matches = password == verify_password; + verify_password.zeroize(); + if !matches { + password.zeroize(); + eprintln!("Passwords do not match"); continue; } - return password; + return Ok(password); } } From 50fbb9521ddbfd17df0c61c0a28a173361955e79 Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:20:28 +0200 Subject: [PATCH 07/21] feat(hardware): add YubiKey OATH backend through ykman --- docs/hardware.md | 32 ++++ src/arguments/mod.rs | 8 + src/hardware.rs | 256 ++++++++++++++++++++++++++++ src/main.rs | 4 + tests/hardware_integration_tests.rs | 45 +++++ 5 files changed, 345 insertions(+) create mode 100644 docs/hardware.md create mode 100644 src/hardware.rs create mode 100644 tests/hardware_integration_tests.rs diff --git a/docs/hardware.md b/docs/hardware.md new file mode 100644 index 00000000..988e0bde --- /dev/null +++ b/docs/hardware.md @@ -0,0 +1,32 @@ +# YubiKey OATH backend + +Install YubiKey Manager (`ykman`) and enable the key's OATH application. cotp invokes +its CLI directly without a shell, keeping the OTP secret on the device. Password +and touch prompts are handled by ykman. Multiple connected keys can be selected +with the global `--device SERIAL` option. + +```sh +cotp --backend yubikey list --json +cotp hardware code 'GitHub:me@example.com' +cotp hardware code 'GitHub:me@example.com' --copy --quiet +cotp --device 12345678 hardware add 'me@example.com' --issuer GitHub --touch +cotp hardware delete 'GitHub:me@example.com' +``` + +Enrollment prompts for the secret instead of putting it in process arguments or +shell history. Enrollment and deletion require an interactive terminal. Queries +must match exactly one account; an exact full name takes precedence over substring +matches. `ykman --single` enforces single matching again on generation. HOTP counters +advance on the key, even if output delivery later fails; cotp never retries a code +generation automatically. Listing does not generate codes or require touch. + +Device accounts use their full OATH names as IDs. Local vault UUIDs, tags and aliases +are not copied onto the device. Hardware JSON reports `expires_at: null` because the +CLI adapter does not know each credential's period. No local vault is opened for +hardware commands. Device/password errors propagate as failures rather than falling +back to a software copy of a secret. Without interactive input, ykman's stdin is +closed so a password prompt fails instead of hanging. + +The backend is tested with a deterministic subprocess fixture; physical-device and +touch behavior requires a real key and is not exercised by the regular test suite. +Reference: https://docs.yubico.com/software/yubikey/tools/ykman/OATH_Commands.html diff --git a/src/arguments/mod.rs b/src/arguments/mod.rs index 8d4f3dde..e63734b2 100644 --- a/src/arguments/mod.rs +++ b/src/arguments/mod.rs @@ -41,6 +41,12 @@ DASHBOARD a add · e edit · / search · Enter copy · Space QR code · ? help" )] pub struct CotpArgs { + /// Use the local encrypted vault or a connected YubiKey + #[arg(long, global = true, value_enum, default_value = "vault")] + pub backend: crate::hardware::Backend, + /// Select a YubiKey by its serial number + #[arg(long, global = true)] + pub device: Option, #[command(subcommand)] pub(crate) command: Option, /// Read settings from this JSON file @@ -61,6 +67,8 @@ pub struct CotpArgs { #[derive(Subcommand)] #[enum_dispatch(SubcommandExecutor)] pub enum CotpSubcommands { + /// Manage accounts on a YubiKey through ykman + Hardware(crate::hardware::HardwareArgs), /// Generate shell completion scripts without unlocking a vault Completions(shell::CompletionsArgs), /// Generate manual pages without unlocking a vault diff --git a/src/hardware.rs b/src/hardware.rs new file mode 100644 index 00000000..019e3ee5 --- /dev/null +++ b/src/hardware.rs @@ -0,0 +1,256 @@ +//! Optional YubiKey OATH backend. ykman owns device transport and touch prompts. +use clap::{Args, Subcommand, ValueEnum}; +use color_eyre::eyre::{Result, WrapErr, eyre}; +use std::io::IsTerminal; +use std::process::{Command, Stdio}; +use zeroize::Zeroizing; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] +pub enum Backend { + #[default] + Vault, + Yubikey, +} + +#[derive(Args)] +pub struct HardwareArgs { + #[command(subcommand)] + command: HardwareCommand, +} + +#[derive(Subcommand)] +enum HardwareCommand { + /// List accounts stored on the selected key + List { + #[arg(long)] + json: bool, + }, + /// Generate exactly one code (HOTP advances on the device) + Code { + query: String, + #[arg(long)] + copy: bool, + #[arg(long)] + quiet: bool, + #[arg(long)] + json: bool, + }, + /// Enroll an account; ykman securely prompts for its secret + Add { + name: String, + #[arg(long)] + touch: bool, + #[arg(long)] + issuer: Option, + }, + /// Delete a single matching account, with ykman's confirmation + Delete { query: String }, +} + +impl crate::arguments::SubcommandExecutor for HardwareArgs { + fn run_command( + self, + _: crate::otp::otp_element::OTPDatabase, + ) -> Result { + Err(eyre!( + "Hardware commands must run before opening a local vault" + )) + } +} + +fn command(device: Option<&str>) -> Command { + let mut command = Command::new("ykman"); + if let Some(serial) = device { + command.args(["--device", serial]); + } + command.args(["oath", "accounts"]); + command.stderr(Stdio::inherit()); + if !std::io::stdin().is_terminal() { + command.stdin(Stdio::null()); + } + command +} + +fn accounts(device: Option<&str>) -> Result> { + let output = command(device) + .arg("list") + .output() + .wrap_err("Cannot run ykman; install YubiKey Manager and enable the OATH application")?; + if !output.status.success() { + return Err(eyre!("YubiKey account listing failed ({})", output.status)); + } + let text = String::from_utf8(output.stdout).wrap_err("Invalid UTF-8 from ykman")?; + Ok(text + .lines() + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect()) +} + +fn select<'a>(names: &'a [String], query: &str) -> Result<&'a str> { + if query.is_empty() { + return Err(eyre!("An account query is required")); + } + let exact: Vec<_> = names + .iter() + .filter(|n| n.eq_ignore_ascii_case(query)) + .collect(); + let matches: Vec<_> = if exact.is_empty() { + let query = query.to_lowercase(); + names + .iter() + .filter(|n| n.to_lowercase().contains(&query)) + .collect() + } else { + exact + }; + match matches.as_slice() { + [name] => Ok(name), + [] => Err(eyre!("No YubiKey account matches the query")), + _ => Err(eyre!( + "Ambiguous YubiKey account query ({} matches); use the full account name", + matches.len() + )), + } +} + +pub fn list(device: Option<&str>, json: bool) -> Result<()> { + let names = accounts(device)?; + if json { + let entries: Vec<_> = names + .iter() + .map(|name| serde_json::json!({"id":name,"backend":"yubikey"})) + .collect(); + println!("{}", serde_json::to_string(&entries)?); + } else { + for name in names { + println!("{}", name.escape_default()); + } + } + Ok(()) +} + +pub fn code(device: Option<&str>, query: &str, copy: bool, quiet: bool, json: bool) -> Result<()> { + if quiet && !copy { + return Err(eyre!("--quiet requires --copy")); + } + let names = accounts(device)?; + let name = select(&names, query)?; + // --single also asks ykman to reject ambiguity before advancing an HOTP counter. + let output = command(device) + .args(["code", "--single", "--", name]) + .output() + .wrap_err("Cannot run ykman")?; + if !output.status.success() { + return Err(eyre!("YubiKey code generation failed ({})", output.status)); + } + let text = Zeroizing::new( + String::from_utf8(output.stdout).wrap_err("Invalid code output from ykman")?, + ); + let code = text.trim(); + if !(6..=8).contains(&code.len()) || !code.bytes().all(|b| b.is_ascii_digit()) { + return Err(eyre!("ykman returned an unexpected code format")); + } + if copy { + crate::clipboard::copy_string_to_clipboard(code)?; + } + if !quiet { + if json { + println!( + "{}", + serde_json::json!({"id":name,"backend":"yubikey","code":code,"expires_at":null}) + ); + } else { + println!("{code}"); + } + } + Ok(()) +} + +pub fn handle(args: &crate::arguments::CotpArgs) -> Result { + use crate::arguments::CotpSubcommands; + let device = args.device.as_deref(); + if let Some(CotpSubcommands::Hardware(hardware)) = &args.command { + match &hardware.command { + HardwareCommand::List { json } => list(device, *json)?, + HardwareCommand::Code { + query, + copy, + quiet, + json, + } => code(device, query, *copy, *quiet, *json)?, + HardwareCommand::Add { + name, + touch, + issuer, + } => { + if !std::io::stdin().is_terminal() { + return Err(eyre!( + "Hardware enrollment requires an interactive terminal" + )); + } + let mut command = command(device); + command.arg("add"); + if *touch { + command.arg("--touch"); + } + if let Some(issuer) = issuer { + command.args(["--issuer", issuer]); + } + let status = command.args(["--", name]).status()?; + if !status.success() { + return Err(eyre!("YubiKey enrollment failed ({status})")); + } + } + HardwareCommand::Delete { query } => { + if !std::io::stdin().is_terminal() { + return Err(eyre!("Hardware deletion requires an interactive terminal")); + } + let names = accounts(device)?; + let name = select(&names, query)?; + let status = command(device).args(["delete", "--", name]).status()?; + if !status.success() { + return Err(eyre!("YubiKey deletion failed ({status})")); + } + } + } + return Ok(true); + } + if args.backend == Backend::Vault { + return Ok(false); + } + match &args.command { + Some(CotpSubcommands::List(list_args)) => { + list(device, list_args.format.as_ref().is_some_and(|f| f.json))? + } + _ => { + return Err(eyre!( + "Use cotp hardware code QUERY or cotp --backend yubikey list for hardware accounts" + )); + } + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn ambiguous_hardware_queries_fail_before_generation() { + let names = vec!["GitHub:personal".into(), "GitHub:work".into()]; + assert!(select(&names, "github").is_err()); + assert_eq!(select(&names, "GITHUB:work").unwrap(), "GitHub:work"); + assert!(select(&names, "").is_err()); + assert!(select(&names, "absent").is_err()); + } + #[test] + fn device_and_query_arguments_are_not_shell_commands() { + let command = command(Some("123456")); + let args: Vec<_> = command + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + assert_eq!(args, ["--device", "123456", "oath", "accounts"]); + assert_eq!(command.get_program(), "ykman"); + } +} diff --git a/src/main.rs b/src/main.rs index 20d0b297..f8d7e540 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ mod config; mod crypto; mod database_file; mod exporters; +mod hardware; mod importers; mod interface; mod otp; @@ -77,6 +78,9 @@ fn main() -> AppResult<()> { return Ok(()); } config::load(&mut cotp_args)?; + if hardware::handle(&cotp_args)? { + return Ok(()); + } if let Some(arguments::CotpSubcommands::Backup(args)) = &cotp_args.command { let path = init_path(&cotp_args); return backup::run(args, &path, cotp_args.password_from_stdin).map_err(|e| e.into()); diff --git a/tests/hardware_integration_tests.rs b/tests/hardware_integration_tests.rs new file mode 100644 index 00000000..ac7f995b --- /dev/null +++ b/tests/hardware_integration_tests.rs @@ -0,0 +1,45 @@ +#[cfg(unix)] +mod unix { + use assert_cmd::Command; + use predicates::prelude::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn hardware_backend_does_not_open_vault_and_requires_one_match() { + let dir = assert_fs::TempDir::new().unwrap(); + let executable = dir.path().join("ykman"); + std::fs::write( + &executable, + r#"#!/bin/sh +case "$*" in + "oath accounts list") printf 'GitHub:personal\nGitHub:work\n' ;; + "oath accounts code --single -- GitHub:work") printf '123456\n' ;; + *) exit 8 ;; +esac +"#, + ) + .unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + let run = || { + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("cotp")); + cmd.current_dir(dir.path()).env("PATH", dir.path()); + cmd + }; + run() + .args(["--backend", "yubikey", "list", "--json"]) + .assert() + .success() + .stdout(predicate::str::contains("GitHub:work")); + run() + .args(["hardware", "code", "github"]) + .assert() + .failure() + .stderr(predicate::str::contains("Ambiguous")); + run() + .args(["hardware", "code", "GitHub:work"]) + .assert() + .success() + .stdout("123456\n"); + assert!(!dir.path().join("db.cotp").exists()); + } +} From 008b418043c206525c1fec3546982df8c80d7c82 Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:21:39 +0200 Subject: [PATCH 08/21] feat(cli): complete scripting commands and durable HOTP output --- README.md | 33 +++- src/arguments/add.rs | 10 +- src/arguments/code.rs | 4 +- src/arguments/mod.rs | 2 +- tests/import_integration_tests.rs | 10 +- tests/scripting_integration_tests.rs | 252 +++++++++++++++++++++++++++ 6 files changed, 297 insertions(+), 14 deletions(-) create mode 100644 tests/scripting_integration_tests.rs diff --git a/README.md b/README.md index c37a3ba8..5cc3635f 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,37 @@ On `edit`, `--issuer` and `--label` still set new values; use `--match-issuer` and `--match-label` to select by those fields. Aliases are unique ignoring case; `edit --clear-alias` removes an alias. Account IDs survive renaming and TUI edits. +### Codes and scripts + +`cotp code QUERY` selects one account by ID, alias, or issuer/label text. +Use `--json` for an object with `id`, `issuer`, `label`, `code`, `expires_at` +(Unix seconds), and `remaining` (whole seconds). HOTP expiry fields are `null`. +`--min-validity 10s` waits for a fresh interval when needed; the requested validity +must be shorter than the account period. `--watch` emits one code per interval, +flushing each line for consumers. `--copy --quiet` copies without printing a code. + +HOTP `code` and legacy `extract` persist the next counter before releasing a code. +`code --preview` and `list` show the current counter without advancing it. +`list --metadata-only --json` lists account identities without generating codes. + +For unattended use, pass `--no-input` to reject every interactive prompt. +`--password-stdin` reads one password line; on Unix, `--password-fd 3` keeps stdin +available for a secret or import. Passwords are never command-line values. +`init` creates a vault with a supplied password of at least eight characters and +refuses to overwrite an existing vault. Imports require `--yes` to apply without +confirmation; deletion uses `--yes`. Password rotation accepts `passwd --new-password-fd`. + +```sh +cotp --no-input --password-fd 3 init 3 color_eyre::Result { let secret = if matches.take_secret_from_stdin { - if let Some(password) = io::stdin().lock().lines().next() { - password.map_err(ErrReport::from) - } else { - Err(eyre::eyre!("Error during reading from stdin")) - } + crate::utils::stdin_line() } else { crate::utils::prompt_password("Insert the secret: ") }?; diff --git a/src/arguments/code.rs b/src/arguments/code.rs index 605561f1..66f64d4b 100644 --- a/src/arguments/code.rs +++ b/src/arguments/code.rs @@ -46,7 +46,7 @@ struct CodeOutput<'a> { label: &'a str, code: &'a str, expires_at: Option, - remaining_seconds: Option, + remaining: Option, } pub fn parse_duration(value: &str) -> Result { @@ -129,7 +129,7 @@ impl SubcommandExecutor for CodeArgs { label: &element.label, code: &code, expires_at: timing_after.map(|value| value.0), - remaining_seconds: timing_after.map(|value| value.1.as_secs()), + remaining: timing_after.map(|value| value.1.as_secs()), }, )?; writeln!(stdout)?; diff --git a/src/arguments/mod.rs b/src/arguments/mod.rs index dc767ed2..e7e335cb 100644 --- a/src/arguments/mod.rs +++ b/src/arguments/mod.rs @@ -36,7 +36,7 @@ pub trait SubcommandExecutor { cotp Open the interactive vault cotp add --issuer GitHub --label you Add an account cotp import --help Migrate from another authenticator - cotp list --json Output codes for scripts + cotp code work --json Output one code for scripts DASHBOARD a add · e edit · / search · Enter copy · Space QR code · ? help" diff --git a/tests/import_integration_tests.rs b/tests/import_integration_tests.rs index dc854325..27116a9d 100644 --- a/tests/import_integration_tests.rs +++ b/tests/import_integration_tests.rs @@ -2,7 +2,13 @@ use assert_cmd::cargo::cargo_bin_cmd; fn database(dir: &tempfile::TempDir) -> std::path::PathBuf { let path = dir.path().join("db.cotp"); - std::fs::copy("test_samples/cli_integration_test/empty_database", &path).unwrap(); + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&path) + .arg("init") + .write_stdin("12345678\n") + .assert() + .success(); path } @@ -48,7 +54,7 @@ fn stdin_import_is_repeatable_and_plaintext_export_is_explicit() { cargo_bin_cmd!("cotp") .args(["--password-stdin", "--database-path"]) .arg(&db) - .args(["import", "-"]) + .args(["import", "-", "--yes"]) .write_stdin("12345678\notpauth://hotp/Example:alice?secret=JBSWY3DP&counter=42\n") .assert() .success(); diff --git a/tests/scripting_integration_tests.rs b/tests/scripting_integration_tests.rs new file mode 100644 index 00000000..dcfae064 --- /dev/null +++ b/tests/scripting_integration_tests.rs @@ -0,0 +1,252 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use predicates::str::{contains, is_empty}; + +fn command(path: &std::path::Path) -> assert_cmd::Command { + let mut command = cargo_bin_cmd!("cotp"); + command + .args(["--no-input", "--password-stdin", "--database-path"]) + .arg(path); + command +} + +fn init(path: &std::path::Path) { + command(path) + .arg("init") + .write_stdin("12345678\n") + .assert() + .success() + .stdout(is_empty()); +} + +#[test] +fn hotp_is_committed_before_output_and_preview_and_list_are_read_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + init(&path); + command(&path) + .args([ + "add", + "--label", + "alice", + "--alias", + "work", + "--type", + "hotp", + "--counter", + "0", + "--secret-stdin", + ]) + .write_stdin("12345678\nGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ\n") + .assert() + .success() + .stdout(is_empty()); + let before = std::fs::read(&path).unwrap(); + for args in [vec!["code", "work", "--preview"], vec!["list", "--json"]] { + command(&path) + .args(args) + .write_stdin("12345678\n") + .assert() + .success() + .stdout(contains("755224")); + assert_eq!(std::fs::read(&path).unwrap(), before); + } + let output = command(&path) + .args(["code", "work", "--json"]) + .write_stdin("12345678\n") + .assert() + .success() + .get_output() + .stdout + .clone(); + let value: serde_json::Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(value["code"], "755224"); + assert_eq!(value["id"].as_str().unwrap().len(), 32); + assert!(value["expires_at"].is_null()); + assert!(value["remaining"].is_null()); + command(&path) + .args(["code", "work", "--preview"]) + .write_stdin("12345678\n") + .assert() + .success() + .stdout("287082\n"); + let metadata = command(&path) + .args(["list", "--json", "--metadata-only"]) + .write_stdin("12345678\n") + .assert() + .success() + .get_output() + .stdout + .clone(); + let metadata: serde_json::Value = serde_json::from_slice(&metadata).unwrap(); + assert!(metadata[0].get("otp_code").is_none()); + command(&path) + .args(["code", "work", "--watch"]) + .write_stdin("12345678\n") + .assert() + .code(3) + .stdout(is_empty()); +} + +#[test] +fn no_input_guards_every_command_prompt_and_errors_keep_stdout_empty() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + init(&path); + command(&path) + .args(["add", "--label", "alice", "--secret-stdin"]) + .write_stdin("12345678\nAA\n") + .assert() + .success(); + for args in [ + vec!["add", "--label", "bob"], + vec!["delete", "alice"], + vec!["passwd"], + ] { + command(&path) + .args(args) + .write_stdin("12345678\n") + .assert() + .code(3) + .stdout(is_empty()) + .stderr(contains("Interactive input is disabled")); + } + command(&path) + .args(["code", "missing"]) + .write_stdin("12345678\n") + .assert() + .code(3) + .stdout(is_empty()) + .stderr(contains("No account")); + command(&path) + .args(["list"]) + .write_stdin("wrong-password\n") + .assert() + .code(1) + .stdout(is_empty()); + command(&path) + .arg("init") + .write_stdin("12345678\n") + .assert() + .code(1) + .stdout(is_empty()); + cargo_bin_cmd!("cotp") + .args(["--no-input", "--database-path"]) + .arg(&path) + .arg("list") + .assert() + .code(1) + .stdout(is_empty()) + .stderr(contains("Interactive input is disabled")); +} + +#[cfg(unix)] +#[test] +fn password_descriptor_preserves_stdin_for_import_and_new_password() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + init(&path); + let password = dir.path().join("password"); + std::fs::write(&password, "12345678\n").unwrap(); + let mut shell = assert_cmd::Command::new("sh"); + shell + .args(["-c", "exec 3<\"$1\"; shift; exec \"$@\"", "sh"]) + .arg(&password) + .arg(assert_cmd::cargo::cargo_bin!("cotp")) + .args(["--no-input", "--password-fd", "3", "--database-path"]) + .arg(&path) + .args(["import", "-", "--yes"]) + .write_stdin("otpauth://totp/Test:alice?secret=JBSWY3DP\n") + .assert() + .success() + .stdout(is_empty()); + let before = std::fs::read(&path).unwrap(); + command(&path) + .args(["code", "alice", "--min-validity", "30s"]) + .write_stdin("12345678\n") + .assert() + .code(3) + .stdout(is_empty()) + .stderr(contains("shorter")); + assert_eq!(std::fs::read(&path).unwrap(), before); + let output = command(&path) + .args(["code", "alice", "--json", "--min-validity", "1s"]) + .write_stdin("12345678\n") + .assert() + .success() + .get_output() + .stdout + .clone(); + let value: serde_json::Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(value["code"].as_str().unwrap().len(), 6); + assert!(value["remaining"].as_u64().unwrap() >= 1); + assert_eq!(value["expires_at"].as_u64().unwrap() % 30, 0); + let mut shell = assert_cmd::Command::new("sh"); + shell + .args(["-c", "exec 3<\"$1\"; shift; exec \"$@\"", "sh"]) + .arg(&password) + .arg(assert_cmd::cargo::cargo_bin!("cotp")) + .args(["--no-input", "--password-fd", "3", "--database-path"]) + .arg(&path) + .args(["passwd", "--new-password-fd", "0"]) + .write_stdin("new-password\n") + .assert() + .success(); + command(&path) + .args(["list", "--metadata-only"]) + .write_stdin("new-password\n") + .assert() + .success(); +} + +#[test] +fn watch_flushes_one_json_line_per_interval() { + use std::{ + io::{BufRead, BufReader, Write}, + process::{Command, Stdio}, + sync::mpsc, + time::Duration, + }; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + init(&path); + command(&path) + .args(["add", "--label", "watch", "--period", "1", "--secret-stdin"]) + .write_stdin("12345678\nAA\n") + .assert() + .success(); + let mut child = Command::new(assert_cmd::cargo::cargo_bin!("cotp")) + .args(["--no-input", "--password-stdin", "--database-path"]) + .arg(&path) + .args(["code", "watch", "--watch", "--json"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(b"12345678\n") + .unwrap(); + let stdout = child.stdout.take().unwrap(); + let (sender, receiver) = mpsc::channel(); + let reader = std::thread::spawn(move || { + for line in BufReader::new(stdout).lines().take(2) { + if sender.send(line).is_err() { + break; + } + } + }); + let first = receiver.recv_timeout(Duration::from_secs(10)); + let second = receiver.recv_timeout(Duration::from_secs(3)); + child.kill().unwrap(); + child.wait().unwrap(); + reader.join().unwrap(); + let first: serde_json::Value = serde_json::from_str(&first.unwrap().unwrap()).unwrap(); + let second: serde_json::Value = serde_json::from_str(&second.unwrap().unwrap()).unwrap(); + assert_eq!( + second["expires_at"].as_u64().unwrap(), + first["expires_at"].as_u64().unwrap() + 1 + ); +} From 10cc10f0e928acae73425aecf70f81d31eb0004c Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:22:37 +0200 Subject: [PATCH 09/21] test(cli): verify hardware dispatch and noninteractive backup guards --- tests/hardware_integration_tests.rs | 26 ++++++++++++++++++++++++++ tests/scripting_integration_tests.rs | 8 ++++++++ 2 files changed, 34 insertions(+) diff --git a/tests/hardware_integration_tests.rs b/tests/hardware_integration_tests.rs index ac7f995b..0f0e4e1f 100644 --- a/tests/hardware_integration_tests.rs +++ b/tests/hardware_integration_tests.rs @@ -40,6 +40,32 @@ esac .assert() .success() .stdout("123456\n"); + run() + .args([ + "--no-input", + "--backend", + "yubikey", + "code", + "GitHub:work", + "--json", + ]) + .assert() + .success() + .stdout(predicate::str::contains("123456")); + for flag in ["--watch", "--preview", "--min-validity=10s"] { + run() + .args(["--backend", "yubikey", "code", "GitHub:work", flag]) + .assert() + .code(3) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("does not support")); + } + run() + .args(["--no-input", "hardware", "add", "account"]) + .assert() + .code(3) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("interactive")); assert!(!dir.path().join("db.cotp").exists()); } } diff --git a/tests/scripting_integration_tests.rs b/tests/scripting_integration_tests.rs index dcfae064..412ae3c3 100644 --- a/tests/scripting_integration_tests.rs +++ b/tests/scripting_integration_tests.rs @@ -129,6 +129,14 @@ fn no_input_guards_every_command_prompt_and_errors_keep_stdout_empty() { .assert() .code(1) .stdout(is_empty()); + cargo_bin_cmd!("cotp") + .args(["--no-input", "--database-path"]) + .arg(&path) + .args(["backup", "verify", "unused.cotp-backup"]) + .assert() + .code(1) + .stdout(is_empty()) + .stderr(contains("Interactive input is disabled")); cargo_bin_cmd!("cotp") .args(["--no-input", "--database-path"]) .arg(&path) From 8d2b52139cc1155480e55bb54051f846445872bb Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:23:09 +0200 Subject: [PATCH 10/21] feat(exchange): merge encrypted snapshots with explicit conflict and HOTP policies --- docs/exchange.md | 29 +++++ src/arguments/mod.rs | 2 + src/backup.rs | 6 +- src/exchange.rs | 294 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 5 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 docs/exchange.md create mode 100644 src/exchange.rs diff --git a/docs/exchange.md b/docs/exchange.md new file mode 100644 index 00000000..affa1a34 --- /dev/null +++ b/docs/exchange.md @@ -0,0 +1,29 @@ +# Exchanging encrypted vaults + +Use `cotp backup create DIRECTORY` to produce an encrypted snapshot. Transfer that +file using your preferred file transport, then authenticate it on the destination: + +```sh +cotp backup verify remote.cotp-backup +cotp merge remote.cotp-backup --dry-run +cotp merge remote.cotp-backup --conflict keep-local +``` + +The source password may differ from the destination password. `--source-password-stdin` +reads the source password without placing it in process arguments. For automation, +provide the destination password separately through the global password FD option. +The source snapshot is authenticated and validated before merging anything. Changes +are written through the same locked, stale-write-protected transaction as other +commands. A rejected conflict or failed authentication writes nothing. + +Account IDs identify accounts across copies; identical account material and names +also deduplicate independently imported entries. Conflicts abort by default. Choose +`--conflict keep-local` or `--conflict take-remote` explicitly. Aliases must remain +unique. Deletions are not propagated: an absent account does not prove deletion. +This is explicit snapshot merging, not a background cloud sync service. + +HOTP accounts require `--hotp max`. Matching HOTP accounts retain the higher counter, +regardless of metadata conflict policy. Choose a single generating device for each +HOTP account: merging counters cannot prevent two disconnected devices from emitting +the same code. `backup restore --force` is full rollback, whereas `merge` preserves +higher counters for matching credentials. diff --git a/src/arguments/mod.rs b/src/arguments/mod.rs index 8d4f3dde..57af9227 100644 --- a/src/arguments/mod.rs +++ b/src/arguments/mod.rs @@ -61,6 +61,8 @@ pub struct CotpArgs { #[derive(Subcommand)] #[enum_dispatch(SubcommandExecutor)] pub enum CotpSubcommands { + /// Merge an authenticated encrypted backup from another device + Merge(crate::exchange::MergeArgs), /// Generate shell completion scripts without unlocking a vault Completions(shell::CompletionsArgs), /// Generate manual pages without unlocking a vault diff --git a/src/backup.rs b/src/backup.rs index 494bd565..8053933e 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -98,7 +98,7 @@ fn password(from_stdin: bool) -> Result> { Ok(Zeroizing::new(password)) } -fn validate(bytes: &[u8], password: &str) -> Result { +pub(crate) fn validate(bytes: &[u8], password: &str) -> Result { let text = std::str::from_utf8(bytes)?; let (plaintext, key, _salt) = decrypt_string(text, password)?; let plaintext = Zeroizing::new(plaintext); @@ -109,7 +109,9 @@ fn validate(bytes: &[u8], password: &str) -> Result { return Err(eyre!("Backup uses a newer unsupported vault format")); } migrate(&mut database)?; - for element in database.elements_ref() { + for element in &mut database.elements { + crate::importers::enrollment::validate(element) + .map_err(|_| eyre!("Backup contains an invalid OTP account"))?; element .get_otp_code() .map_err(|_| eyre!("Backup contains an invalid OTP account"))?; diff --git a/src/exchange.rs b/src/exchange.rs new file mode 100644 index 00000000..5711c89b --- /dev/null +++ b/src/exchange.rs @@ -0,0 +1,294 @@ +//! Explicit, local merging of authenticated encrypted backups. No network transport. +use crate::{ + arguments::SubcommandExecutor, + otp::{ + otp_element::{OTPDatabase, OTPElement}, + otp_type::OTPType, + }, +}; +use clap::{Args, ValueEnum}; +use color_eyre::eyre::{Result, eyre}; +use std::{ + io::{self, BufRead, IsTerminal, Read}, + path::PathBuf, +}; +use zeroize::Zeroizing; + +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +pub enum ConflictPolicy { + #[default] + Abort, + KeepLocal, + TakeRemote, +} +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +pub enum HotpPolicy { + #[default] + Reject, + Max, +} + +#[derive(Args)] +pub struct MergeArgs { + /// An encrypted cotp vault or backup from another device + file: PathBuf, + /// Resolve changed accounts explicitly; no changes are written on abort + #[arg(long, value_enum, default_value = "abort")] + conflict: ConflictPolicy, + /// HOTP is rejected unless max is chosen; never decreases an existing counter + #[arg(long, value_enum, default_value = "reject")] + hotp: HotpPolicy, + /// Read the source vault's password from stdin (separate from destination unlock) + #[arg(long)] + source_password_stdin: bool, + #[arg(long)] + pub dry_run: bool, + #[arg(long)] + json: bool, +} + +#[derive(Default, serde::Serialize)] +struct Report { + added: usize, + updated: usize, + unchanged: usize, + conflicts_kept: usize, +} + +fn same_identity(a: &OTPElement, b: &OTPElement) -> bool { + a.type_ == b.type_ + && a.secret == b.secret + && a.algorithm == b.algorithm + && a.digits == b.digits + && a.period == b.period + && a.pin == b.pin + && a.issuer == b.issuer + && a.label == b.label +} + +fn merge( + local: &OTPDatabase, + remote: OTPDatabase, + conflict: ConflictPolicy, + hotp: HotpPolicy, +) -> Result<(OTPDatabase, Report)> { + let mut candidate = OTPDatabase { + version: local.version, + elements: local.elements.clone(), + needs_modification: false, + }; + let mut report = Report::default(); + for mut incoming in remote.elements { + if incoming.type_ == OTPType::Hotp && matches!(hotp, HotpPolicy::Reject) { + return Err(eyre!( + "Source contains HOTP accounts; choose --hotp max after designating a single device to generate HOTP codes" + )); + } + let existing = candidate + .elements + .iter() + .position(|e| e.id == incoming.id) + .or_else(|| { + candidate + .elements + .iter() + .position(|e| same_identity(e, &incoming)) + }); + if let Some(index) = existing { + let old = &candidate.elements[index]; + incoming.id = old.id.clone(); + let counter = if incoming.type_ == OTPType::Hotp && same_identity(old, &incoming) { + old.counter.max(incoming.counter) + } else { + incoming.counter + }; + // Counters are merged independently of metadata conflict policy. + let mut comparable = incoming.clone(); + comparable.counter = old.counter; + if &comparable == old { + if old.counter != counter { + candidate.elements[index].counter = counter; + candidate.mark_modified(); + report.updated += 1; + } else { + report.unchanged += 1; + } + continue; + } + match conflict { + ConflictPolicy::Abort => { + return Err(eyre!( + "Conflicting account {}; use --conflict keep-local or take-remote", + old.id + )); + } + ConflictPolicy::KeepLocal => { + if old.type_ == OTPType::Hotp + && same_identity(old, &incoming) + && old.counter != counter + { + candidate.elements[index].counter = counter; + candidate.mark_modified(); + report.updated += 1; + } + report.conflicts_kept += 1; + } + ConflictPolicy::TakeRemote => { + incoming.counter = counter; + candidate.elements[index] = incoming; + candidate.mark_modified(); + report.updated += 1; + } + } + } else { + candidate.add_element(incoming)?; + report.added += 1; + } + } + let mut aliases = std::collections::HashSet::new(); + for element in &candidate.elements { + if let Some(alias) = &element.alias + && !aliases.insert(alias.to_lowercase()) + { + return Err(eyre!( + "Merge would create duplicate aliases; resolve aliases before merging" + )); + } + } + Ok((candidate, report)) +} + +impl SubcommandExecutor for MergeArgs { + fn run_command(self, database: OTPDatabase) -> Result { + let password = Zeroizing::new(if self.source_password_stdin { + io::stdin() + .lock() + .lines() + .next() + .ok_or_else(|| eyre!("Missing source password on stdin"))?? + } else { + if !io::stdin().is_terminal() { + return Err(eyre!( + "Use --source-password-stdin for noninteractive merge" + )); + } + rpassword::prompt_password("Source backup password: ")? + }); + let mut bytes = Vec::new(); + std::fs::File::open(&self.file)? + .take(64 * 1024 * 1024 + 1) + .read_to_end(&mut bytes)?; + if bytes.len() > 64 * 1024 * 1024 { + return Err(eyre!("Source backup exceeds 64 MiB")); + } + let remote = crate::backup::validate(&bytes, &password)?; + let (candidate, report) = merge(&database, remote, self.conflict, self.hotp)?; + if self.json { + println!("{}", serde_json::to_string(&report)?); + } else { + eprintln!( + "Merge{}: {} added, {} updated, {} unchanged, {} conflicts kept locally", + if self.dry_run { " preview" } else { "" }, + report.added, + report.updated, + report.unchanged, + report.conflicts_kept + ); + } + if self.dry_run { + Ok(database) + } else { + Ok(candidate) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::otp::otp_element::OTPElementBuilder; + fn vault(label: &str) -> OTPDatabase { + let mut db = OTPDatabase::default(); + db.add_element( + OTPElementBuilder::default() + .issuer("Example") + .label(label) + .secret("JBSWY3DP") + .build() + .unwrap(), + ) + .unwrap(); + db.needs_modification = false; + db + } + fn clone_vault(db: &OTPDatabase) -> OTPDatabase { + OTPDatabase { + version: db.version, + elements: db.elements.clone(), + needs_modification: false, + } + } + #[test] + fn conflicts_abort_without_mutating_local() { + let local = vault("alice"); + let mut remote = clone_vault(&local); + remote.elements[0].label = "renamed".into(); + assert!(merge(&local, remote, ConflictPolicy::Abort, HotpPolicy::Reject).is_err()); + assert_eq!(local.elements[0].label, "alice"); + assert!(!local.is_modified()); + } + #[test] + fn merging_same_snapshot_twice_is_idempotent() { + let local = vault("alice"); + let remote = vault("bob"); + let again = clone_vault(&remote); + let (merged, report) = + merge(&local, remote, ConflictPolicy::Abort, HotpPolicy::Reject).unwrap(); + assert_eq!(report.added, 1); + let (merged_again, report) = + merge(&merged, again, ConflictPolicy::Abort, HotpPolicy::Reject).unwrap(); + assert_eq!(report.added, 0); + assert_eq!(merged_again.elements.len(), 2); + } + #[test] + fn hotp_requires_policy_and_never_moves_backwards() { + let mut local = vault("alice"); + local.elements[0].type_ = OTPType::Hotp; + local.elements[0].counter = Some(50); + let mut remote = clone_vault(&local); + remote.elements[0].counter = Some(4); + assert!( + merge( + &local, + clone_vault(&remote), + ConflictPolicy::TakeRemote, + HotpPolicy::Reject + ) + .is_err() + ); + let (merged, _) = + merge(&local, remote, ConflictPolicy::TakeRemote, HotpPolicy::Max).unwrap(); + assert_eq!(merged.elements[0].counter, Some(50)); + let mut remote = clone_vault(&local); + remote.elements[0].counter = Some(70); + let (merged, _) = + merge(&local, remote, ConflictPolicy::KeepLocal, HotpPolicy::Max).unwrap(); + assert_eq!(merged.elements[0].counter, Some(70)); + } + #[test] + fn explicit_remote_policy_preserves_account_identity() { + let local = vault("alice"); + let mut remote = clone_vault(&local); + remote.elements[0].label = "new label".into(); + let (merged, report) = merge( + &local, + remote, + ConflictPolicy::TakeRemote, + HotpPolicy::Reject, + ) + .unwrap(); + assert_eq!(merged.elements[0].id, local.elements[0].id); + assert_eq!(merged.elements[0].label, "new label"); + assert_eq!(report.updated, 1); + } +} diff --git a/src/main.rs b/src/main.rs index 20d0b297..68b795f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ mod clipboard; mod config; mod crypto; mod database_file; +mod exchange; mod exporters; mod importers; mod interface; From a19d54b363e15c7f338fd4e3d206c547f764287e Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:23:32 +0200 Subject: [PATCH 11/21] fix(import): preserve legacy vaults during dry runs and rejected imports --- src/main.rs | 20 ++++++++++++++++++-- tests/import_integration_tests.rs | 28 +++++++++++++++++++++------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index 5159e639..32fd2b77 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,13 @@ fn init(args: &CotpArgs) -> color_eyre::Result { "Vault does not exist. Create it with cotp init, add, or import." )); } + if !path.exists() + && matches!(&args.command, Some(arguments::CotpSubcommands::Import(import)) if import.dry_run) + { + return Err(eyre!( + "Dry-run requires an existing vault; create it with cotp init first" + )); + } let first_run = utils::init_app().map_err(|()| eyre!("Could not create vault directory"))?; persistence::initialize(&path)?; let explicit_password = if args.password_from_stdin { @@ -95,16 +102,25 @@ fn run(mut cotp_args: CotpArgs) -> Result<(), (u8, color_eyre::eyre::Report)> { ) .map_err(|error| (1, error)); } + let dry_run = matches!(&cotp_args.command, Some(arguments::CotpSubcommands::Import(args)) if args.dry_run); + let save_upgrade_before_command = matches!( + &cotp_args.command, + None | Some( + arguments::CotpSubcommands::Code(_) + | arguments::CotpSubcommands::Extract(_) + | arguments::CotpSubcommands::List(_) + ) + ); let (mut database, key, salt) = init(&cotp_args).map_err(|error| (1, error))?; let mut key = zeroize::Zeroizing::new(key); persistence::unlock(&key, &salt).map_err(|error| (1, error))?; otp::migrations::migrate(&mut database).map_err(|error| (1, error))?; - if database.is_modified() { + if database.is_modified() && save_upgrade_before_command { database.save(&key, &salt).map_err(|error| (1, error))?; } key.zeroize(); let mut database = args_parser(cotp_args, database).map_err(|error| (3, error))?; - if database.is_modified() { + if database.is_modified() && !dry_run { persistence::persist(&mut database).map_err(|error| { ( 1, diff --git a/tests/import_integration_tests.rs b/tests/import_integration_tests.rs index 27116a9d..2fbfdcc6 100644 --- a/tests/import_integration_tests.rs +++ b/tests/import_integration_tests.rs @@ -2,13 +2,7 @@ use assert_cmd::cargo::cargo_bin_cmd; fn database(dir: &tempfile::TempDir) -> std::path::PathBuf { let path = dir.path().join("db.cotp"); - cargo_bin_cmd!("cotp") - .args(["--password-stdin", "--database-path"]) - .arg(&path) - .arg("init") - .write_stdin("12345678\n") - .assert() - .success(); + std::fs::copy("test_samples/cli_integration_test/empty_database", &path).unwrap(); path } @@ -81,3 +75,23 @@ fn stdin_import_is_repeatable_and_plaintext_export_is_explicit() { assert_eq!(value["elements"].as_array().unwrap().len(), 1); assert_eq!(value["elements"][0]["counter"], 42); } + +#[test] +fn dry_run_does_not_create_a_missing_vault() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("missing/vault.cotp"); + let input = dir.path().join("uris.txt"); + std::fs::write(&input, "otpauth://totp/Example:alice?secret=JBSWY3DP").unwrap(); + cargo_bin_cmd!("cotp") + .args(["--no-input", "--password-stdin", "--database-path"]) + .arg(&db) + .arg("import") + .arg(&input) + .arg("--dry-run") + .write_stdin("12345678\n") + .assert() + .code(1) + .stderr(predicates::str::contains("cotp init")); + assert!(!db.exists()); + assert!(!db.parent().unwrap().exists()); +} From 22f3fe3482df7ac4fdfecaaa5c0c4de5b2b5c31e Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:23:35 +0200 Subject: [PATCH 12/21] feat(migrations): import native backups and export encrypted Aegis selections --- Cargo.lock | 60 +++- Cargo.toml | 1 + docs/imports.md | 38 +++ src/arguments/export.rs | 153 ++++++++- src/arguments/import.rs | 48 ++- src/exporters/aegis.rs | 126 ++++++++ src/exporters/mod.rs | 2 + src/importers/aegis.rs | 35 ++- src/importers/aegis_encrypted.rs | 180 +++++------ src/importers/enrollment.rs | 21 +- src/importers/mod.rs | 1 + src/importers/modern.rs | 332 ++++++++++++++++++++ test_samples/migrations/bitwarden.json | 30 ++ test_samples/migrations/ente.txt | 2 + test_samples/migrations/onepassword.data | 52 +++ test_samples/migrations/twofas.2fas | 32 ++ tests/native_migration_integration_tests.rs | 84 +++++ 17 files changed, 1087 insertions(+), 110 deletions(-) create mode 100644 src/exporters/aegis.rs create mode 100644 src/importers/modern.rs create mode 100644 test_samples/migrations/bitwarden.json create mode 100644 test_samples/migrations/ente.txt create mode 100644 test_samples/migrations/onepassword.data create mode 100644 test_samples/migrations/twofas.2fas create mode 100644 tests/native_migration_integration_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 22bdc229..2d59dc88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,6 +132,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -618,6 +627,7 @@ dependencies = [ "url", "urlencoding", "zeroize", + "zip", ] [[package]] @@ -646,9 +656,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -847,6 +857,17 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -1100,6 +1121,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1482,7 +1504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -3867,12 +3889,44 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index b8a88462..e5c5349a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ tui-input = { version = "0.15.5", default-features = false, features = ["crosste tempfile = "3.27.0" rqrr = { version = "0.10", default-features = false } image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } +zip = { version = "4", default-features = false, features = ["deflate"] } [dev-dependencies] proptest = "1" diff --git a/docs/imports.md b/docs/imports.md index 7b42e0ee..ee76fc68 100644 --- a/docs/imports.md +++ b/docs/imports.md @@ -23,3 +23,41 @@ cotp export --plaintext --otp-uri --path otp-uris.json ``` The default filename is `exported.plaintext.json`. Exports contain authentication secrets. Files are created privately and atomically, and existing paths are never overwritten. Use the encrypted backup workflow for routine backups. + +## Native migrations + +These formats are detected automatically; explicit flags are available when needed: + +| Application | Flag | Supported input | +| --- | --- | --- | +| 2FAS | `--2fas` | Plaintext schema 4 `.2fas` JSON with `services[].secret` and `services[].otp`; compatible earlier services containers are accepted | +| Ente Auth | `--ente` | Decrypted text export with one `otpauth://` URI per line | +| Bitwarden | `--bitwarden` | Unencrypted Authenticator or Password Manager JSON, `items[].login.totp` as an OTP URI, bare base32 seed, or `steam://` seed | +| 1Password | `--onepassword` | `.1pux` ZIP containing `export.data`, or extracted `export.data` JSON; imports every `details.sections[].fields[].value.totp` | + +2FAS group names become tags and entry positions are retained. Bitwarden favorites and 1Password favorites/tags are retained. Entries without OTP data in password manager exports are ignored. Passwords, notes, attachments, and unrelated fields are never imported. 1PUX attachments are not extracted. The decompressed `export.data` limit is 32 MiB. Encrypted 2FAS, Ente, Bitwarden exports and 1Password CSV/1PIF are not supported; create a plaintext export in the source application first. + +```sh +cotp import backup.2fas --dry-run +cotp import ente-auth.txt --ente --yes +cotp import bitwarden.json --yes +cotp import account.1pux --yes +``` + +Source schemas: [2FAS BackupContent](https://github.com/twofas/2fas-android/blob/main/data/services/src/main/java/com/twofasapp/data/services/domain/BackupContent.kt), [2FAS BackupService](https://github.com/twofas/2fas-android/blob/main/data/services/src/main/java/com/twofasapp/data/services/domain/BackupService.kt), [Ente export documentation](https://ente.com/help/auth/migration/export), [Bitwarden Authenticator export examples](https://bitwarden.com/help/authenticator-import-export/), and [1Password 1PUX specification](https://support.1password.com/1pux-format/). The fixtures in `test_samples/migrations` are synthetic examples built from these structures, containing only dummy credentials. + +## Selected and encrypted exports + +Export selections combine with AND: repeated `--id` values select any of those IDs, `--tag` matches a tag case insensitively, and `--query` matches a case insensitive substring in issuer, label, alias, or tags. Empty selections and unknown IDs are errors. + +```sh +cotp export --plaintext --query example --tag work --path selected.json +cotp export --plaintext --id ACCOUNT_ID --otp-uri --path selected-uris.json +cotp export --aegis-encrypted --path aegis.json +cotp export --aegis-encrypted --export-password-file /private/export-password --path aegis.json +cotp import aegis.json --import-password-file /private/export-password --yes +``` + +`--aegis` produces plaintext Aegis JSON and requires `--plaintext`. `--aegis-encrypted` uses a separate password, obtained through a verified terminal prompt or `--export-password-file`. Import supports the corresponding `--import-password-file`; passwords are never passed as argument values. Password files have a 4096 byte limit and trailing newlines are removed. Aegis export supports TOTP, HOTP, and Steam, including algorithms, digits, periods, HOTP counters, favorites, and tags as Aegis groups. Other OTP types cause an error rather than being silently dropped. cotp-only IDs, aliases, and ordering are preserved by cotp JSON exports. + +The encrypted Aegis container uses a random 256-bit master key, scrypt (`N=32768, r=8, p=1`) password slot, and AES-256-GCM, following [Aegis password slots](https://github.com/beemdevelopment/Aegis/blob/master/app/src/main/java/com/beemdevelopment/aegis/vault/slots/PasswordSlot.java) and [entry serialization](https://github.com/beemdevelopment/Aegis/blob/master/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntry.java). Exports are created privately without overwriting existing files. diff --git a/src/arguments/export.rs b/src/arguments/export.rs index 9df4ebc0..03c11d23 100644 --- a/src/arguments/export.rs +++ b/src/arguments/export.rs @@ -14,9 +14,26 @@ use super::SubcommandExecutor; #[derive(Args)] pub struct ExportArgs { /// Acknowledge that exported files contain unencrypted authentication secrets - #[arg(long, required = true)] + #[arg( + long, + required_unless_present = "aegis_encrypted", + conflicts_with = "aegis_encrypted" + )] pub plaintext: bool, + /// Export only these account IDs (repeatable) + #[arg(long = "id")] + pub ids: Vec, + /// Export only accounts containing this tag + #[arg(long)] + pub tag: Option, + /// Match issuer, label, alias, or tags (case insensitive substring) + #[arg(long)] + pub query: Option, + /// Read the Aegis export password from a private file + #[arg(long, requires = "aegis_encrypted")] + pub export_password_file: Option, + /// Export file path #[arg(short, long, default_value = ".")] pub path: PathBuf, @@ -29,6 +46,13 @@ pub struct ExportArgs { #[derive(Args)] #[group(required = false, multiple = false)] pub struct ExportFormat { + /// Export an unencrypted Aegis backup + #[arg(long)] + pub aegis: bool, + /// Export an encrypted Aegis backup with a separate password + #[arg(long = "aegis-encrypted")] + pub aegis_encrypted: bool, + /// Export into cotp backup #[arg(short, long)] pub cotp: bool, @@ -49,6 +73,8 @@ pub struct ExportFormat { impl Default for ExportFormat { fn default() -> Self { Self { + aegis: false, + aegis_encrypted: false, cotp: true, andotp: false, otp_uri: false, @@ -60,22 +86,78 @@ impl Default for ExportFormat { impl SubcommandExecutor for ExportArgs { fn run_command(self, database: OTPDatabase) -> color_eyre::Result { let export_format = self.format.unwrap_or_default(); + for id in &self.ids { + if !database.elements_ref().iter().any(|e| &e.id == id) { + return Err(eyre!("Export account ID does not exist")); + } + } + let elements: Vec = database + .elements_ref() + .iter() + .filter(|e| { + (self.ids.is_empty() || self.ids.contains(&e.id)) + && self + .tag + .as_ref() + .is_none_or(|tag| e.tags.iter().any(|t| t.eq_ignore_ascii_case(tag))) + && self.query.as_ref().is_none_or(|query| { + let query = query.to_lowercase(); + [&e.issuer, &e.label] + .into_iter() + .chain(e.alias.iter()) + .chain(e.tags.iter()) + .any(|value| value.to_lowercase().contains(&query)) + }) + }) + .cloned() + .collect(); + if elements.is_empty() { + return Err(eyre!("No accounts match the export selection")); + } + let selected = OTPDatabase::from(elements); let exported_path = if self.path.is_dir() { - self.path.join("exported.plaintext.json") + self.path.join(if export_format.aegis_encrypted { + "exported.aegis.json" + } else { + "exported.plaintext.json" + }) } else { self.path }; - if export_format.cotp { - do_export(&database, exported_path) + if export_format.aegis || export_format.aegis_encrypted { + let password = if export_format.aegis_encrypted { + Some(zeroize::Zeroizing::new( + if let Some(path) = self.export_password_file { + use std::io::Read; + let mut password = zeroize::Zeroizing::new(String::new()); + std::fs::File::open(path)? + .take(4097) + .read_to_string(&mut password)?; + if password.len() > 4096 { + return Err(eyre!("Export password file is too large")); + } + password.trim_end_matches(['\r', '\n']).to_owned() + } else { + crate::utils::verified_password("New Aegis export password: ", 8) + }, + )) + } else { + None + }; + let value = + crate::exporters::aegis::encode(&selected, password.as_ref().map(|p| p.as_str()))?; + do_export(&value, exported_path) + } else if export_format.cotp { + do_export(&selected, exported_path) } else if export_format.andotp { - let andotp: &Vec = (&database).into(); + let andotp: &Vec = (&selected).into(); do_export(&andotp, exported_path) } else if export_format.otp_uri { - let otp_uri_list: OtpUriList = (&database).into(); + let otp_uri_list: OtpUriList = (&selected).into(); do_export(&otp_uri_list, exported_path) } else if export_format.freeotp_plus { - let freeotp_plus: FreeOTPPlusJson = (&database).try_into()?; + let freeotp_plus: FreeOTPPlusJson = (&selected).try_into()?; do_export(&freeotp_plus, exported_path) } else { unreachable!("Unreachable code"); @@ -90,3 +172,60 @@ impl SubcommandExecutor for ExportArgs { .map_err(|e| eyre!("An error occurred while exporting database: {e}")) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::otp::from_otp_uri::FromOtpUri; + + #[test] + fn selection_combines_ids_query_and_tag_and_preserves_metadata() { + let mut database = OTPDatabase::default(); + for (issuer, tag) in [ + ("Example", "work"), + ("Example", "personal"), + ("Other", "work"), + ] { + let mut e = + OTPElement::from_otp_uri(&format!("otpauth://totp/{issuer}:alice?secret=JBSWY3DP")) + .unwrap(); + e.tags.push(tag.to_owned()); + database.add_element(e).unwrap(); + } + let first_id = database.elements_ref()[0].id.clone(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("selected.json"); + let returned = ExportArgs { + plaintext: true, + ids: database + .elements_ref() + .iter() + .map(|e| e.id.clone()) + .collect(), + tag: Some("WORK".into()), + query: Some("EXAMPLE".into()), + export_password_file: None, + path: path.clone(), + format: None, + } + .run_command(database) + .unwrap(); + assert_eq!(returned.elements_ref().len(), 3); + let exported: OTPDatabase = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(exported.elements_ref().len(), 1); + assert_eq!(exported.elements_ref()[0].id, first_id); + assert_eq!(exported.elements_ref()[0].tags, ["work"]); + let missing = ExportArgs { + plaintext: true, + ids: vec!["missing".into()], + tag: None, + query: None, + export_password_file: None, + path: directory.path().join("missing.json"), + format: None, + } + .run_command(returned); + assert!(missing.is_err()); + assert!(!directory.path().join("missing.json").exists()); + } +} diff --git a/src/arguments/import.rs b/src/arguments/import.rs index 6a1abecd..3b54f993 100644 --- a/src/arguments/import.rs +++ b/src/arguments/import.rs @@ -23,6 +23,9 @@ pub struct ImportArgs { /// Backup file path (legacy alias for positional INPUT) #[arg(short, long, required_unless_present = "input")] pub path: Option, + /// Read the encrypted Aegis import password from a private file + #[arg(long)] + pub import_password_file: Option, /// Preview changes without modifying the vault #[arg(long)] pub dry_run: bool, @@ -40,6 +43,19 @@ pub struct ImportArgs { #[derive(Args)] #[group(required = false, multiple = false)] pub struct BackupType { + /// Import an unencrypted 2FAS .2fas backup + #[arg(long = "2fas")] + pub twofas: bool, + /// Import an Ente Auth plaintext URI export + #[arg(long)] + pub ente: bool, + /// Import Bitwarden Authenticator or Password Manager plaintext JSON + #[arg(long)] + pub bitwarden: bool, + /// Import a 1Password .1pux ZIP or extracted export.data JSON + #[arg(long)] + pub onepassword: bool, + /// Import from cotp backup #[arg(short, long)] pub cotp: bool, @@ -89,6 +105,10 @@ pub struct BackupType { impl BackupType { fn selected(&self) -> Option<&'static str> { [ + (self.twofas, "twofas"), + (self.ente, "ente"), + (self.bitwarden, "bitwarden"), + (self.onepassword, "onepassword"), (self.cotp, "cotp"), (self.andotp, "andotp"), (self.aegis, "aegis"), @@ -126,13 +146,18 @@ impl SubcommandExecutor for ImportArgs { if bytes.len() > 32 * 1024 * 1024 { return Err(eyre!("Import input exceeds 32 MiB")); } - let report = if image::guess_format(&bytes).is_ok() { + let report = if bytes.starts_with(b"PK\x03\x04") { + crate::importers::modern::onepassword_archive(&bytes)? + } else if image::guess_format(&bytes).is_ok() { enrollment::decode_qr(&bytes)? } else { let content = std::str::from_utf8(&bytes) .map_err(|_| eyre!("Input is not UTF-8 text or a supported QR image"))? .trim(); - if content.starts_with("otpauth") || self.backup_type.google_authenticator { + if content.starts_with("otpauth") + || self.backup_type.google_authenticator + || self.backup_type.ente + { enrollment::parse_uris(content) } else { let value: serde_json::Value = serde_json::from_str(content).map_err(|_| { @@ -144,7 +169,24 @@ impl SubcommandExecutor for ImportArgs { }; if format == "aegis-encrypted" { let mut report = ImportReport::default(); - report.record(1, enrollment::convert::(value)); + let input: AegisEncryptedDatabase = serde_json::from_value(value) + .map_err(|_| eyre!("Invalid encrypted Aegis backup"))?; + let elements = if let Some(path) = self.import_password_file { + let mut password = zeroize::Zeroizing::new(String::new()); + std::fs::File::open(path)? + .take(4097) + .read_to_string(&mut password)?; + if password.len() > 4096 { + return Err(eyre!("Import password file is too large")); + } + input.decrypt_with_password(password.trim_end_matches(['\r', '\n'])) + } else { + input.try_into() + }; + report.record( + 1, + elements.map_err(|_| eyre!("Cannot decrypt Aegis backup")), + ); report } else { enrollment::parse_json(value, format)? diff --git a/src/exporters/aegis.rs b/src/exporters/aegis.rs new file mode 100644 index 00000000..d4fc4af1 --- /dev/null +++ b/src/exporters/aegis.rs @@ -0,0 +1,126 @@ +//! Aegis v1 container / v3 database with an scrypt password slot and AES-256-GCM. +use crate::otp::{otp_element::OTPDatabase, otp_type::OTPType}; +use aes_gcm::{ + Aes256Gcm, KeyInit, + aead::{Aead, Nonce}, +}; +use color_eyre::eyre::{Result, eyre}; +use serde_json::{Value, json}; +use zeroize::Zeroizing; + +pub fn encode(database: &OTPDatabase, password: Option<&str>) -> Result { + let mut entries = Vec::new(); + let mut groups = Vec::::new(); + let mut group_ids = std::collections::BTreeMap::::new(); + for element in database.elements_ref() { + if !matches!( + element.type_, + OTPType::Totp | OTPType::Hotp | OTPType::Steam + ) { + return Err(eyre!( + "Aegis export supports TOTP, HOTP, and Steam accounts only" + )); + } + let mut entry_groups = Vec::new(); + for tag in &element.tags { + let id = if let Some(id) = group_ids.get(tag) { + id.clone() + } else { + let id = uuid()?; + group_ids.insert(tag.clone(), id.clone()); + groups.push(json!({"uuid":id,"name":tag})); + id + }; + entry_groups.push(id); + } + entries.push(json!({"type":element.type_.to_string().to_lowercase(),"uuid":uuid()?,"name":element.label,"issuer":element.issuer,"note":"","icon":null,"groups":entry_groups,"favorite":element.favorite,"info":{"secret":element.secret,"algo":element.algorithm.to_string().to_uppercase(),"digits":element.digits,"period":element.period,"counter":element.counter}})); + } + let db = json!({"version":3,"entries":entries,"groups":groups,"icons_optimized":true}); + let Some(password) = password else { + return Ok(json!({"version":1,"header":{"slots":null,"params":null},"db":db})); + }; + if password.is_empty() { + return Err(eyre!("Encrypted export requires a nonempty password")); + } + let plaintext = Zeroizing::new(serde_json::to_vec(&db)?); + let master = Zeroizing::new(random::<32>()?); + let salt = random::<32>()?; + let mut derived = Zeroizing::new([0u8; 32]); + let params = scrypt::Params::new(15, 8, 1).map_err(|_| eyre!("Invalid scrypt parameters"))?; + scrypt::scrypt(password.as_bytes(), &salt, ¶ms, derived.as_mut()) + .map_err(|_| eyre!("Scrypt failed"))?; + let (key, key_params) = encrypt(derived.as_slice(), master.as_slice())?; + let (encrypted, crypt_params) = encrypt(master.as_slice(), &plaintext)?; + Ok( + json!({"version":1,"header":{"slots":[{"type":1,"uuid":uuid()?,"key":hex::encode(key),"key_params":key_params,"n":32768,"r":8,"p":1,"salt":hex::encode(salt),"repaired":true,"is_backup":true}],"params":crypt_params},"db":data_encoding::BASE64.encode(&encrypted)}), + ) +} +fn random() -> Result<[u8; N]> { + let mut bytes = [0u8; N]; + getrandom::fill(&mut bytes).map_err(|_| eyre!("OS random generation failed"))?; + Ok(bytes) +} +fn uuid() -> Result { + let mut bytes = random::<16>()?; + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + let s = hex::encode(bytes); + Ok(format!( + "{}-{}-{}-{}-{}", + &s[..8], + &s[8..12], + &s[12..16], + &s[16..20], + &s[20..] + )) +} +fn encrypt(key: &[u8], plaintext: &[u8]) -> Result<(Vec, Value)> { + let nonce = random::<12>()?; + let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| eyre!("Invalid AES key"))?; + let nonce_value = + Nonce::::try_from(nonce.as_slice()).map_err(|_| eyre!("Invalid nonce"))?; + let mut ciphertext = cipher + .encrypt(&nonce_value, plaintext) + .map_err(|_| eyre!("Encryption failed"))?; + let tag = ciphertext.split_off(ciphertext.len() - 16); + Ok(( + ciphertext, + json!({"nonce":hex::encode(nonce),"tag":hex::encode(tag)}), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::importers::aegis_encrypted::AegisEncryptedDatabase; + use crate::otp::{from_otp_uri::FromOtpUri, otp_element::OTPElement}; + #[test] + fn encrypted_backup_round_trip_and_wrong_password() { + let mut element = + OTPElement::from_otp_uri("otpauth://hotp/Example:alice?secret=JBSWY3DP&counter=99") + .unwrap(); + element.favorite = true; + element.tags = vec!["work".into()]; + let mut database = OTPDatabase::default(); + database.add_element(element).unwrap(); + let encoded = encode(&database, Some("correct horse battery staple")).unwrap(); + assert!( + !serde_json::to_string(&encoded) + .unwrap() + .contains("JBSWY3DP") + ); + let input: AegisEncryptedDatabase = serde_json::from_value(encoded.clone()).unwrap(); + let restored = input + .decrypt_with_password("correct horse battery staple") + .unwrap(); + assert_eq!(restored[0].secret, "JBSWY3DP"); + assert_eq!(restored[0].counter, Some(99)); + assert!(restored[0].favorite); + assert_eq!(restored[0].tags, ["work"]); + let plain = encode(&database, None).unwrap(); + let restored = crate::importers::enrollment::parse_json(plain, "aegis").unwrap(); + assert_eq!(restored.elements[0].tags, ["work"]); + let input: AegisEncryptedDatabase = serde_json::from_value(encoded).unwrap(); + assert!(input.decrypt_with_password("wrong").is_err()); + } +} diff --git a/src/exporters/mod.rs b/src/exporters/mod.rs index 0c8925a2..fe9ea176 100644 --- a/src/exporters/mod.rs +++ b/src/exporters/mod.rs @@ -30,3 +30,5 @@ where })?; Ok(exported_path) } + +pub mod aegis; diff --git a/src/importers/aegis.rs b/src/importers/aegis.rs index ad2d39dc..e449d7d2 100644 --- a/src/importers/aegis.rs +++ b/src/importers/aegis.rs @@ -20,6 +20,14 @@ struct AegisHeader { pub(crate) struct AegisDb { //version: u64, entries: Vec, + #[serde(default)] + groups: Vec, +} + +#[derive(Serialize, Deserialize)] +struct AegisGroup { + uuid: String, + name: String, } #[derive(Serialize, Deserialize)] @@ -30,6 +38,10 @@ struct AegisElement { issuer: String, //icon: Option, info: AegisInfo, + #[serde(default)] + favorite: bool, + #[serde(default)] + groups: Vec, } impl From for OTPElement { @@ -38,7 +50,7 @@ impl From for OTPElement { id: String::new(), alias: None, tags: Vec::new(), - favorite: false, + favorite: value.favorite, order: None, secret: value.info.secret, issuer: value.issuer, @@ -57,7 +69,26 @@ impl TryFrom for Vec { type Error = String; fn try_from(aegis_db: AegisDb) -> Result { - Ok(aegis_db.entries.into_iter().map(Into::into).collect()) + Ok(aegis_db + .entries + .into_iter() + .map(|entry| { + let tags = entry + .groups + .iter() + .filter_map(|id| { + aegis_db + .groups + .iter() + .find(|g| &g.uuid == id) + .map(|g| g.name.clone()) + }) + .collect(); + let mut element: OTPElement = entry.into(); + element.tags = tags; + element + }) + .collect()) } } diff --git a/src/importers/aegis_encrypted.rs b/src/importers/aegis_encrypted.rs index 2132663a..b5a6cafc 100644 --- a/src/importers/aegis_encrypted.rs +++ b/src/importers/aegis_encrypted.rs @@ -3,7 +3,6 @@ use aes_gcm::{Aes256Gcm, KeyInit}; // Or `Aes128Gcm` use data_encoding::BASE64; use hex::FromHex; use serde::Deserialize; -use zeroize::Zeroize; use crate::otp::otp_element::OTPElement; use crate::utils; @@ -45,110 +44,105 @@ struct AegisEncryptedSlot { impl TryFrom for Vec { type Error = String; - - fn try_from(aegis_encrypted: AegisEncryptedDatabase) -> Result { - let mut password = utils::password("Insert your Aegis password: ", 0); - let master_key: Option> = get_master_key(&aegis_encrypted, &password); - password.zeroize(); - - match master_key { - Some(mut master_key) => { - let content = BASE64 - .decode(aegis_encrypted.db.as_bytes()) - .map_err(|e| format!("Error during base64 decoding: {e:?}"))?; - - let cipher = Aes256Gcm::new_from_slice(master_key.as_slice()) - .map_err(|e| format!("Invalid master key length: {e:?}"))?; - master_key.zeroize(); - - let nonce_bytes = Vec::from_hex(&aegis_encrypted.header.params.nonce) - .expect("Failed to parse hex nonce"); - let nonce = Nonce::::try_from(nonce_bytes.as_slice()) - .map_err(|e| format!("Invalid nonce length: {e:?}"))?; - - let payload = [ - content, - Vec::from_hex(&aegis_encrypted.header.params.tag) - .expect("Failed to parse hex tag"), - ] - .concat(); - - let decrypted_db = cipher - .decrypt(&nonce, payload.as_slice()) - .map_err(|e| format!("Failed to derive master key: {e:?}"))?; - - map_results(decrypted_db) - } - None => Err("Failed to derive master key".to_string()), - } + fn try_from(database: AegisEncryptedDatabase) -> Result { + let password = zeroize::Zeroizing::new(utils::password("Insert your Aegis password: ", 0)); + database.decrypt_with_password(&password) } } -fn get_master_key(aegis_encrypted: &AegisEncryptedDatabase, password: &str) -> Option> { - let mut master_key: Option> = None; - for slot in aegis_encrypted - .header - .slots - .iter() - .filter(|item| item.r#type == 1) - { - match calc_master_key(slot, password) { - Ok(value) => { - master_key = Some(value); +impl AegisEncryptedDatabase { + pub fn decrypt_with_password(self, password: &str) -> Result, String> { + if self.header.slots.len() > 16 { + return Err("Too many Aegis key slots".into()); + } + let mut master_key = None; + for slot in self.header.slots.iter().filter(|slot| slot.r#type == 1) { + if let Ok(key) = calc_master_key(slot, password) { + master_key = Some(zeroize::Zeroizing::new(key)); break; } - Err(e) => println!("{e}"), } + let master_key = + master_key.ok_or("Cannot decrypt Aegis backup: wrong password or invalid key slot")?; + let content = BASE64 + .decode(self.db.as_bytes()) + .map_err(|_| "Invalid Aegis base64 data")?; + let plaintext = + zeroize::Zeroizing::new(decrypt(&master_key, &content, &self.header.params)?); + let db: AegisDb = serde_json::from_slice(&plaintext) + .map_err(|_| "Invalid decrypted Aegis account data")?; + db.try_into() } - master_key -} - -fn map_results(decrypted_db: Vec) -> Result, String> { - let json = String::from_utf8(decrypted_db) - .map_err(|e| format!("Failed to decode from utf-8 bytes: {e:?}"))?; - - serde_json::from_str::(json.as_str()) - .map_err(|e| e.to_string()) - .and_then(TryInto::try_into) } -fn get_params(slot: &AegisEncryptedSlot) -> Result { - let n = slot.n.unwrap(); - let p = slot.p.unwrap(); - let r = slot.r.unwrap(); - - Params::new((n as f32).log2() as u8, r, p) - .map_err(|e| format!("Error during scrypt params creation: {e:?}")) +fn decrypt(key: &[u8], content: &[u8], params: &AegisEncryptedParams) -> Result, String> { + let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| "Invalid AES key length")?; + let nonce_bytes = Vec::from_hex(¶ms.nonce).map_err(|_| "Invalid Aegis nonce encoding")?; + let nonce = Nonce::::try_from(nonce_bytes.as_slice()) + .map_err(|_| "Invalid Aegis nonce length")?; + let tag = Vec::from_hex(¶ms.tag).map_err(|_| "Invalid Aegis authentication tag")?; + if tag.len() != 16 { + return Err("Invalid Aegis authentication tag length".into()); + } + let mut payload = content.to_vec(); + payload.extend_from_slice(&tag); + cipher + .decrypt(&nonce, payload.as_slice()) + .map_err(|_| "Aegis authentication failed".into()) } fn calc_master_key(slot: &AegisEncryptedSlot, password: &str) -> Result, String> { - let salt = Vec::from_hex(slot.salt.as_ref().unwrap()).expect("Failed to parse hex salt"); - let mut output: [u8; 32] = [0; 32]; - let params = get_params(slot)?; - - if let Err(e) = scrypt( - password.as_bytes(), - salt.as_slice(), - ¶ms, - output.as_mut_slice(), - ) { - return Err(format!("Error during scrypt key derivation: {e:?}")); + let n = slot.n.ok_or("Missing scrypt N")?; + let r = slot.r.ok_or("Missing scrypt r")?; + let p = slot.p.ok_or("Missing scrypt p")?; + if !n.is_power_of_two() + || !(2..=262144).contains(&n) + || r == 0 + || p == 0 + || u64::from(n) * u64::from(r) > 4_194_304 + || u64::from(n) * u64::from(r) * u64::from(p) > 8_388_608 + { + return Err("Unsupported or excessive Aegis scrypt parameters".into()); } - let cipher = Aes256Gcm::new_from_slice(output.as_slice()) - .map_err(|e| format!("Invalid derived key length: {e:?}"))?; - output.zeroize(); - - let cipher_text = [ - Vec::from_hex(&slot.key).expect("Failed to parse hex key"), - Vec::from_hex(&slot.key_params.tag).expect("Failed to parse hex tag"), - ] - .concat(); - - let nonce_bytes = Vec::from_hex(&slot.key_params.nonce).expect("Failed to parse hex nonce"); - let nonce = Nonce::::try_from(nonce_bytes.as_slice()) - .map_err(|e| format!("Invalid nonce length: {e:?}"))?; + let salt = Vec::from_hex(slot.salt.as_ref().ok_or("Missing scrypt salt")?) + .map_err(|_| "Invalid scrypt salt")?; + if !(16..=64).contains(&salt.len()) { + return Err("Invalid scrypt salt length".into()); + } + let params = Params::new(n.ilog2() as u8, r, p).map_err(|_| "Invalid scrypt parameters")?; + let mut key = zeroize::Zeroizing::new([0u8; 32]); + scrypt(password.as_bytes(), &salt, ¶ms, key.as_mut()) + .map_err(|_| "Scrypt derivation failed")?; + let encrypted = Vec::from_hex(&slot.key).map_err(|_| "Invalid encrypted Aegis key")?; + decrypt(key.as_slice(), &encrypted, &slot.key_params) +} - cipher - .decrypt(&nonce, cipher_text.as_slice()) - .map_err(|e| format!("Failed to derive master key: {e:?}")) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn malformed_slots_and_authentication_parameters_are_errors() { + let input: AegisEncryptedDatabase = serde_json::from_value(serde_json::json!({ + "header":{"slots":[{"type":1,"key":"bad","key_params":{"nonce":"bad","tag":"bad"},"n":3,"r":8,"p":1,"salt":"bad"}],"params":{"nonce":"bad","tag":"bad"}},"db":"bad" + })).unwrap(); + assert!(input.decrypt_with_password("password").is_err()); + for (nonce, tag) in [ + ("bad", "00"), + ("00", "00"), + ("000000000000000000000000", "00"), + ] { + assert!( + decrypt( + &[0; 32], + b"", + &AegisEncryptedParams { + nonce: nonce.into(), + tag: tag.into() + } + ) + .is_err() + ); + } + } } diff --git a/src/importers/enrollment.rs b/src/importers/enrollment.rs index 7b2de613..2f56854c 100644 --- a/src/importers/enrollment.rs +++ b/src/importers/enrollment.rs @@ -128,6 +128,9 @@ pub fn decode_qr(bytes: &[u8]) -> Result { /// Decode container entries independently, preserving source entry numbers on errors. pub fn parse_json(value: Value, format: &str) -> Result { + if matches!(format, "twofas" | "bitwarden" | "onepassword") { + return super::modern::parse(&value, format); + } let mut report = ImportReport::default(); let (pointer, wrapper): (&str, fn(Value) -> Value) = match format { "cotp" => ( @@ -147,8 +150,16 @@ pub fn parse_json(value: Value, format: &str) -> Result { .pointer(pointer) .and_then(Value::as_array) .ok_or_else(|| eyre!("Import file does not contain the expected account list"))?; + let aegis_groups = value + .pointer("/db/groups") + .cloned() + .unwrap_or_else(|| serde_json::json!([])); for (index, value) in entries.iter().enumerate() { - let value = wrapper(value.clone()); + let value = if format == "aegis" { + serde_json::json!({"db":{"entries":[value],"groups":aegis_groups}}) + } else { + wrapper(value.clone()) + }; let result = match format { "cotp" => convert::(value), "andotp" => convert::>(value), @@ -175,7 +186,13 @@ where } pub fn detect_format(value: &Value) -> Result<&'static str> { - if value.get("elements").is_some() { + if value.get("services").is_some() || value.get("servicesEncrypted").is_some() { + Ok("twofas") + } else if value.get("accounts").is_some() { + Ok("onepassword") + } else if value.get("encrypted").is_some() { + Ok("bitwarden") + } else if value.get("elements").is_some() { Ok("cotp") } else if value.pointer("/db/entries").is_some() { Ok("aegis") diff --git a/src/importers/mod.rs b/src/importers/mod.rs index b1a70bb0..231d7c04 100644 --- a/src/importers/mod.rs +++ b/src/importers/mod.rs @@ -7,4 +7,5 @@ pub mod freeotp_plus; pub mod google_authenticator; #[cfg(test)] pub mod importer; +pub mod modern; pub mod otp_uri; diff --git a/src/importers/modern.rs b/src/importers/modern.rs new file mode 100644 index 00000000..ad3224a4 --- /dev/null +++ b/src/importers/modern.rs @@ -0,0 +1,332 @@ +//! Native plaintext migration formats. See docs/imports.md for primary schemas. +use super::enrollment::ImportReport; +use crate::otp::{ + from_otp_uri::FromOtpUri, + otp_element::{OTPElement, OTPElementBuilder}, + otp_type::OTPType, +}; +use color_eyre::eyre::{Result, eyre}; +use serde_json::Value; +use std::io::{Cursor, Read}; + +pub fn onepassword_archive(bytes: &[u8]) -> Result { + let mut archive = + zip::ZipArchive::new(Cursor::new(bytes)).map_err(|_| eyre!("Invalid 1PUX archive"))?; + let mut entry = archive + .by_name("export.data") + .map_err(|_| eyre!("1PUX archive is missing export.data"))?; + if entry.size() > 32 * 1024 * 1024 { + return Err(eyre!("1PUX export.data exceeds 32 MiB")); + } + let mut json = zeroize::Zeroizing::new(Vec::new()); + entry + .by_ref() + .take(32 * 1024 * 1024 + 1) + .read_to_end(&mut json)?; + if json.len() > 32 * 1024 * 1024 { + return Err(eyre!("1PUX export.data exceeds 32 MiB")); + } + let value = + serde_json::from_slice(&json).map_err(|_| eyre!("Invalid 1PUX export.data JSON"))?; + parse(&value, "onepassword") +} + +fn array<'a>(value: &'a Value, field: &str) -> Result<&'a [Value]> { + value + .get(field) + .and_then(Value::as_array) + .map(Vec::as_slice) + .ok_or_else(|| eyre!("Missing account container: {field}")) +} +fn text<'a>(value: &'a Value, pointer: &str) -> &'a str { + value.pointer(pointer).and_then(Value::as_str).unwrap_or("") +} + +pub fn parse(value: &Value, format: &str) -> Result { + let mut report = ImportReport::default(); + match format { + "twofas" => { + if value + .get("servicesEncrypted") + .is_some_and(|v| !v.is_null() && v.as_str() != Some("")) + { + return Err(eyre!( + "Encrypted 2FAS backups are not supported; export an unencrypted .2fas file" + )); + } + if value + .get("schemaVersion") + .and_then(Value::as_u64) + .is_some_and(|v| v > 4) + { + return Err(eyre!("Unsupported 2FAS backup schema version")); + } + for (index, service) in array(value, "services")?.iter().enumerate() { + report.record( + index + 1, + twofas(service).map(|mut e| { + if let Some(group) = value + .get("groups") + .and_then(Value::as_array) + .into_iter() + .flatten() + .find(|g| g.get("id") == service.get("groupId")) + && let Some(name) = group.get("name").and_then(Value::as_str) + { + e.tags.push(name.to_owned()); + } + vec![e] + }), + ); + } + } + "bitwarden" => { + if value.get("encrypted").and_then(Value::as_bool) == Some(true) { + return Err(eyre!( + "Encrypted Bitwarden exports are not supported; use an unencrypted JSON export" + )); + } + for (index, item) in array(value, "items")?.iter().enumerate() { + let Some(totp) = item.pointer("/login/totp").filter(|v| !v.is_null()) else { + continue; + }; + if totp.as_str() == Some("") { + continue; + } + let result = totp + .as_str() + .ok_or_else(|| eyre!("Invalid TOTP value")) + .and_then(|secret| { + from_secret_or_uri( + secret, + text(item, "/name"), + text(item, "/login/username"), + ) + }) + .map(|mut e| { + e.favorite = item + .get("favorite") + .and_then(Value::as_bool) + .unwrap_or(false); + vec![e] + }); + report.record(index + 1, result); + } + } + "onepassword" => { + let mut index = 0; + for account in array(value, "accounts")? { + for vault in array(account, "vaults")? { + for item in array(vault, "items")? { + for section in item + .pointer("/details/sections") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + for field in section + .get("fields") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(totp) = + field.pointer("/value/totp").filter(|v| !v.is_null()) + else { + continue; + }; + if totp.as_str() == Some("") { + continue; + } + index += 1; + let result = totp + .as_str() + .ok_or_else(|| eyre!("Invalid TOTP value")) + .and_then(|secret| { + from_secret_or_uri( + secret, + text(item, "/overview/title"), + text(item, "/overview/subtitle"), + ) + }) + .map(|mut e| { + e.tags = item + .pointer("/overview/tags") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(); + e.favorite = item + .get("favIndex") + .and_then(Value::as_i64) + .is_some_and(|v| v > 0); + vec![e] + }); + report.record(index, result); + } + } + } + } + } + } + _ => return Err(eyre!("Unknown migration format")), + } + Ok(report) +} + +fn from_secret_or_uri(secret: &str, issuer: &str, label: &str) -> Result { + if secret.starts_with("otpauth://") { + let mut element = OTPElement::from_otp_uri(secret)?; + if element.issuer.is_empty() { + element.issuer = issuer.to_owned(); + } + if element.label.is_empty() { + element.label = label.to_owned(); + } + return Ok(element); + } + if let Some(secret) = secret.strip_prefix("steam://") { + return OTPElementBuilder::default() + .secret(secret) + .issuer(issuer) + .label(label) + .type_(OTPType::Steam) + .digits(5u64) + .build(); + } + OTPElementBuilder::default() + .secret(secret) + .issuer(issuer) + .label(label) + .build() +} + +fn twofas(service: &Value) -> Result { + let otp = service + .get("otp") + .ok_or_else(|| eyre!("Missing OTP parameters"))?; + let secret = service + .get("secret") + .and_then(Value::as_str) + .ok_or_else(|| eyre!("Missing OTP secret"))?; + let kind = otp + .get("tokenType") + .and_then(Value::as_str) + .unwrap_or("TOTP") + .to_ascii_lowercase(); + let issuer = otp + .get("issuer") + .and_then(Value::as_str) + .unwrap_or(text(service, "/name")); + let label = otp + .get("account") + .and_then(Value::as_str) + .or_else(|| otp.get("label").and_then(Value::as_str)) + .unwrap_or(text(service, "/name")); + // A URI funnels all format validation through the same strict path as QR enrollment. + let mut uri = url::Url::parse(&format!("otpauth://{kind}/"))?; + uri.set_path(&format!("{issuer}:{label}")); + { + let mut query = uri.query_pairs_mut(); + query.append_pair("secret", secret); + for (key, default) in [("digits", "6"), ("period", "30"), ("algorithm", "SHA1")] { + match otp.get(key).filter(|v| !v.is_null()) { + Some(Value::String(value)) => { + query.append_pair(key, value); + } + Some(Value::Number(value)) => { + query.append_pair(key, &value.to_string()); + } + None => { + query.append_pair(key, default); + } + _ => return Err(eyre!("Invalid OTP parameter")), + } + } + if let Some(counter) = otp.get("counter").filter(|v| !v.is_null()) { + query.append_pair("counter", &counter.to_string()); + } + } + let mut element = OTPElement::from_otp_uri(uri.as_str())?; + element.order = service.pointer("/order/position").and_then(Value::as_i64); + Ok(element) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::otp::otp_algorithm::OTPAlgorithm; + use serde_json::json; + const TWOFAS: &str = include_str!("../../test_samples/migrations/twofas.2fas"); + const BITWARDEN: &str = include_str!("../../test_samples/migrations/bitwarden.json"); + const ONEPASSWORD: &str = include_str!("../../test_samples/migrations/onepassword.data"); + + #[test] + fn official_schema_fixtures_preserve_parameters_and_metadata() { + let report = parse(&serde_json::from_str(TWOFAS).unwrap(), "twofas").unwrap(); + assert!(report.errors.is_empty()); + let e = &report.elements[0]; + assert_eq!(e.type_, OTPType::Hotp); + assert_eq!(e.counter, Some(42)); + assert_eq!(e.digits, 8); + assert_eq!(e.algorithm, OTPAlgorithm::Sha256); + assert_eq!(e.tags, ["work"]); + assert_eq!(e.order, Some(2)); + let report = parse(&serde_json::from_str(BITWARDEN).unwrap(), "bitwarden").unwrap(); + assert!(report.errors.is_empty()); + assert_eq!(report.elements.len(), 2); + assert!(report.elements[0].favorite); + assert_eq!(report.elements[0].period, 60); + assert_eq!(report.elements[1].label, "bob"); + let report = parse(&serde_json::from_str(ONEPASSWORD).unwrap(), "onepassword").unwrap(); + assert!(report.errors.is_empty()); + assert_eq!(report.elements.len(), 1); + assert_eq!(report.elements[0].algorithm, OTPAlgorithm::Sha512); + assert_eq!(report.elements[0].tags, ["work"]); + assert!(report.elements[0].favorite); + let report = crate::importers::enrollment::parse_uris(include_str!( + "../../test_samples/migrations/ente.txt" + )); + assert!(report.errors.is_empty()); + assert_eq!(report.elements.len(), 2); + assert_eq!(report.elements[1].counter, Some(42)); + } + + #[test] + fn onepassword_zip_reads_export_data_without_extracting_attachments() { + use std::io::Write; + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + writer + .start_file("export.data", zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(ONEPASSWORD.as_bytes()).unwrap(); + writer + .start_file("files/attachment", zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(b"not an OTP").unwrap(); + let bytes = writer.finish().unwrap().into_inner(); + let report = onepassword_archive(&bytes).unwrap(); + assert!(report.errors.is_empty()); + assert_eq!(report.elements.len(), 1); + assert!(onepassword_archive(b"not a ZIP").is_err()); + } + + #[test] + fn unsupported_encryption_is_rejected_and_errors_redact_secrets() { + assert!( + parse( + &json!({"servicesEncrypted":"ciphertext","services":[]}), + "twofas" + ) + .is_err() + ); + assert!(parse(&json!({"encrypted":true,"items":[]}), "bitwarden").is_err()); + assert!(parse(&json!({"schemaVersion":99,"services":[]}), "twofas").is_err()); + let report = parse(&json!({"items":[{"login":{"totp":"TOP_SECRET_INVALID"}},{"login":{"totp":"JBSWY3DP"}}]}), "bitwarden").unwrap(); + assert_eq!(report.errors.len(), 1); + assert!(!report.errors[0].contains("TOP_SECRET")); + assert_eq!(report.elements.len(), 1); + } +} diff --git a/test_samples/migrations/bitwarden.json b/test_samples/migrations/bitwarden.json new file mode 100644 index 00000000..4bd1cf16 --- /dev/null +++ b/test_samples/migrations/bitwarden.json @@ -0,0 +1,30 @@ +{ + "encrypted": false, + "items": [ + { + "name": "Example", + "type": 1, + "favorite": true, + "login": { + "username": "alice", + "totp": "otpauth://totp/Example:alice?secret=JBSWY3DP&algorithm=SHA256&digits=8&period=60" + } + }, + { + "name": "No OTP", + "type": 1, + "login": { + "username": "bob", + "totp": null + } + }, + { + "name": "Bare seed", + "type": 1, + "login": { + "username": "bob", + "totp": "JBSWY3DP" + } + } + ] +} diff --git a/test_samples/migrations/ente.txt b/test_samples/migrations/ente.txt new file mode 100644 index 00000000..dc7b31e9 --- /dev/null +++ b/test_samples/migrations/ente.txt @@ -0,0 +1,2 @@ +otpauth://totp/Example:alice?secret=JBSWY3DP&issuer=Example&algorithm=SHA256&digits=8&period=60 +otpauth://hotp/Example:bob?secret=JBSWY3DP&counter=42 diff --git a/test_samples/migrations/onepassword.data b/test_samples/migrations/onepassword.data new file mode 100644 index 00000000..0ca228c9 --- /dev/null +++ b/test_samples/migrations/onepassword.data @@ -0,0 +1,52 @@ +{ + "accounts": [ + { + "attrs": { + "name": "Synthetic" + }, + "vaults": [ + { + "attrs": { + "name": "Personal" + }, + "items": [ + { + "favIndex": 1, + "overview": { + "title": "Example", + "subtitle": "alice", + "tags": [ + "work" + ] + }, + "details": { + "sections": [ + { + "title": "Security", + "fields": [ + { + "id": "TOTP_field", + "title": "one-time password", + "value": { + "totp": "otpauth://totp/Example:alice?secret=JBSWY3DP&algorithm=SHA512&digits=8&period=60" + } + } + ] + } + ] + } + }, + { + "overview": { + "title": "No OTP" + }, + "details": { + "sections": [] + } + } + ] + } + ] + } + ] +} diff --git a/test_samples/migrations/twofas.2fas b/test_samples/migrations/twofas.2fas new file mode 100644 index 00000000..5f8c514a --- /dev/null +++ b/test_samples/migrations/twofas.2fas @@ -0,0 +1,32 @@ +{ + "schemaVersion": 4, + "services": [ + { + "name": "Example", + "secret": "JBSWY3DP", + "updatedAt": 0, + "otp": { + "issuer": "Example", + "account": "alice", + "tokenType": "HOTP", + "counter": 42, + "algorithm": "SHA256", + "digits": 8, + "period": 30 + }, + "order": { + "position": 2 + }, + "groupId": "group-work" + } + ], + "groups": [ + { + "id": "group-work", + "name": "work", + "isExpanded": true, + "updatedAt": 0 + } + ], + "servicesEncrypted": null +} diff --git a/tests/native_migration_integration_tests.rs b/tests/native_migration_integration_tests.rs new file mode 100644 index 00000000..9cba9764 --- /dev/null +++ b/tests/native_migration_integration_tests.rs @@ -0,0 +1,84 @@ +use assert_cmd::cargo::cargo_bin_cmd; + +#[test] +fn native_imports_and_selected_encrypted_aegis_export_work_without_password_arguments() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("db.cotp"); + std::fs::copy("test_samples/cli_integration_test/empty_database", &db).unwrap(); + for source in [ + "twofas.2fas", + "bitwarden.json", + "onepassword.data", + "ente.txt", + ] { + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&db) + .args(["import", "--yes"]) + .arg(format!("test_samples/migrations/{source}")) + .write_stdin("12345678\n") + .assert() + .success(); + } + let password_file = dir.path().join("password"); + std::fs::write(&password_file, "separate backup password\n").unwrap(); + let encrypted = dir.path().join("aegis.json"); + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&db) + .args([ + "export", + "--aegis-encrypted", + "--tag", + "work", + "--query", + "example", + "--export-password-file", + ]) + .arg(&password_file) + .arg("--path") + .arg(&encrypted) + .write_stdin("12345678\n") + .assert() + .success(); + assert!( + !std::fs::read_to_string(&encrypted) + .unwrap() + .contains("JBSWY3DP") + ); + let restored = dir.path().join("restored.cotp"); + std::fs::copy( + "test_samples/cli_integration_test/empty_database", + &restored, + ) + .unwrap(); + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&restored) + .arg("import") + .arg(&encrypted) + .args(["--yes", "--import-password-file"]) + .arg(&password_file) + .write_stdin("12345678\n") + .assert() + .success(); + let plaintext = dir.path().join("restored.json"); + cargo_bin_cmd!("cotp") + .args(["--password-stdin", "--database-path"]) + .arg(&restored) + .args(["export", "--plaintext", "--path"]) + .arg(&plaintext) + .write_stdin("12345678\n") + .assert() + .success(); + let data: serde_json::Value = + serde_json::from_slice(&std::fs::read(plaintext).unwrap()).unwrap(); + let elements = data["elements"].as_array().unwrap(); + assert_eq!(elements.len(), 2); + assert!( + elements + .iter() + .all(|e| e["tags"] == serde_json::json!(["work"])) + ); + assert!(elements.iter().any(|e| e["counter"] == 42)); +} From 00e569877558df0835e6139abb1391088000a5a0 Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:24:14 +0200 Subject: [PATCH 13/21] feat(session): lock dashboards and expire owned clipboard copies --- README.md | 18 +++ src/arguments/mod.rs | 2 + src/clipboard.rs | 218 +++++++++++++++++++++++------ src/interface/app.rs | 123 ++++++++++++++++ src/interface/enums.rs | 6 +- src/interface/form.rs | 58 ++++++++ src/interface/handlers/mod.rs | 53 ++++++- src/interface/row.rs | 1 + src/interface/tests.rs | 55 ++++++++ src/interface/view.rs | 11 ++ src/main.rs | 13 ++ src/persistence.rs | 17 +++ src/session_lock.rs | 71 ++++++++++ tests/session_integration_tests.rs | 37 +++++ 14 files changed, 638 insertions(+), 45 deletions(-) create mode 100644 src/session_lock.rs create mode 100644 tests/session_integration_tests.rs diff --git a/README.md b/README.md index c37a3ba8..b7623be9 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ use `cotp list` or `cotp list --json` when piping output. | `d` or `Delete` | Delete an account, with confirmation | | `?` or `i` | Open the shortcuts and actions menu | | `Esc` | Close the current screen, finish searching, clear a filter, or quit | +| `Ctrl+L` | Lock immediately; enter the vault password to restore pending edits | +| `Ctrl+S` | Save pending account changes without quitting | | `q`, `Ctrl+C`, `Ctrl+D` | Quit; account changes prompt you to save or discard | ### Stable account selection @@ -272,3 +274,19 @@ Copying an HOTP code commits the incremented counter before releasing the code; if the dashboard has unsaved edits, save those first. Displayed HOTP codes are previews. A clipboard failure after successful persistence still consumes the counter; this prevents a previously released code from being issued again. + + +The dashboard locks after 300 seconds without keyboard, mouse, or paste activity. +`cotp lock` requests a lock in every dashboard using the selected vault, without +requesting a password. Locking encrypts pending account edits and unfinished forms +in memory and drops the decrypted database and vault key. Enter the password to +resume; locking neither saves nor discards pending work. + +Native clipboard copies expire after 30 seconds, provided the clipboard still +contains the copied value. A newer clipboard value is left intact. This comparison +is best effort: operating-system clipboards do not offer an atomic compare-and-clear. +SSH/OSC52 copies cannot be read back reliably and are never automatically cleared; +the dashboard reports this limitation. Set `idle_timeout_seconds` or +`clipboard_seconds` in the JSON configuration to change these defaults; `0` +disables the respective timer. Clipboard expiration continues after a CLI command +exits, using a helper process that receives the copied content through stdin. diff --git a/src/arguments/mod.rs b/src/arguments/mod.rs index 8d4f3dde..7997c5e3 100644 --- a/src/arguments/mod.rs +++ b/src/arguments/mod.rs @@ -65,6 +65,8 @@ pub enum CotpSubcommands { Completions(shell::CompletionsArgs), /// Generate manual pages without unlocking a vault Man(shell::ManArgs), + /// Lock every running dashboard for this vault without unlocking it + Lock(crate::session_lock::LockArgs), /// Add new OTP code Add(AddArgs), /// Edit an existing OTP Code diff --git a/src/clipboard.rs b/src/clipboard.rs index ada7d512..ff42316b 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -5,70 +5,208 @@ use copypasta_ext::prelude::*; #[cfg(all(feature = "desktop-clipboard", target_os = "linux"))] use copypasta_ext::wayland_bin::WaylandBinClipboardContext; #[cfg(feature = "desktop-clipboard")] -use copypasta_ext::x11_bin::ClipboardContext as BinClipboardContext; -#[cfg(feature = "desktop-clipboard")] -use copypasta_ext::x11_fork::ClipboardContext as ForkClipboardContext; +use copypasta_ext::{ + x11_bin::ClipboardContext as BinClipboardContext, + x11_fork::ClipboardContext as ForkClipboardContext, +}; use crossterm::style::Print; -use std::{env, io}; +use std::{ + env, + io::{self, Read, Write}, + process::{Command, Stdio}, + time::Duration, +}; +use zeroize::Zeroizing; pub enum CopyType { Native, OSC52, } +/// Native expiration compares the clipboard with our copy before clearing it. +/// OSC52 has no portable read-back, so it is never cleared automatically. pub fn copy_string_to_clipboard(content: &str) -> color_eyre::Result { - if ssh_clipboard(content) { - Ok(CopyType::OSC52) - } else if wayland_clipboard(content) || other_platform_clipboard(content) { - Ok(CopyType::Native) - } else { - Err(eyre!("Cannot detect clipboard implementation")) - } -} - -fn ssh_clipboard(content: &str) -> bool { - env_var_set("SSH_CONNECTION") - // We do not use copypasta_ext::osc52 module because we have enabled terminal raw mode, so we print with crossterm utilities - // Check https://github.com/timvisee/rust-clipboard-ext/blob/371df19d2f961882a21c957f396d1e24548d1f28/src/osc52.rs#L92 - && crossterm::execute!( + if env_var_set("SSH_CONNECTION") { + crossterm::execute!( io::stdout(), Print(format!( "\x1B]52;c;{}\x07", general_purpose::STANDARD.encode(content) )) - ) - .is_ok() + )?; + return Ok(CopyType::OSC52); + } + for backend in ["wayland", "bin", "fork"] { + if native_set(backend, content) { + let seconds = crate::config::settings().clipboard_seconds; + if seconds > 0 { + schedule_expiration(backend, content, seconds)?; + } + return Ok(CopyType::Native); + } + } + Err(eyre!("Cannot detect clipboard implementation")) } -#[cfg(all(feature = "desktop-clipboard", target_os = "linux"))] -fn wayland_clipboard(content: &str) -> bool { - env_var_set("WAYLAND_DISPLAY") - && WaylandBinClipboardContext::new() - .and_then(|mut ctx| ctx.set_contents(content.to_owned())) - .is_ok() + +fn schedule_expiration(backend: &str, content: &str, seconds: u64) -> color_eyre::Result<()> { + // The short-lived CLI cannot own the timer. Pass the sensitive content through + // an anonymous pipe; argv and environment contain only public timer metadata. + let mut child = Command::new(env::current_exe()?) + .args(["--internal-clipboard-expiry", backend, &seconds.to_string()]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + if let Some(mut input) = child.stdin.take() { + input.write_all(content.as_bytes())?; + } + // Reap the helper in long-running dashboards; the OS reaps it after CLI exit. + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) } -#[cfg(not(all(feature = "desktop-clipboard", target_os = "linux")))] -fn wayland_clipboard(_content: &str) -> bool { +/// Handle the private helper before parsing CLI arguments or unlocking a vault. +pub fn run_expiry_helper() -> color_eyre::Result { + let args: Vec<_> = env::args().collect(); + if args.get(1).map(String::as_str) == Some("--internal-clipboard-write") { + let mut content = Zeroizing::new(String::new()); + io::stdin().take(1_048_577).read_to_string(&mut content)?; + if content.len() > 1_048_576 || !fork_set(&content) { + return Err(eyre!("Cannot write native clipboard")); + } + return Ok(true); + } + if args.get(1).map(String::as_str) != Some("--internal-clipboard-expiry") { + return Ok(false); + } + if args.len() != 4 { + return Err(eyre!("Invalid clipboard helper arguments")); + } + let seconds = args[3].parse::()?; + let mut content = Zeroizing::new(String::new()); + io::stdin().take(1_048_577).read_to_string(&mut content)?; + if content.len() > 1_048_576 { + return Err(eyre!("Clipboard content too large")); + } + std::thread::sleep(Duration::from_secs(seconds)); + clear_owned( + &content, + || native_get(&args[2]), + |value| native_set(&args[2], value), + ); + Ok(true) +} + +fn clear_owned( + expected: &str, + read: impl FnOnce() -> Option, + clear: impl FnOnce(&str) -> bool, +) -> bool { + if let Some(current) = read() { + let current = Zeroizing::new(current); + if current.as_str() == expected { + return clear(""); + } + } false } #[cfg(feature = "desktop-clipboard")] -fn other_platform_clipboard(content: &str) -> bool { - BinClipboardContext::new() - .and_then(|mut ctx| ctx.set_contents(content.to_owned())) - .is_ok() - || ForkClipboardContext::new() +fn native_set(backend: &str, content: &str) -> bool { + match backend { + #[cfg(target_os = "linux")] + "wayland" if env_var_set("WAYLAND_DISPLAY") => WaylandBinClipboardContext::new() + .and_then(|mut ctx| ctx.set_contents(content.to_owned())) + .is_ok(), + "bin" => BinClipboardContext::new() .and_then(|mut ctx| ctx.set_contents(content.to_owned())) - .is_ok() + .is_ok(), + // X11's provider forks. Isolate it first, so no fork can inherit the + // dashboard's decrypted vault, editor buffers or encryption key. + "fork" => isolated_fork_set(content), + _ => false, + } } - -fn env_var_set(env_var: &str) -> bool { - env::var(env_var) - .map(|v| !v.trim().is_empty()) - .unwrap_or(false) +#[cfg(feature = "desktop-clipboard")] +fn native_get(backend: &str) -> Option { + match backend { + #[cfg(target_os = "linux")] + "wayland" => WaylandBinClipboardContext::new() + .and_then(|mut ctx| ctx.get_contents()) + .ok(), + "bin" => BinClipboardContext::new() + .and_then(|mut ctx| ctx.get_contents()) + .ok(), + "fork" => ForkClipboardContext::new() + .and_then(|mut ctx| ctx.get_contents()) + .ok(), + _ => None, + } +} +#[cfg(not(feature = "desktop-clipboard"))] +fn native_set(_backend: &str, _content: &str) -> bool { + false +} +#[cfg(not(feature = "desktop-clipboard"))] +fn native_get(_backend: &str) -> Option { + None } +#[cfg(feature = "desktop-clipboard")] +fn isolated_fork_set(content: &str) -> bool { + let result = (|| -> color_eyre::Result { + let mut child = Command::new(env::current_exe()?) + .arg("--internal-clipboard-write") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(content.as_bytes())?; + } + Ok(child.wait()?.success()) + })(); + result.unwrap_or(false) +} +#[cfg(feature = "desktop-clipboard")] +fn fork_set(content: &str) -> bool { + ForkClipboardContext::new() + .and_then(|mut ctx| ctx.set_contents(content.to_owned())) + .is_ok() +} #[cfg(not(feature = "desktop-clipboard"))] -fn other_platform_clipboard(_content: &str) -> bool { +fn fork_set(_content: &str) -> bool { false } + +fn env_var_set(name: &str) -> bool { + env::var(name).is_ok_and(|value| !value.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn expires_only_the_copy_we_still_own() { + assert!(clear_owned( + "123456", + || Some("123456".into()), + |value| { + assert!(value.is_empty()); + true + } + )); + assert!(!clear_owned( + "123456", + || Some("user's newer copy".into()), + |_| panic!("must retain newer clipboard") + )); + assert!(!clear_owned( + "123456", + || None, + |_| panic!("must not erase unreadable clipboard") + )); + } +} diff --git a/src/interface/app.rs b/src/interface/app.rs index 6dcd93bc..6987eb81 100644 --- a/src/interface/app.rs +++ b/src/interface/app.rs @@ -13,6 +13,9 @@ pub type AppResult = Result>; pub struct App<'a> { pub running: bool, + pub(crate) sealed: Option, + pub(crate) unlock_password: zeroize::Zeroizing, + pub(crate) last_activity: Instant, pub(crate) table: StatefulTable, pub(crate) database: &'a mut OTPDatabase, pub(crate) current_page: Page, @@ -27,6 +30,7 @@ pub struct App<'a> { pub(crate) hitboxes: Vec<(ratatui::layout::Rect, Action)>, } +#[derive(serde::Serialize, serde::Deserialize)] pub struct Popup { pub(crate) text: String, pub(crate) action: PopupAction, @@ -36,6 +40,9 @@ impl<'a> App<'a> { pub fn new(database: &'a mut OTPDatabase) -> Self { Self { running: true, + sealed: None, + unlock_password: zeroize::Zeroizing::new(String::new()), + last_activity: Instant::now(), table: StatefulTable::new(database.elements_ref()), database, current_page: Page::default(), @@ -69,6 +76,14 @@ impl<'a> App<'a> { } pub fn tick(&mut self, force_update: bool) { + if self.sealed.is_some() { + return; + } + let timeout = crate::config::settings().idle_timeout_seconds; + if timeout > 0 && self.last_activity.elapsed() >= Duration::from_secs(timeout) { + self.lock_session(); + return; + } let now = unix_seconds(); if force_update || now != self.now { self.filter(); @@ -90,3 +105,111 @@ fn unix_seconds() -> u64 { .unwrap_or_default() .as_secs() } + +#[derive(serde::Serialize, serde::Deserialize)] +struct Snapshot { + database: OTPDatabase, + modified: bool, + form: Option, + search: String, + focus: Focus, + page: Page, + popup: Popup, + popup_scroll: u16, + selected: Option, +} +impl App<'_> { + pub(crate) fn lock_session(&mut self) { + use zeroize::Zeroize; + if self.sealed.is_some() { + return; + } + let result = (|| -> color_eyre::Result { + // Serialize by reference, preserving the unsaved flag omitted by the on-disk format. + #[derive(serde::Serialize)] + struct State<'a> { + database: &'a OTPDatabase, + modified: bool, + form: Option, + search: &'a str, + focus: &'a Focus, + page: &'a Page, + popup: &'a Popup, + popup_scroll: u16, + selected: Option, + } + let state = State { + database: self.database, + modified: self.database.is_modified(), + form: self.form.as_ref().map(|f| f.snapshot()), + search: &self.search_query, + focus: &self.focus, + page: &self.current_page, + popup: &self.popup, + popup_scroll: self.popup_scroll, + selected: self.table.selected_index(), + }; + let plaintext = zeroize::Zeroizing::new(serde_json::to_string(&state)?); + crate::persistence::seal(&plaintext) + })(); + match result { + Ok(cipher) => { + self.sealed = Some(cipher); + *self.database = OTPDatabase::default(); + self.form = None; + self.table = StatefulTable::new(&[]); + self.search_query.zeroize(); + self.popup.text.zeroize(); + if let Some((message, _, _)) = self.notification.as_mut() { + message.zeroize(); + } + self.notification = None; + self.hitboxes.clear(); + crate::persistence::lock(); + } + Err(error) => self.notify(format!("Could not lock: {error}"), true), + } + } + pub(crate) fn unlock_session(&mut self) { + use zeroize::Zeroize; + let Some(cipher) = self.sealed.clone() else { + return; + }; + let result = (|| -> color_eyre::Result<()> { + let (text, key, salt) = + crate::crypto::cryptography::decrypt_string(&cipher, &self.unlock_password)?; + let text = zeroize::Zeroizing::new(text); + let key = zeroize::Zeroizing::new(key); + let state: Snapshot = serde_json::from_str(&text)?; + crate::persistence::unlock(&key, &salt)?; + *self.database = state.database; + self.database.needs_modification = state.modified; + self.form = state.form.map(super::form::AccountForm::restore); + self.search_query = state.search; + self.focus = state.focus; + self.current_page = state.page; + self.popup = state.popup; + self.popup_scroll = state.popup_scroll; + self.filter(); + if let Some(selected) = state.selected { + self.table.state.select( + self.table + .items + .iter() + .position(|row| row.database_index == selected), + ); + } + Ok(()) + })(); + self.unlock_password.zeroize(); + match result { + Ok(()) => { + self.sealed = None; + self.last_activity = Instant::now(); + self.filter(); + self.notify("Unlocked · pending edits restored", false); + } + Err(_) => self.notify("Could not unlock. Check your password.", true), + } + } +} diff --git a/src/interface/enums.rs b/src/interface/enums.rs index 4337beb5..e5826720 100644 --- a/src/interface/enums.rs +++ b/src/interface/enums.rs @@ -1,4 +1,4 @@ -#[derive(Eq, PartialEq, Debug)] +#[derive(serde::Serialize, serde::Deserialize, Eq, PartialEq, Debug)] pub enum Focus { MainPage, SearchBar, @@ -6,14 +6,14 @@ pub enum Focus { Form, } -#[derive(Eq, PartialEq, Debug)] +#[derive(serde::Serialize, serde::Deserialize, Eq, PartialEq, Debug)] pub enum PopupAction { DeleteOtp, GeneralInfo, SaveBeforeQuit, } -#[derive(Eq, PartialEq, Debug, Default)] +#[derive(serde::Serialize, serde::Deserialize, Eq, PartialEq, Debug, Default)] pub enum Page { #[default] Main, diff --git a/src/interface/form.rs b/src/interface/form.rs index ddce7be6..8b840a72 100644 --- a/src/interface/form.rs +++ b/src/interface/form.rs @@ -85,7 +85,54 @@ pub(crate) struct AccountForm { pub(crate) error: Option, } +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct FormSnapshot { + editing: Option, + original: Option, + inputs: [String; 9], + secret: String, + pin: String, + active: usize, + error: Option, +} +impl Drop for FormSnapshot { + fn drop(&mut self) { + self.secret.zeroize(); + self.pin.zeroize(); + self.inputs.zeroize(); + } +} + impl AccountForm { + pub(crate) fn snapshot(&self) -> FormSnapshot { + FormSnapshot { + editing: self.editing, + original: self.original.clone(), + inputs: std::array::from_fn(|i| self.inputs[i].value().to_owned()), + secret: self.secret.value.to_string(), + pin: self.pin.value.to_string(), + active: self.active, + error: self.error.clone(), + } + } + pub(crate) fn restore(mut state: FormSnapshot) -> Self { + Self { + editing: state.editing, + original: state.original.take(), + inputs: std::array::from_fn(|i| Input::new(std::mem::take(&mut state.inputs[i]))), + secret: SecretInput { + value: Zeroizing::new(std::mem::take(&mut state.secret)), + cursor: 0, + }, + pin: SecretInput { + value: Zeroizing::new(std::mem::take(&mut state.pin)), + cursor: 0, + }, + active: state.active, + error: state.error.take(), + } + } + pub(crate) fn new(editing: Option, element: Option<&OTPElement>) -> Self { let mut form = Self { editing, @@ -462,3 +509,14 @@ pub(crate) fn handle_form_key(key: KeyEvent, app: &mut App) { _ => form.input(key), } } + +impl Drop for AccountForm { + fn drop(&mut self) { + for input in &mut self.inputs { + input.value_and_reset().zeroize(); + } + if let Some(error) = self.error.as_mut() { + error.zeroize(); + } + } +} diff --git a/src/interface/handlers/mod.rs b/src/interface/handlers/mod.rs index 25f96acf..984e113d 100644 --- a/src/interface/handlers/mod.rs +++ b/src/interface/handlers/mod.rs @@ -13,10 +13,45 @@ mod popup; mod search_bar; pub fn handle_key_events(key_event: KeyEvent, app: &mut App) { + if app.sealed.is_some() { + match key_event.code { + KeyCode::Enter => app.unlock_session(), + KeyCode::Backspace => { + let mut replacement = zeroize::Zeroizing::new(app.unlock_password.to_string()); + replacement.pop(); + app.unlock_password = replacement; + } + KeyCode::Char('u') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { + use zeroize::Zeroize; + app.unlock_password.zeroize(); + } + KeyCode::Char(c) + if !key_event + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + app.unlock_password.push(c) + } + _ => {} + } + return; + } + app.last_activity = std::time::Instant::now(); + if key_event.code == KeyCode::Char('l') && key_event.modifiers.contains(KeyModifiers::CONTROL) { + app.lock_session(); + return; + } if app.focus == Focus::Form { super::form::handle_form_key(key_event, app); return; } + if key_event.code == KeyCode::Char('s') && key_event.modifiers.contains(KeyModifiers::CONTROL) { + match crate::persistence::persist(app.database) { + Ok(()) => app.notify("Changes saved to encrypted vault", false), + Err(error) => app.notify(format!("Could not save: {error}"), true), + } + return; + } if key_event.modifiers.contains(KeyModifiers::CONTROL) && matches!(key_event.code, KeyCode::Char('c' | 'd')) { @@ -32,6 +67,10 @@ pub fn handle_key_events(key_event: KeyEvent, app: &mut App) { } pub fn handle_mouse_events(event: MouseEvent, app: &mut App) { + if app.sealed.is_some() { + return; + } + app.last_activity = std::time::Instant::now(); if event.kind == MouseEventKind::Down(MouseButton::Left) { let action = app .hitboxes @@ -109,6 +148,10 @@ pub fn handle_mouse_events(event: MouseEvent, app: &mut App) { } pub fn handle_paste(text: &str, app: &mut App) { + if app.sealed.is_some() { + return; + } + app.last_activity = std::time::Instant::now(); if let Some(form) = app.form.as_mut() { form.paste(text); return; @@ -150,11 +193,17 @@ pub(crate) fn copy_selected(app: &mut App, uri: bool) { code_for(element) } }) - .and_then(|text| copy_string_to_clipboard(&text).map_err(|error| error.to_string())); + .and_then(|text| { + copy_string_to_clipboard(&zeroize::Zeroizing::new(text)) + .map_err(|error| error.to_string()) + }); let noun = if uri { "OTP URI" } else { "Code" }; match result { Ok(CopyType::Native) => app.notify(format!("✓ {noun} copied to clipboard"), false), - Ok(CopyType::OSC52) => app.notify(format!("✓ {noun} sent to terminal clipboard"), false), + Ok(CopyType::OSC52) => app.notify( + format!("✓ {noun} sent to terminal clipboard · automatic clearing unavailable"), + false, + ), Err(error) => app.notify(format!("Could not copy: {error}"), true), } } diff --git a/src/interface/row.rs b/src/interface/row.rs index 00009c50..df04ece4 100644 --- a/src/interface/row.rs +++ b/src/interface/row.rs @@ -1,5 +1,6 @@ use crate::otp::{otp_element::OTPElement, otp_type::OTPType}; +#[derive(zeroize::Zeroize, zeroize::ZeroizeOnDrop)] pub(crate) struct Row { /// Stable database position, independent of the filtered table position. pub(crate) database_index: usize, diff --git a/src/interface/tests.rs b/src/interface/tests.rs index ccfecc48..62b47def 100644 --- a/src/interface/tests.rs +++ b/src/interface/tests.rs @@ -747,3 +747,58 @@ fn record_ui_demo() { ) .unwrap(); } + +#[test] +fn locking_wipes_plaintext_and_key_and_restores_pending_database_and_form() { + use crate::{ + crypto::cryptography::{argon_derive_key, gen_salt}, + persistence, + }; + use zeroize::Zeroizing; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + std::fs::write(&path, b"unchanged disk").unwrap(); + persistence::initialize(&path).unwrap(); + let salt = gen_salt().unwrap(); + let key = Zeroizing::new(argon_derive_key(b"password", &salt).unwrap()); + persistence::unlock(&key, &salt).unwrap(); + let mut database = database(); + database.mark_modified(); + let mut app = App::new(&mut database); + app.open_form(false); + app.form.as_mut().unwrap().active = 2; + handle_paste("MYUNFINISHEDSECRET", &mut app); + handle_key_events( + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL), + &mut app, + ); + assert!(app.sealed.is_some()); + assert!(app.database.elements_ref().is_empty()); + assert!(!app.database.is_modified()); + assert!(app.table.items.is_empty()); + assert!(app.form.is_none()); + assert!(app.search_query.is_empty()); + assert!(persistence::seal("cannot encrypt without key").is_err()); + assert!(!app.sealed.as_ref().unwrap().contains("MYUNFINISHEDSECRET")); + let locked = text(&render(&mut app, 100, 20)); + assert!(locked.contains("Locked")); + assert!(!locked.contains("alex@")); + app.unlock_password.push_str("wrong password"); + app.unlock_session(); + assert!(app.sealed.is_some()); + assert!(app.unlock_password.is_empty()); + app.unlock_password.push_str("password"); + app.unlock_session(); + assert!(app.sealed.is_none()); + assert_eq!(app.database.elements_ref().len(), 3); + assert!(app.database.is_modified()); + assert_eq!(app.focus, Focus::Form); + let form_state = serde_json::to_string(&app.form.as_ref().unwrap().snapshot()).unwrap(); + assert!(form_state.contains("MYUNFINISHEDSECRET")); + assert_eq!(std::fs::read(&path).unwrap(), b"unchanged disk"); + app.cancel_form(); + app.last_activity = std::time::Instant::now() - std::time::Duration::from_secs(301); + app.tick(false); + assert!(app.sealed.is_some()); + assert!(persistence::seal("locked again").is_err()); +} diff --git a/src/interface/view.rs b/src/interface/view.rs index 0ebee393..52124fc9 100644 --- a/src/interface/view.rs +++ b/src/interface/view.rs @@ -25,6 +25,15 @@ impl App<'_> { ratatui::widgets::Block::default().style(Style::default().bg(BACKGROUND).fg(TEXT)), frame.area(), ); + if self.sealed.is_some() { + let error = self + .notification + .as_ref() + .map(|(message, _, _)| message.as_str()) + .unwrap_or(""); + frame.render_widget(Paragraph::new(format!("cotp · Locked\n\nEnter your vault password and press Enter to unlock.\nPending edits are encrypted in memory.\n\nPassword: {}\n{}", "•".repeat(self.unlock_password.chars().count()), error)).wrap(Wrap { trim: true }), frame.area().inner(Margin::new(1, 1))); + return; + } if frame.area().width < 36 || frame.area().height < 12 { frame.render_widget( Paragraph::new("cotp\n\nEnlarge the terminal to 36 × 12.\nq quit") @@ -88,6 +97,7 @@ impl App<'_> { ("/", "search"), ("Space", "QR"), ("h", "hide"), + ("Ctrl+L", "lock"), ("?", "help"), ("q", "quit"), ] @@ -123,6 +133,7 @@ impl App<'_> { frame.render_widget(Paragraph::new(line), rect); let action = match *key { "Ctrl+U" => Some(Action::ClearSearch), + "Ctrl+L" => None, "↑" => Some(Action::Key(KeyCode::Up)), "↓" | "↑↓" => Some(Action::Key(KeyCode::Down)), "Enter" | "↵" => Some(Action::Key(KeyCode::Enter)), diff --git a/src/main.rs b/src/main.rs index 20d0b297..5edc7af6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ mod otp; mod path; mod persistence; mod reading; +mod session_lock; mod utils; fn init(args: &CotpArgs) -> color_eyre::Result { @@ -71,12 +72,20 @@ fn init(args: &CotpArgs) -> color_eyre::Result { fn main() -> AppResult<()> { color_eyre::install()?; + if clipboard::run_expiry_helper()? { + return Ok(()); + } let mut cotp_args: CotpArgs = CotpArgs::parse(); if arguments::shell::handle(&cotp_args)? { return Ok(()); } config::load(&mut cotp_args)?; + if matches!(cotp_args.command, Some(arguments::CotpSubcommands::Lock(_))) { + session_lock::request(&init_path(&cotp_args))?; + eprintln!("Lock requested for running dashboards"); + return Ok(()); + } if let Some(arguments::CotpSubcommands::Backup(args)) = &cotp_args.command { let path = init_path(&cotp_args); return backup::run(args, &path, cotp_args.password_from_stdin).map_err(|e| e.into()); @@ -141,7 +150,11 @@ fn dashboard(mut database: OTPDatabase) -> AppResult { let terminal = Terminal::new(backend)?; let mut tui = Tui::new(terminal, EventHandler::new(250)); tui.init()?; + let mut lock_requests = session_lock::Watcher::new(path::DATABASE_PATH.get().unwrap())?; while app.running { + if lock_requests.requested() { + app.lock_session(); + } tui.draw(&mut app)?; match tui.events.next()? { Event::Tick => app.tick(false), diff --git a/src/persistence.rs b/src/persistence.rs index b7b72a8a..4928c357 100644 --- a/src/persistence.rs +++ b/src/persistence.rs @@ -116,6 +116,23 @@ pub fn lock() { } } +/// Seal transient UI state before clearing the only retained vault key. +pub fn seal(plaintext: &str) -> Result { + let session = SESSION + .lock() + .map_err(|_| eyre!("Vault session unavailable"))?; + let session = session + .as_ref() + .ok_or_else(|| eyre!("Vault session not initialized"))?; + let key = session + .key + .as_ref() + .ok_or_else(|| eyre!("Vault is locked"))?; + Ok(serde_json::to_string( + &crate::crypto::cryptography::encrypt_string_with_key(plaintext, key, &session.salt)?, + )?) +} + pub fn write_database(path: &Path, content: &[u8]) -> Result<()> { let mut session = SESSION .lock() diff --git a/src/session_lock.rs b/src/session_lock.rs new file mode 100644 index 00000000..189a19d8 --- /dev/null +++ b/src/session_lock.rs @@ -0,0 +1,71 @@ +//! Secret-free cross-process lock requests. Each dashboard watches the same marker. +use color_eyre::eyre::Result; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +fn marker(path: &Path) -> Result { + let mut path = path.canonicalize()?.into_os_string(); + path.push(".lock-request"); + Ok(PathBuf::from(path)) +} + +pub fn request(path: &Path) -> Result<()> { + let mut token = [0; 16]; + getrandom::fill(&mut token).map_err(|e| color_eyre::eyre::eyre!(e))?; + crate::database_file::write(&marker(path)?, &token)?; + Ok(()) +} + +pub struct Watcher { + path: PathBuf, + last: Option>, +} +impl Watcher { + pub fn new(path: &Path) -> Result { + let path = marker(path)?; + let last = fs::read(&path).ok(); + Ok(Self { path, last }) + } + pub fn requested(&mut self) -> bool { + let current = fs::read(&self.path).ok(); + if current == self.last { + return false; + } + let changed = current.is_some(); + self.last = current; + changed + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn all_dashboards_receive_new_requests() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + fs::write(&path, b"encrypted vault").unwrap(); + let mut first = Watcher::new(&path).unwrap(); + let mut second = Watcher::new(&path).unwrap(); + assert!(!first.requested()); + request(&path).unwrap(); + assert!(first.requested()); + assert!(second.requested()); + assert!(!first.requested()); + request(&path).unwrap(); + assert!(first.requested()); + } +} + +#[derive(clap::Args)] +pub struct LockArgs {} +impl crate::arguments::SubcommandExecutor for LockArgs { + fn run_command( + self, + database: crate::otp::otp_element::OTPDatabase, + ) -> Result { + Ok(database) + } +} diff --git a/tests/session_integration_tests.rs b/tests/session_integration_tests.rs new file mode 100644 index 00000000..06579e61 --- /dev/null +++ b/tests/session_integration_tests.rs @@ -0,0 +1,37 @@ +use assert_cmd::Command; +use predicates::prelude::*; + +#[test] +fn lock_requests_do_not_unlock_or_modify_the_vault() { + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().join("vault.cotp"); + std::fs::write(&vault, b"opaque encrypted content").unwrap(); + for _ in 0..2 { + Command::new(assert_cmd::cargo::cargo_bin!("cotp")) + .args(["--database-path", vault.to_str().unwrap(), "lock"]) + .assert() + .success() + .stderr(predicate::str::contains("Lock requested")); + assert_eq!(std::fs::read(&vault).unwrap(), b"opaque encrypted content"); + assert_eq!( + std::fs::read(dir.path().join("vault.cotp.lock-request")) + .unwrap() + .len(), + 16 + ); + } +} + +#[test] +fn clipboard_helper_does_not_open_a_vault() { + let dir = tempfile::tempdir().unwrap(); + Command::new(assert_cmd::cargo::cargo_bin!("cotp")) + .current_dir(dir.path()) + .args(["--internal-clipboard-expiry", "unsupported", "0"]) + .write_stdin("sensitive content supplied only over stdin") + .assert() + .success() + .stdout("") + .stderr(""); + assert!(!dir.path().join("db.cotp").exists()); +} From 404ebe892832de8b1c6397c6180d5ef281f39bd4 Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:26:44 +0200 Subject: [PATCH 14/21] docs: connect feature guides and validate headless builds in CI --- .github/workflows/build.yml | 16 ++++++++++++++++ README.md | 30 +++++++++++++++++++++--------- docs/hardware.md | 6 +++++- docs/imports.md | 2 +- docs/vault-format.md | 4 ++++ 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9c00d591..048f28a3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -98,3 +98,19 @@ jobs: target/debug/cotp* target/aarch64-unknown-linux-gnu/debug/cotp* if-no-files-found: error + + headless: + name: Headless build without desktop clipboard dependencies + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + key: headless + - run: cargo clippy --locked --no-default-features -- -D warnings + - run: cargo test --locked --no-default-features diff --git a/README.md b/README.md index 3c9319f9..fce6afeb 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,9 @@ field, or use `←` / `→` or `Space`, to change it. `Ctrl+S` or **Save account form; `Esc` or **Cancel** discards it. Secret keys and PINs are masked; when editing, leave either blank to keep its existing value. -Account additions, edits, deletions, and counter changes remain pending until you -quit and confirm **save**. Choosing **discard** leaves the on-disk vault unchanged. +Account additions, edits, deletions, and manual counter changes remain pending until +you press `Ctrl+S` or quit and confirm **save**. Choosing **discard** drops pending +edits. Consuming an HOTP code persists its next counter immediately. CLI subcommands remain available for scripting; run `cotp --help` for examples. ## TL;DR @@ -138,8 +139,11 @@ cotp extract --issuer google --copy-clipboard # Import an encrypted Aegis Database backup cotp import --path my_db.json --aegis-encrypted -# Export the cotp database -cotp export +# Create an encrypted backup +cotp backup create ~/backups/cotp --keep 10 + +# Export secrets explicitly for migration +cotp export --plaintext --path migration.json ``` ## Compatibility @@ -220,7 +224,7 @@ Some needs to be converted using simple python script you can find listed in the | [Aegis](https://github.com/beemdevelopment/Aegis) (encrypted) | Make an encrypted backup using the app itself. | No | `--aegis-encrypted` | | [Authy](https://authy.com/) | Obtain `/data/data/com.authy.authy/shared_prefs/com.authy.storage.tokens.authenticator.xml` from your phone. | [Yes](https://github.com/replydev/cotp/blob/master/converters/authy.py) | `--authy` | | [Authy](https://authy.com/) (2nd method) | Follow this guide: https://gist.github.com/gboudreau/94bb0c11a6209c82418d01a59d958c93. | No | `--authy-exported` | -| [cotp](https://github.com/replydev/cotp) | Export your database using `cotp export`. | No | `--cotp` | +| [cotp](https://github.com/replydev/cotp) | Export your database using `cotp export --plaintext`. | No | `--cotp` | | [FreeOTP](https://freeotp.github.io/) | Obtain `/data/data/org.fedorahosted.freeotp/shared_prefs/tokens.xml` from your phone. | [Yes](https://github.com/replydev/cotp/blob/master/converters/freeotp.py) | `--freeotp` | | [FreeOTP+](https://github.com/helloworld1/FreeOTPPlus) | Make a backup using the app itself. | No | `--freeotp-plus` | | [Google Authenticator](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2) | In the app open the menu, choose "Transfer accounts" > "Export accounts", select the accounts and let it generate the QR code(s). Scan each QR code with any scanner and save the resulting `otpauth-migration://...` URI(s), one per line, into a text file. | No | `--google-authenticator` | @@ -241,7 +245,10 @@ To terminate the import: # Configuration -By default database is located in `$HOME/.cotp/db.cotp`. If you want to use a different path, you can use `COTP_DB_PATH` environment variable or use the `--database-path` argument. +Release builds use the platform data directory (`$XDG_DATA_HOME/cotp/db.cotp` on Linux), +with automatic migration from the legacy `$HOME/.cotp/db.cotp`. An existing `./db.cotp` +enables portable mode; debug builds always use that local path. Override the location +with `COTP_DB_PATH`, `--database-path`, or a named vault profile. The first can be configured in shell configuration files, the second in package managers where the configuration of environment variables is not allowed, like Scoop. These are examples of how to do this in bash: @@ -253,10 +260,15 @@ or $ cotp --database-path /home/user/.local/custom-folder/db.cotp ``` -# Planned features +# Feature guides + +- [Configuration, named profiles, shell completions, manuals, and headless builds](docs/configuration.md) +- [QR enrollment, repeatable imports, native migration formats, and encrypted Aegis exports](docs/imports.md) +- [Encrypted snapshot exchange and explicit conflict resolution](docs/exchange.md) +- [YubiKey OATH accounts and touch-required codes](docs/hardware.md) +- [Security model and release provenance](SECURITY.md), [vault format](docs/vault-format.md) -Currently, there is not any planned feature. If you need something new that could improve the software feel free to open -an issue. +Run `cotp --help` and `cotp COMMAND --help` for the complete command reference. # Contribution diff --git a/docs/hardware.md b/docs/hardware.md index 988e0bde..9997d3c5 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -7,7 +7,7 @@ with the global `--device SERIAL` option. ```sh cotp --backend yubikey list --json -cotp hardware code 'GitHub:me@example.com' +cotp --backend yubikey code 'GitHub:me@example.com' cotp hardware code 'GitHub:me@example.com' --copy --quiet cotp --device 12345678 hardware add 'me@example.com' --issuer GitHub --touch cotp hardware delete 'GitHub:me@example.com' @@ -30,3 +30,7 @@ closed so a password prompt fails instead of hanging. The backend is tested with a deterministic subprocess fixture; physical-device and touch behavior requires a real key and is not exercised by the regular test suite. Reference: https://docs.yubico.com/software/yubikey/tools/ykman/OATH_Commands.html + +The shared `code` command accepts the YubiKey backend. Hardware `--watch`, +`--preview`, and nonzero `--min-validity` are rejected because the adapter cannot +guarantee their semantics across all device credentials. diff --git a/docs/imports.md b/docs/imports.md index ee76fc68..1d604147 100644 --- a/docs/imports.md +++ b/docs/imports.md @@ -7,7 +7,7 @@ cotp import screenshot.png --dry-run cotp import accounts.json --duplicates skip cotp import accounts.json --duplicates replace cotp import accounts.json --duplicates keep-both -cotp import - < otp-uris.txt +cotp import - --yes < otp-uris.txt cotp import --aegis --path aegis.json ``` diff --git a/docs/vault-format.md b/docs/vault-format.md index 6bde3c6d..0b8c309d 100644 --- a/docs/vault-format.md +++ b/docs/vault-format.md @@ -14,6 +14,10 @@ be a bare array. Schema migrations preserve OTP secrets and parameters. The cryptographic envelope version is separate from the payload schema version; do not change KDF parameters without a new envelope version and migration support. +Payload schema 3 assigns a persistent random 128-bit hexadecimal ID to each account. +Accounts also carry an optional alias, tags, favorite status, and an optional custom +sort order. Legacy migration assigns missing IDs and repairs collisions. + An account stores its issuer, label, Base32 secret, type, HMAC algorithm, digit count, period, optional HOTP counter and optional scheme-specific PIN. Never log the payload. Plaintext portability exports must be treated like passwords. From e3b87c28466a7da8de36eb4407d55aaba4e97ea6 Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:27:38 +0200 Subject: [PATCH 15/21] test(exchange): verify authenticated snapshot merging and dry runs --- tests/exchange_integration_tests.rs | 76 +++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/exchange_integration_tests.rs diff --git a/tests/exchange_integration_tests.rs b/tests/exchange_integration_tests.rs new file mode 100644 index 00000000..a60efc3d --- /dev/null +++ b/tests/exchange_integration_tests.rs @@ -0,0 +1,76 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use std::path::Path; + +fn cmd(vault: &Path) -> Command { + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("cotp")); + cmd.arg("--database-path") + .arg(vault) + .args(["--no-input", "--password-stdin"]); + cmd +} + +#[test] +fn encrypted_merge_authenticates_and_dry_run_preserves_destination() { + let dir = assert_fs::TempDir::new().unwrap(); + let source = dir.path().join("source.cotp"); + let destination = dir.path().join("destination.cotp"); + for path in [&source, &destination] { + cmd(path) + .arg("init") + .write_stdin("test-password\n") + .assert() + .success(); + } + cmd(&source) + .args([ + "add", + "--issuer", + "Example", + "--label", + "alice", + "--secret-stdin", + ]) + .write_stdin("test-password\nJBSWY3DP\n") + .assert() + .success(); + let before = std::fs::read(&destination).unwrap(); + cmd(&destination) + .arg("merge") + .arg(&source) + .args(["--source-password-stdin", "--dry-run", "--json"]) + .write_stdin("test-password\ntest-password\n") + .assert() + .success() + .stdout(predicate::str::contains("\"added\":1")); + assert_eq!(before, std::fs::read(&destination).unwrap()); + cmd(&destination) + .arg("merge") + .arg(&source) + .arg("--source-password-stdin") + .write_stdin("test-password\nwrong-password\n") + .assert() + .failure(); + assert_eq!(before, std::fs::read(&destination).unwrap()); + cmd(&destination) + .arg("merge") + .arg(&source) + .arg("--source-password-stdin") + .write_stdin("test-password\ntest-password\n") + .assert() + .success(); + cmd(&destination) + .args(["list", "--metadata-only", "--json"]) + .write_stdin("test-password\n") + .assert() + .success() + .stdout(predicate::str::contains("alice")); + cmd(&destination) + .arg("merge") + .arg(&source) + .args(["--source-password-stdin", "--json"]) + .write_stdin("test-password\ntest-password\n") + .assert() + .success() + .stdout(predicate::str::contains("\"added\":0")); +} From be9b107125e9d78ea6e6c959903ce3dfc87a2c80 Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:28:07 +0200 Subject: [PATCH 16/21] docs: record independent feature branches and implementation boundaries --- docs/implementation.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/implementation.md diff --git a/docs/implementation.md b/docs/implementation.md new file mode 100644 index 00000000..f40dbeb5 --- /dev/null +++ b/docs/implementation.md @@ -0,0 +1,41 @@ +# CLI standard feature integration + +The implementation is assembled on `feature/cli-standard`; `main` is not modified. +Feature branches and their isolated worktrees are retained for review. Worktrees +are siblings of the integration checkout, named `standard-`. + +| Feature branch | Delivered behavior | +| --- | --- | +| `feature/standard-accounts` | Persistent IDs, aliases, one-based selectors, ambiguity errors, metadata-preserving schema migration | +| `feature/standard-storage` | Cross-process locking, stale-write checks, durable HOTP consumption, encrypted backup create/list/verify/restore and retention | +| `feature/standard-imports` | Local PNG/JPEG QR decoding, OTP/migration URI input, automatic format detection, dry runs, duplicate policies, per-entry validation, explicit private plaintext exports | +| `feature/standard-reliability` | Full HOTP/TOTP RFC vectors, malformed-input property tests, cryptographic envelope validation, security/format documentation, release checksums and signed provenance | +| `feature/standard-shell` | JSON settings, named vault profiles, shell completions, generated manual pages, build without native clipboard dependencies | +| `feature/standard-cli` | Single-account `code`, minimum validity, watch, JSON expiry, metadata-only listing, noninteractive setup, password FDs, clean output and documented exit codes | +| `feature/standard-migrations` | 2FAS/Ente/Bitwarden/1Password imports, 1PUX archives, selective export, encrypted Aegis export, fixture-based interoperability tests | +| `feature/standard-session` | Inactivity/manual/cross-process lock, encrypted pending-edit restoration, explicit save shortcut, native clipboard expiration with ownership comparison | +| `feature/standard-hardware` | YubiKey OATH listing/code/enrollment/deletion through ykman, device selection, single-match generation and touch-capable enrollment | +| `feature/standard-exchange` | Authenticated encrypted snapshot merging, dry runs, explicit conflict policy, HOTP max-counter policy, no silent deletion propagation | +| `feature/standard-organization` | Tags, favorites, custom ordering, shared CLI/TUI filtering and account metadata | +| `feature/standard-keychain` | Opt-in native OS credential storage, authenticated remember/forget/status, vault-scoped credentials and explicit-password precedence | +| `feature/standard-review` | Integration review fixes for plaintext/key lifetimes and malformed backup handling | +| `feature/standard-docs` | Consolidated user guides and headless CI checks | + +## Deliberate boundaries + +- Snapshot exchange is local and explicit. No hosted service or background network + synchronization is introduced. HOTP use must be assigned to a single generating + device; merging counters does not make disconnected concurrent generation safe. +- Native clipboard clearing is best effort. OSC52 cannot safely read back clipboard + ownership, so SSH copies are not automatically erased. +- YubiKey support requires `ykman`; hardware timing/watch/preview options are rejected + when their semantics cannot be guaranteed. Hardware JSON expiry is unknown. +- Keychain integration is opt-in. Session reauthentication still requires the vault + password; locking does not delete a remembered OS credential. +- New authenticator imports accept the documented export formats. Unsupported + proprietary encrypted formats fail with instructions to export supported input. +- Native device/credential stores and GitHub release attestation require their host + environments. Automated tests use synthetic vaults and backend fixtures; no user + credentials are read or written during verification. + +See the linked guides in README for commands, compatibility changes, and configuration. From f856469228b1d3a076b1524fa6a6300f0a0e4363 Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:28:16 +0200 Subject: [PATCH 17/21] fix(security): wipe transient vault buffers and validate backup sources --- src/backup.rs | 26 +++++++++- src/crypto/cryptography.rs | 100 ++++++++++++++++++++++++++++++++----- src/interface/form.rs | 3 ++ src/otp/otp_element.rs | 11 ++-- src/reading.rs | 10 ++-- 5 files changed, 126 insertions(+), 24 deletions(-) diff --git a/src/backup.rs b/src/backup.rs index 91523da2..0bbfffb5 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -1,6 +1,6 @@ //! Backups are authenticated encrypted vault files, never plaintext exports. use crate::{ - crypto::{cryptography::decrypt_string, encrypted_database::EncryptedDatabase}, + crypto::cryptography::{decrypt_string, validate_envelope}, otp::{ migrations::migrate, otp_element::{OTPDatabase, OTPElement}, @@ -140,7 +140,7 @@ fn create(vault: &Path, directory: &Path) -> Result { fs::read(vault)? }; // Reject accidentally pointing backup at a plaintext export. - let _: EncryptedDatabase = serde_json::from_slice(&bytes)?; + validate_envelope(&bytes)?; private_directory(directory)?; let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); let path = directory.join(format!("cotp-{timestamp:030}.cotp-backup")); @@ -242,6 +242,28 @@ mod tests { } } #[test] + fn corrupt_source_does_not_create_backup_or_prune_verified_files() { + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().join("vault"); + let backups = dir.path().join("backups"); + fs::create_dir(&backups).unwrap(); + let good = backups.join("cotp-0001.cotp-backup"); + let original = encrypted(); + fs::write(&good, &original).unwrap(); + let mut corrupt: serde_json::Value = serde_json::from_slice(&original).unwrap(); + corrupt["cipher"] = "truncated".into(); + fs::write(&vault, corrupt.to_string()).unwrap(); + let args = BackupArgs { + action: BackupAction::Create { + directory: backups.clone(), + keep: Some(1), + }, + }; + assert!(run(&args, &vault, false, None).is_err()); + assert_eq!(fs::read(&good).unwrap(), original); + assert_eq!(list(&backups).unwrap(), vec![good]); + } + #[test] fn retention_only_removes_matching_backup_files() { let dir = tempfile::tempdir().unwrap(); for name in [ diff --git a/src/crypto/cryptography.rs b/src/crypto/cryptography.rs index e25bc912..87992ad3 100644 --- a/src/crypto/cryptography.rs +++ b/src/crypto/cryptography.rs @@ -3,7 +3,7 @@ use chacha20poly1305::aead::Aead; use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce}; use color_eyre::eyre::{ErrReport, eyre}; use data_encoding::BASE64; -use zeroize::Zeroizing; +use zeroize::{Zeroize, Zeroizing}; use super::encrypted_database::EncryptedDatabase; @@ -60,7 +60,41 @@ pub fn decrypt_string( encrypted_text: &str, password: &str, ) -> color_eyre::Result<(String, Vec, Vec)> { - //encrypted text is an encrypted database json serialized object + let DecodedEnvelope { + nonce, + cipher_text, + salt, + } = decode_envelope(encrypted_text)?; + + let key = Zeroizing::new(argon_derive_key(password.as_bytes(), salt.as_slice())?); + + let aead = XChaCha20Poly1305::new_from_slice(&key) + .map_err(|e| eyre!("Invalid encryption key length: {e}"))?; + let nonce = XNonce::try_from(nonce.as_slice()) + .map_err(|_| eyre!("Invalid nonce length in encrypted database"))?; + let decrypted = aead + .decrypt(&nonce, cipher_text.as_slice()) + .map_err(|_| eyre!("Wrong password"))?; + let from_utf8 = String::from_utf8(decrypted).map_err(|error| { + error.into_bytes().zeroize(); + eyre!("Decrypted vault is not UTF-8") + })?; + Ok((from_utf8, key.to_vec(), salt)) +} + +struct DecodedEnvelope { + nonce: Vec, + cipher_text: Vec, + salt: Vec, +} + +/// Check the encrypted container without a password. Authentication still requires decrypting it. +pub(crate) fn validate_envelope(bytes: &[u8]) -> color_eyre::Result<()> { + decode_envelope(std::str::from_utf8(bytes)?)?; + Ok(()) +} + +fn decode_envelope(encrypted_text: &str) -> color_eyre::Result { let encrypted_database: EncryptedDatabase = serde_json::from_str(encrypted_text) .map_err(|e| eyre!("Error during encrypted database deserialization: {e}"))?; if encrypted_database.version() != 1 { @@ -79,17 +113,14 @@ pub fn decrypt_string( return Err(eyre!("Invalid nonce or salt length")); } - let key = Zeroizing::new(argon_derive_key(password.as_bytes(), salt.as_slice())?); - - let aead = XChaCha20Poly1305::new_from_slice(&key) - .map_err(|e| eyre!("Invalid encryption key length: {e}"))?; - let nonce = XNonce::try_from(nonce.as_slice()) - .map_err(|_| eyre!("Invalid nonce length in encrypted database"))?; - let decrypted = aead - .decrypt(&nonce, cipher_text.as_slice()) - .map_err(|_| eyre!("Wrong password"))?; - let from_utf8 = String::from_utf8(decrypted).map_err(ErrReport::from)?; - Ok((from_utf8, key.to_vec(), salt)) + if cipher_text.len() < 16 { + return Err(eyre!("Truncated encrypted vault ciphertext")); + } + Ok(DecodedEnvelope { + nonce, + cipher_text, + salt, + }) } #[cfg(test)] @@ -144,3 +175,46 @@ mod malformed_tests { assert!(decrypt_string(&value.to_string(), "correct password").is_err()); } } + +#[cfg(test)] +mod envelope_tests { + use super::*; + + #[test] + fn structural_validation_rejects_unsupported_and_truncated_containers() { + let valid = serde_json::json!({"version":1,"nonce":BASE64.encode(&[0;24]),"salt":BASE64.encode(&[0;16]),"cipher":BASE64.encode(&[0;16])}); + assert!(validate_envelope(valid.to_string().as_bytes()).is_ok()); + for (field, value) in [ + ("version", serde_json::json!(2)), + ("nonce", serde_json::json!(BASE64.encode(&[0; 23]))), + ("salt", serde_json::json!(BASE64.encode(&[0; 15]))), + ("cipher", serde_json::json!(BASE64.encode(&[0; 15]))), + ("cipher", serde_json::json!("not base64!")), + ] { + let mut corrupt = valid.clone(); + corrupt[field] = value; + assert!( + validate_envelope(corrupt.to_string().as_bytes()).is_err(), + "{field}" + ); + } + } + + #[test] + fn authenticated_invalid_utf8_returns_a_sanitized_error() { + let salt = [1; 16]; + let key = Zeroizing::new(argon_derive_key(b"password", &salt).unwrap()); + let nonce = XNonce::from([2; 24]); + let aead = XChaCha20Poly1305::new_from_slice(&key).unwrap(); + let cipher = aead.encrypt(&nonce, &[0xff, 0xfe][..]).unwrap(); + let envelope = EncryptedDatabase::new( + 1, + BASE64.encode(&nonce), + BASE64.encode(&salt), + BASE64.encode(&cipher), + ); + let error = + decrypt_string(&serde_json::to_string(&envelope).unwrap(), "password").unwrap_err(); + assert_eq!(error.to_string(), "Decrypted vault is not UTF-8"); + } +} diff --git a/src/interface/form.rs b/src/interface/form.rs index 8b840a72..044d4ba6 100644 --- a/src/interface/form.rs +++ b/src/interface/form.rs @@ -100,6 +100,9 @@ impl Drop for FormSnapshot { self.secret.zeroize(); self.pin.zeroize(); self.inputs.zeroize(); + if let Some(error) = self.error.as_mut() { + error.zeroize(); + } } } diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index cbfcdc59..18135ccd 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -9,7 +9,7 @@ use data_encoding::BASE32_NOPAD; use qrcode::QrCode; use qrcode::render::unicode; use serde::{Deserialize, Serialize}; -use zeroize::{Zeroize, ZeroizeOnDrop}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use super::{ algorithms::{ @@ -65,8 +65,9 @@ impl OTPDatabase { } fn overwrite_database_key(&self, key: &Vec, salt: &[u8]) -> color_eyre::Result<()> { - let json: &str = &serde_json::to_string(&self)?; - let encrypted = encrypt_string_with_key(json, key, salt)?; + let json = Zeroizing::new(serde_json::to_string(&self)?); + let encrypted = encrypt_string_with_key(&json, key, salt)?; + drop(json); let content = serde_json::to_vec(&encrypted)?; crate::persistence::write_database(DATABASE_PATH.get().unwrap(), &content)?; Ok(()) @@ -74,10 +75,10 @@ impl OTPDatabase { pub fn save_with_pw(&mut self, password: &str) -> color_eyre::Result<(Vec, [u8; 16])> { let salt = gen_salt()?; - let key = argon_derive_key(password.as_bytes(), &salt)?; + let key = Zeroizing::new(argon_derive_key(password.as_bytes(), &salt)?); self.save(&key, &salt)?; crate::persistence::unlock(&key, &salt)?; - Ok((key, salt)) + Ok((key.to_vec(), salt)) } pub fn add_all(&mut self, mut elements: Vec) -> color_eyre::Result<()> { diff --git a/src/reading.rs b/src/reading.rs index fda40c9c..375f0c5e 100644 --- a/src/reading.rs +++ b/src/reading.rs @@ -3,7 +3,7 @@ use crate::otp::otp_element::{OTPDatabase, OTPElement}; use crate::path::DATABASE_PATH; use color_eyre::eyre::{ErrReport, eyre}; use std::fs::read_to_string; -use zeroize::Zeroize; +use zeroize::Zeroizing; pub type ReadResult = (OTPDatabase, Vec, Vec); @@ -21,14 +21,16 @@ pub fn read_decrypted_text(password: &str) -> color_eyre::Result<(String, Vec color_eyre::Result { match read_decrypted_text(password) { - Ok((mut contents, key, salt)) => { + Ok((contents, key, salt)) => { + let contents = Zeroizing::new(contents); + let key = Zeroizing::new(key); let mut database: OTPDatabase = serde_json::from_str(&contents) .or_else(|_| serde_json::from_str::>(&contents).map(Into::into)) .map_err(ErrReport::from)?; - contents.zeroize(); + drop(contents); crate::otp::migrations::migrate(&mut database)?; database.sort(); - Ok((database, key, salt)) + Ok((database, key.to_vec(), salt)) } Err(e) => Err(e), } From 768a5dfdf9e6fb01a2e26d654e599e46d0ac6f0c Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:28:42 +0200 Subject: [PATCH 18/21] feat(keychain): add opt-in native credential store unlock --- Cargo.lock | 964 +++++++++++++++++++++++++++- Cargo.toml | 12 + docs/configuration.md | 46 ++ src/arguments/mod.rs | 2 + src/keychain.rs | 321 +++++++++ src/main.rs | 10 +- tests/keychain_integration_tests.rs | 37 ++ 7 files changed, 1362 insertions(+), 30 deletions(-) create mode 100644 src/keychain.rs create mode 100644 tests/keychain_integration_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 4aa5de48..1c848259 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,7 +24,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ "crypto-common 0.2.2", - "inout", + "inout 0.2.5", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", ] [[package]] @@ -33,7 +44,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cipher", + "cipher 0.5.2", "cpubits", "cpufeatures 0.3.1", ] @@ -45,8 +56,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" dependencies = [ "aead", - "aes", - "cipher", + "aes 0.9.3", + "cipher 0.5.2", "ctr", "ctutils", "ghash", @@ -176,6 +187,123 @@ dependencies = [ "tempfile", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "atomic" version = "0.6.1" @@ -185,6 +313,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" @@ -298,6 +432,28 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.8.1" @@ -351,6 +507,12 @@ version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -372,6 +534,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "cc" version = "1.9.0" @@ -401,7 +572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cipher", + "cipher 0.5.2", "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -414,10 +585,20 @@ checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" dependencies = [ "aead", "chacha20", - "cipher", + "cipher 0.5.2", "poly1305", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + [[package]] name = "cipher" version = "0.5.2" @@ -426,7 +607,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout", + "inout 0.2.5", ] [[package]] @@ -551,6 +732,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -598,6 +788,32 @@ dependencies = [ "x11-clipboard", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "core_detect" version = "1.0.0" @@ -626,8 +842,9 @@ dependencies = [ "getrandom 0.4.3", "globset", "hex", - "hmac", + "hmac 0.13.0", "image", + "keyring", "md-5", "predicates", "proptest", @@ -640,7 +857,7 @@ dependencies = [ "scrypt", "serde", "serde_json", - "sha1", + "sha1 0.11.0", "sha2 0.11.1", "tempfile", "test-case", @@ -779,7 +996,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher", + "cipher 0.5.2", ] [[package]] @@ -866,6 +1083,36 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes 0.8.4", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "openssl", + "sha2 0.10.9", + "zeroize", +] + [[package]] name = "deltae" version = "0.3.2" @@ -956,6 +1203,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -1032,6 +1280,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum_dispatch" version = "0.3.13" @@ -1044,6 +1298,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -1069,6 +1344,26 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eyre" version = "0.6.14" @@ -1166,6 +1461,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1181,6 +1491,42 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -1194,6 +1540,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "slab", @@ -1247,6 +1595,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1338,12 +1697,36 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac" version = "0.13.0" @@ -1537,6 +1920,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "inout" version = "0.2.5" @@ -1602,6 +1995,23 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "openssl", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "lab" version = "0.11.0" @@ -1626,6 +2036,16 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -1845,6 +2265,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", + "memoffset 0.9.1", ] [[package]] @@ -1876,6 +2297,39 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1893,6 +2347,36 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1953,13 +2437,60 @@ dependencies = [ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "openssl-sys" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] [[package]] name = "option-ext" @@ -1976,6 +2507,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "owo-colors" version = "4.4.0" @@ -2015,6 +2556,12 @@ dependencies = [ "libm", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2045,7 +2592,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ "digest 0.11.3", - "hmac", + "hmac 0.13.0", ] [[package]] @@ -2156,6 +2703,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2175,6 +2733,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + [[package]] name = "poly1305" version = "0.9.1" @@ -2217,6 +2789,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.4" @@ -2365,6 +2946,8 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ + "libc", + "rand_chacha", "rand_core 0.6.4", ] @@ -2378,11 +2961,24 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -2669,7 +3265,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" dependencies = [ "cfg-if", - "cipher", + "cipher 0.5.2", ] [[package]] @@ -2705,6 +3301,61 @@ dependencies = [ "sha2 0.11.1", ] +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes 0.8.4", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.8", + "serde", + "sha2 0.10.9", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -2754,6 +3405,28 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -2937,6 +3610,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -3226,9 +3905,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3283,6 +3974,17 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unarray" version = "0.1.4" @@ -3376,6 +4078,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -3673,13 +4381,31 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -3697,14 +4423,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -3713,48 +4456,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.4" @@ -3813,6 +4604,16 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xml-rs" version = "0.8.29" @@ -3842,6 +4643,82 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.8", + "serde", + "serde_repr", + "sha1 0.10.7", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -3968,3 +4845,40 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml index fe8644ab..ae60f45a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,3 +74,15 @@ assert_cmd = "2.2.2" assert_fs = "1.1.4" predicates = "3.1.4" test-case = "3.4.0" + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "3.6.3", default-features = false, features = ["sync-secret-service", "crypto-rust", "vendored"] } + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3.6.3", default-features = false, features = ["apple-native"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { version = "3.6.3", default-features = false, features = ["windows-native"] } + +[target.'cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))'.dependencies] +keyring = { version = "3.6.3", default-features = false } diff --git a/docs/configuration.md b/docs/configuration.md index 7997d6a7..c5e33c4d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -36,3 +36,49 @@ cargo build --release --locked --no-default-features This build retains the TUI and SSH OSC52 clipboard support. Local desktop copying requires the default `desktop-clipboard` feature. + +## Optional OS credential store unlock + +By default cotp asks for your vault password. Set `"keychain": true` in your +configuration to permit initial unlock using an explicitly saved password: + +```sh +cotp keychain remember +cotp keychain status +cotp keychain forget +``` + +`remember` requires the current vault password through a terminal prompt, +`--password-stdin`, or `--password-fd`. It authenticates the encrypted vault before +saving anything. Passwords are never accepted through command arguments or +environment variables. Enrollment is explicit: enabling the setting, creating a +vault, or typing a password during normal unlock never saves a credential. +`remember` and `forget` work even while automatic keychain unlock is disabled. +`status` reports presence and whether automatic unlock is enabled; it never prints +the stored password. `forget` deletes only the saved OS credential. + +An explicit `--password-stdin` or `--password-fd` always takes precedence over the +saved password. Missing credentials fall back to the normal terminal prompt; +`--no-input` rejects that prompt. Credential store errors are reported and stop +unlock, rather than falling back silently. A stale password also fails normal vault +authentication: after changing your password, run `keychain remember` again or +`keychain forget`. New vault creation still requires an explicit password or the +usual verified prompt. + +Credentials use service `cotp.vault-password.v1` and a SHA-256 hash of the canonical +vault path as the account identifier. Symlinks to the same vault share a credential; +distinct vault paths have distinct credentials. Moving a vault requires enrolling +its new path. Forget the old path before moving it. The OS credential store protects +the saved password under your operating system account; your vault file remains +encrypted normally. Inactivity locking clears the running session but preserves +the saved credential, and session unlock still requires entering the vault password. + +Supported native stores are Linux Secret Service over D-Bus (for example GNOME +Keyring or KWallet with Secret Service enabled), macOS Keychain, and Windows +Credential Manager. Linux needs a running, unlocked Secret Service session; headless +systems can keep using stdin/file descriptor passwords. Other platforms explicitly +reject keychain commands. The `keyring` 3.6 API is compiled with target-specific +`sync-secret-service` + `crypto-rust` + `vendored`, `apple-native`, or `windows-native` +features; Linux includes D-Bus client code and encrypts secrets during bus transfer. +See the [keyring native store documentation](https://docs.rs/keyring/3.6.3/keyring/#credential-store-features) +and [entry API](https://docs.rs/keyring/3.6.3/keyring/struct.Entry.html). diff --git a/src/arguments/mod.rs b/src/arguments/mod.rs index 660ccf2b..2cad5d87 100644 --- a/src/arguments/mod.rs +++ b/src/arguments/mod.rs @@ -78,6 +78,8 @@ pub enum CotpSubcommands { Init(InitArgs), /// Generate one uniquely selected account code (HOTP advances unless --preview) Code(code::CodeArgs), + /// Remember, forget, or inspect this vault's password in the OS credential store + Keychain(crate::keychain::KeychainArgs), /// Manage accounts on a YubiKey through ykman Hardware(crate::hardware::HardwareArgs), /// Merge an authenticated encrypted backup from another device diff --git a/src/keychain.rs b/src/keychain.rs new file mode 100644 index 00000000..2dd5531c --- /dev/null +++ b/src/keychain.rs @@ -0,0 +1,321 @@ +//! Explicitly enrolled vault passwords in native OS credential stores. +use crate::{arguments::CotpArgs, otp::otp_element::OTPDatabase, utils}; +use clap::{Args, Subcommand}; +use color_eyre::eyre::{Result, WrapErr, eyre}; +use sha2::{Digest, Sha256}; +use std::path::Path; +use zeroize::Zeroizing; + +const SERVICE: &str = "cotp.vault-password.v1"; + +#[derive(Args)] +pub struct KeychainArgs { + #[command(subcommand)] + action: KeychainAction, +} + +#[derive(Subcommand)] +enum KeychainAction { + /// Authenticate the vault password and save it in the OS credential store + Remember, + /// Delete this vault's saved password without unlocking the vault + Forget, + /// Report whether this vault has a saved password (never display the password) + Status, +} + +impl crate::arguments::SubcommandExecutor for KeychainArgs { + fn run_command(self, _: OTPDatabase) -> Result { + Err(eyre!("Keychain commands must run before opening the vault")) + } +} + +trait CredentialStore { + fn get(&self) -> Result>>; + fn set(&self, password: &str) -> Result<()>; + fn delete(&self) -> Result<()>; +} + +struct NativeStore(keyring::Entry); +impl NativeStore { + fn open(path: &Path) -> Result { + if !cfg!(any( + target_os = "linux", + target_os = "macos", + target_os = "windows" + )) { + return Err(eyre!( + "OS keychain integration is unsupported on this platform" + )); + } + keyring::Entry::new(SERVICE, &credential_id(path)?) + .map(Self) + .map_err(|_| backend_error()) + } +} +fn backend_error() -> color_eyre::eyre::Report { + // Native errors can include credential attributes; keep all diagnostics redacted. + eyre!( + "Cannot access the OS credential store; check that it is running, unlocked, and permits cotp access" + ) +} +impl CredentialStore for NativeStore { + fn get(&self) -> Result>> { + match self.0.get_password() { + Ok(password) => Ok(Some(Zeroizing::new(password))), + Err(keyring::Error::NoEntry) => Ok(None), + Err(_) => Err(backend_error()), + } + } + fn set(&self, password: &str) -> Result<()> { + self.0.set_password(password).map_err(|_| backend_error()) + } + fn delete(&self) -> Result<()> { + match self.0.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(_) => Err(backend_error()), + } + } +} + +fn credential_id(path: &Path) -> Result { + let canonical = path + .canonicalize() + .wrap_err("Cannot resolve existing vault path for the OS credential store")?; + let mut hash = Sha256::new(); + hash.update(b"cotp vault path v1\0"); + hash.update(canonical.as_os_str().as_encoded_bytes()); + Ok(hex::encode(hash.finalize())) +} + +/// Explicit password input always takes precedence; creation never consults a keychain. +pub fn unlock_password( + path: &Path, + explicit: Option, + first_run: bool, +) -> Result> { + resolve_password( + explicit.map(Zeroizing::new), + crate::config::settings().keychain, + first_run, + || NativeStore::open(path)?.get(), + || { + if first_run { + utils::verified_password("Choose a password: ", 8).map(Zeroizing::new) + } else { + utils::prompt_password("Password: ").map(Zeroizing::new) + } + }, + ) +} + +fn resolve_password( + explicit: Option>, + enabled: bool, + first_run: bool, + read_saved: impl FnOnce() -> Result>>, + prompt: impl FnOnce() -> Result>, +) -> Result> { + if let Some(password) = explicit { + return Ok(password); + } + if enabled + && !first_run + && let Some(password) = read_saved()? + { + return Ok(password); + } + prompt() +} + +fn remember(store: &impl CredentialStore, path: &Path, password: &str) -> Result<()> { + // Authentication completes before any credential store write. No database is saved. + let bytes = std::fs::read(path).wrap_err("Cannot read vault for password verification")?; + crate::backup::validate(&bytes, password) + .wrap_err("Vault password verification failed; nothing was stored")?; + store.set(password) +} + +pub fn handle(args: &CotpArgs) -> Result { + let Some(crate::arguments::CotpSubcommands::Keychain(command)) = &args.command else { + return Ok(false); + }; + let path = crate::path::init_path(args); + match command.action { + KeychainAction::Remember => { + // Never enroll an existing keychain credential implicitly: require authentication input. + let password = Zeroizing::new(if args.password_from_stdin { + utils::stdin_line()? + } else if let Some(fd) = args.password_fd { + utils::password_from_fd(fd)? + } else { + utils::prompt_password("Vault password to remember: ")? + }); + let store = NativeStore::open(&path)?; + remember(&store, &path, &password)?; + println!("Vault password saved in the OS credential store"); + } + KeychainAction::Forget => { + NativeStore::open(&path)?.delete()?; + println!("Saved vault password removed"); + } + KeychainAction::Status => { + let saved = NativeStore::open(&path)?.get()?.is_some(); + println!( + "{}; automatic keychain unlock {}", + if saved { + "Saved password present" + } else { + "No saved password" + }, + if crate::config::settings().keychain { + "enabled" + } else { + "disabled" + } + ); + } + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::{Cell, RefCell}; + + #[derive(Default)] + struct MockStore { + password: RefCell>>, + fail: Cell, + reads: Cell, + writes: Cell, + } + impl CredentialStore for MockStore { + fn get(&self) -> Result>> { + self.reads.set(self.reads.get() + 1); + if self.fail.get() { + return Err(backend_error()); + } + Ok(self.password.borrow().clone()) + } + fn set(&self, password: &str) -> Result<()> { + if self.fail.get() { + return Err(backend_error()); + } + self.writes.set(self.writes.get() + 1); + *self.password.borrow_mut() = Some(Zeroizing::new(password.to_owned())); + Ok(()) + } + fn delete(&self) -> Result<()> { + if self.fail.get() { + return Err(backend_error()); + } + self.password.borrow_mut().take(); + Ok(()) + } + } + + #[test] + fn explicit_password_default_mode_and_new_vaults_never_read_keychain() { + let store = MockStore::default(); + store.fail.set(true); + for (explicit, enabled, first_run, expected) in [ + (Some("explicit"), true, false, "explicit"), + (None, false, false, "prompt"), + (None, true, true, "prompt"), + ] { + let password = resolve_password( + explicit.map(|s| Zeroizing::new(s.into())), + enabled, + first_run, + || store.get(), + || Ok(Zeroizing::new("prompt".into())), + ) + .unwrap(); + assert_eq!(password.as_str(), expected); + } + assert_eq!(store.reads.get(), 0); + } + + #[test] + fn saved_password_unlocks_missing_falls_back_and_backend_error_propagates() { + let store = MockStore::default(); + store.set("saved").unwrap(); + let saved = resolve_password( + None, + true, + false, + || store.get(), + || panic!("must not prompt"), + ) + .unwrap(); + assert_eq!(saved.as_str(), "saved"); + store.delete().unwrap(); + let prompted = resolve_password( + None, + true, + false, + || store.get(), + || Ok(Zeroizing::new("prompt".into())), + ) + .unwrap(); + assert_eq!(prompted.as_str(), "prompt"); + // Represents the same prompt rejection returned by utils::prompt_password in no-input mode. + assert!( + resolve_password( + None, + true, + false, + || store.get(), + || Err(eyre!("Interactive input is disabled")) + ) + .is_err() + ); + store.fail.set(true); + let failed = resolve_password( + None, + true, + false, + || store.get(), + || panic!("backend failure must not prompt"), + ); + assert!(failed.is_err()); + } + + #[test] + fn remember_authenticates_before_write_and_forget_removes_only_credential() { + let store = MockStore::default(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.cotp"); + std::fs::copy("test_samples/cli_integration_test/empty_database", &path).unwrap(); + let before = std::fs::read(&path).unwrap(); + assert!(remember(&store, &path, "wrong-password").is_err()); + assert_eq!(store.writes.get(), 0); + remember(&store, &path, "12345678").unwrap(); + assert_eq!(store.writes.get(), 1); + assert_eq!(store.get().unwrap().unwrap().as_str(), "12345678"); + store.delete().unwrap(); + assert!(store.get().unwrap().is_none()); + assert_eq!(std::fs::read(path).unwrap(), before); + } + + #[test] + fn credential_scope_is_canonical_and_different_for_distinct_vaults() { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a.cotp"); + let b = dir.path().join("b.cotp"); + std::fs::write(&a, b"a").unwrap(); + std::fs::write(&b, b"b").unwrap(); + let id = credential_id(&a).unwrap(); + assert_eq!(id.len(), 64); + assert_eq!(id, credential_id(&dir.path().join("./a.cotp")).unwrap()); + assert_ne!(id, credential_id(&b).unwrap()); + #[cfg(unix)] + { + let alias = dir.path().join("alias.cotp"); + std::os::unix::fs::symlink(&a, &alias).unwrap(); + assert_eq!(id, credential_id(&alias).unwrap()); + } + } +} diff --git a/src/main.rs b/src/main.rs index cc7c8a20..f78a1c30 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ mod exporters; mod hardware; mod importers; mod interface; +mod keychain; mod otp; mod path; mod persistence; @@ -65,11 +66,7 @@ fn init(args: &CotpArgs) -> color_eyre::Result { } else { args.password_fd.map(utils::password_from_fd).transpose()? }; - let password = zeroize::Zeroizing::new(match explicit_password { - Some(password) => password, - None if first_run => utils::verified_password("Choose a password: ", 8)?, - None => utils::password("Password: ", 0)?, - }); + let password = keychain::unlock_password(&path, explicit_password, first_run)?; if first_run { if password.chars().count() < 8 { return Err(eyre!( @@ -90,6 +87,9 @@ fn run(mut cotp_args: CotpArgs) -> Result<(), (u8, color_eyre::eyre::Report)> { } config::load(&mut cotp_args).map_err(|error| (1, error))?; utils::set_no_input(cotp_args.no_input); + if keychain::handle(&cotp_args).map_err(|error| (1, error))? { + return Ok(()); + } if hardware::handle(&cotp_args).map_err(|error| (3, error))? { return Ok(()); } diff --git a/tests/keychain_integration_tests.rs b/tests/keychain_integration_tests.rs new file mode 100644 index 00000000..74287b34 --- /dev/null +++ b/tests/keychain_integration_tests.rs @@ -0,0 +1,37 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use predicates::str::contains; + +#[test] +fn no_input_remember_requires_explicit_authentication_without_accessing_native_store() { + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().join("vault.cotp"); + std::fs::copy("test_samples/cli_integration_test/empty_database", &vault).unwrap(); + let before = std::fs::read(&vault).unwrap(); + cargo_bin_cmd!("cotp") + .arg("--database-path") + .arg(&vault) + .args(["--no-input", "keychain", "remember"]) + .assert() + .failure() + .stderr(contains("Interactive input is disabled")); + assert_eq!(std::fs::read(vault).unwrap(), before); +} + +#[test] +fn explicit_password_overrides_enabled_keychain_without_accessing_native_store() { + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().join("vault.cotp"); + let config = dir.path().join("config.json"); + std::fs::copy("test_samples/cli_integration_test/empty_database", &vault).unwrap(); + std::fs::write(&config, r#"{"keychain":true}"#).unwrap(); + cargo_bin_cmd!("cotp") + .arg("--database-path") + .arg(&vault) + .arg("--config") + .arg(&config) + .args(["--no-input", "--password-stdin", "list", "--json"]) + .write_stdin("12345678\n") + .assert() + .success() + .stdout("[]\n"); +} From f429b7434796528a902342d0ec638446635b9a9d Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:28:45 +0200 Subject: [PATCH 19/21] feat(accounts): organize tags favorites and custom order across CLI and TUI --- README.md | 23 +++++ src/arguments/add.rs | 16 +++ src/arguments/edit.rs | 46 ++++++++- src/arguments/list.rs | 30 +++++- src/arguments/selector.rs | 19 ++-- src/hardware.rs | 9 +- src/interface/handlers/main_window.rs | 20 ++++ src/interface/handlers/popup.rs | 2 +- src/interface/stateful_table.rs | 6 +- src/interface/tests.rs | 35 +++++++ src/interface/view.rs | 18 +++- src/otp/mod.rs | 1 + src/otp/organization.rs | 85 ++++++++++++++++ src/otp/otp_element.rs | 11 ++- tests/organization_integration_tests.rs | 124 ++++++++++++++++++++++++ 15 files changed, 418 insertions(+), 27 deletions(-) create mode 100644 src/otp/organization.rs create mode 100644 tests/organization_integration_tests.rs diff --git a/README.md b/README.md index 5cc3635f..2b86c902 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ use `cotp list` or `cotp list --json` when piping output. | `a` / `e` | Add / edit an account | | `Space` | Show or close the selected account's QR code | | `h` | Hide / reveal codes | +| `f` | Toggle the selected favorite | | `+` / `-` | Increase / decrease an HOTP counter | | `d` or `Delete` | Delete an account, with confirmation | | `?` or `i` | Open the shortcuts and actions menu | @@ -60,6 +61,28 @@ On `edit`, `--issuer` and `--label` still set new values; use `--match-issuer` and `--match-label` to select by those fields. Aliases are unique ignoring case; `edit --clear-alias` removes an alias. Account IDs survive renaming and TUI edits. +### Tags, favorites, and ordering + +Add metadata with `add --tag work --tag mail --favorite --order 1`. +Tags match without case sensitivity; repeat `--tag` on `list`, `code`, `extract`, +`delete`, or `edit` to require all tags. `--favorite` restricts the selection to favorites. +Filtered list indices retain their positions in the full list. + +```sh +cotp edit --alias github --add-tag work --remove-tag personal --set-favorite true +cotp edit --alias github --order -1 +cotp list --tag work --favorite --metadata-only --json +cotp code --tag work --favorite +cotp edit --alias github --clear-tags --clear-order +``` + +`edit --set-favorite false` removes a favorite. Explicit order values sort first, +lowest to highest; other accounts sort by issuer, label, then persistent ID. +Favorites keep their existing positions. The dashboard displays favorites, aliases, +and tags; press `f` to toggle the selected favorite, then save or discard when quitting. +Dashboard and CLI queries support `tag:work`, `is:favorite`, and combinations such +as `tag:work is:favorite github`. Ordinary query text also matches aliases and IDs. + ### Codes and scripts `cotp code QUERY` selects one account by ID, alias, or issuer/label text. diff --git a/src/arguments/add.rs b/src/arguments/add.rs index 089a4b5e..b165c3d6 100644 --- a/src/arguments/add.rs +++ b/src/arguments/add.rs @@ -26,6 +26,16 @@ pub struct AddArgs { #[arg(long)] pub alias: Option, + /// Account tag (repeat for multiple tags) + #[arg(long = "tag", value_parser = crate::otp::organization::parse_tag)] + pub tags: Vec, + /// Mark this account as a favorite + #[arg(long)] + pub favorite: bool, + /// Position before accounts without an explicit order (lowest first) + #[arg(long, allow_hyphen_values = true)] + pub order: Option, + /// Code issuer #[arg(short, long, default_value = "")] pub issuer: String, @@ -76,6 +86,9 @@ impl SubcommandExecutor for AddArgs { super::selector::validate_alias(&database, alias, None)?; } let alias = self.alias.clone(); + let tags = self.tags.clone(); + let favorite = self.favorite; + let order = self.order; let mut otp_element = if self.otp_uri { let mut otp_uri = if self.take_secret_from_stdin { crate::utils::stdin_line()? @@ -90,6 +103,9 @@ impl SubcommandExecutor for AddArgs { }; otp_element.alias = alias; + crate::otp::organization::add_tags(&mut otp_element.tags, tags); + otp_element.favorite = favorite; + otp_element.order = order; database.add_element(otp_element)?; Ok(database) } diff --git a/src/arguments/edit.rs b/src/arguments/edit.rs index da59b533..7aca3f4d 100644 --- a/src/arguments/edit.rs +++ b/src/arguments/edit.rs @@ -10,7 +10,7 @@ use super::{ #[derive(Args)] pub struct EditArgs { /// One-based account index shown by list - #[arg(short, long, required_unless_present_any = ["id", "alias", "match_issuer", "match_label"])] + #[arg(short, long, required_unless_present_any = ["id", "alias", "match_issuer", "match_label", "tags", "favorite"])] pub index: Option, /// Persistent account identifier #[arg(long)] @@ -24,12 +24,36 @@ pub struct EditArgs { /// Select by label (case-insensitive glob) #[arg(long)] pub match_label: Option, + /// Require this tag when selecting (repeat to require all tags) + #[arg(long = "tag", value_parser = crate::otp::organization::parse_tag)] + pub tags: Vec, + /// Select favorite accounts only + #[arg(long)] + pub favorite: bool, /// Set a unique account alias #[arg(long, conflicts_with = "clear_alias")] pub set_alias: Option, /// Remove the account alias #[arg(long)] pub clear_alias: bool, + /// Add an account tag (repeat for multiple tags) + #[arg(long = "add-tag", value_parser = crate::otp::organization::parse_tag)] + pub add_tags: Vec, + /// Remove an account tag (case-insensitive; repeat as needed) + #[arg(long = "remove-tag", value_parser = crate::otp::organization::parse_tag)] + pub remove_tags: Vec, + /// Remove all account tags before adding new tags + #[arg(long)] + pub clear_tags: bool, + /// Set favorite status explicitly: true or false + #[arg(long, action = clap::ArgAction::Set)] + pub set_favorite: Option, + /// Set a custom position (lowest first) + #[arg(long, allow_hyphen_values = true, conflicts_with = "clear_order")] + pub order: Option, + /// Restore alphabetical position + #[arg(long)] + pub clear_order: bool, /// New code issuer #[arg(short = 's', long)] pub issuer: Option, @@ -65,6 +89,8 @@ impl SubcommandExecutor for EditArgs { issuer: self.match_issuer, label: self.match_label, query: None, + tags: self.tags, + favorite: self.favorite, } .select(&database)?; if let Some(alias) = &self.set_alias { @@ -107,6 +133,24 @@ impl SubcommandExecutor for EditArgs { if self.clear_alias { element.alias = None; } + if self.clear_tags { + element.tags.clear(); + } + element.tags.retain(|tag| { + !self + .remove_tags + .iter() + .any(|value| value.eq_ignore_ascii_case(tag)) + }); + crate::otp::organization::add_tags(&mut element.tags, self.add_tags); + if let Some(favorite) = self.set_favorite { + element.favorite = favorite; + } + if self.clear_order { + element.order = None; + } else if self.order.is_some() { + element.order = self.order; + } database.mark_modified(); Ok(database) } diff --git a/src/arguments/list.rs b/src/arguments/list.rs index 2f82bdda..12eb9ea9 100644 --- a/src/arguments/list.rs +++ b/src/arguments/list.rs @@ -8,6 +8,12 @@ use crate::otp::otp_element::OTPDatabase; #[derive(Args)] pub struct ListArgs { + /// Require this tag (case-insensitive; repeat to require all tags) + #[arg(long = "tag", value_parser = crate::otp::organization::parse_tag)] + pub tags: Vec, + /// List favorite accounts only + #[arg(long)] + pub favorite: bool, /// List output format #[command(flatten)] pub format: Option, @@ -43,6 +49,9 @@ struct JsonOtpList<'a> { alias: Option<&'a str>, issuer: &'a str, label: &'a str, + tags: &'a [String], + favorite: bool, + order: Option, #[serde(skip_serializing_if = "Option::is_none")] otp_code: Option, } @@ -53,6 +62,9 @@ impl SubcommandExecutor for ListArgs { .elements_ref() .iter() .enumerate() + .filter(|(_, element)| { + crate::otp::organization::matches_filters(element, &self.tags, self.favorite) + }) .map(|(index, element)| { Ok(JsonOtpList { index: index + 1, @@ -60,6 +72,9 @@ impl SubcommandExecutor for ListArgs { alias: element.alias.as_deref(), issuer: &element.issuer, label: &element.label, + tags: &element.tags, + favorite: element.favorite, + order: element.order, otp_code: if self.metadata_only { None } else { @@ -73,7 +88,10 @@ impl SubcommandExecutor for ListArgs { serde_json::to_writer_pretty(&mut stdout, &elements)?; writeln!(stdout)?; } else { - write!(stdout, "Index\tID\tAlias\tIssuer\tLabel")?; + write!( + stdout, + "Index\tID\tAlias\tIssuer\tLabel\tTags\tFavorite\tOrder" + )?; if !self.metadata_only { write!(stdout, "\tOTP")?; } @@ -81,12 +99,18 @@ impl SubcommandExecutor for ListArgs { for element in elements { write!( stdout, - "{}\t{}\t{}\t{}\t{}", + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", element.index, element.id.escape_default(), element.alias.unwrap_or("").escape_default(), element.issuer.escape_default(), - element.label.escape_default() + element.label.escape_default(), + element.tags.join(",").escape_default(), + element.favorite, + element + .order + .map(|value| value.to_string()) + .unwrap_or_default() )?; if let Some(code) = element.otp_code { write!(stdout, "\t{code}")?; diff --git a/src/arguments/selector.rs b/src/arguments/selector.rs index 649a022a..76623eb9 100644 --- a/src/arguments/selector.rs +++ b/src/arguments/selector.rs @@ -23,6 +23,12 @@ pub struct AccountSelector { /// Account label, optionally a case-insensitive glob #[arg(short, long)] pub label: Option, + /// Require this tag (case-insensitive; repeat to require all tags) + #[arg(long = "tag", value_parser = crate::otp::organization::parse_tag)] + pub tags: Vec, + /// Match favorite accounts only + #[arg(long)] + pub favorite: bool, /// Account ID, alias, or text in its issuer/label #[arg(value_name = "QUERY")] pub query: Option, @@ -39,6 +45,8 @@ impl AccountSelector { && self.issuer.is_none() && self.label.is_none() && self.query.is_none() + && self.tags.is_empty() + && !self.favorite { return Err(eyre!( "Specify an account ID, alias, index, issuer, label, or query" @@ -64,7 +72,8 @@ impl AccountSelector { .iter() .enumerate() .filter(|(index, element)| { - self.index.is_none_or(|value| value == index + 1) + crate::otp::organization::matches_filters(element, &self.tags, self.favorite) + && self.index.is_none_or(|value| value == index + 1) && self.id.as_ref().is_none_or(|value| *value == element.id) && self.alias.as_ref().is_none_or(|value| { element @@ -103,13 +112,7 @@ fn matcher(pattern: &str) -> color_eyre::Result { } fn matches_query(element: &OTPElement, query: &str) -> bool { - element.id.eq_ignore_ascii_case(query) - || element - .alias - .as_ref() - .is_some_and(|alias| alias.eq_ignore_ascii_case(query)) - || element.issuer.to_lowercase().contains(query) - || element.label.to_lowercase().contains(query) + crate::otp::organization::matches_search(element, query) } pub fn validate_alias( diff --git a/src/hardware.rs b/src/hardware.rs index dafca699..c71ca495 100644 --- a/src/hardware.rs +++ b/src/hardware.rs @@ -227,7 +227,9 @@ pub fn handle(args: &crate::arguments::CotpArgs) -> Result { )); } let selector = &args.selector; - if selector.index.is_some() + if !selector.tags.is_empty() + || selector.favorite + || selector.index.is_some() || selector.issuer.is_some() || selector.label.is_some() || [&selector.query, &selector.id, &selector.alias] @@ -249,6 +251,11 @@ pub fn handle(args: &crate::arguments::CotpArgs) -> Result { code(device, query, args.copy, args.quiet, args.json)?; } Some(CotpSubcommands::List(list_args)) => { + if !list_args.tags.is_empty() || list_args.favorite { + return Err(eyre!( + "YubiKey accounts do not support tag or favorite filters" + )); + } list(device, list_args.format.as_ref().is_some_and(|f| f.json))? } _ => { diff --git a/src/interface/handlers/main_window.rs b/src/interface/handlers/main_window.rs index e55cc413..917d1973 100644 --- a/src/interface/handlers/main_window.rs +++ b/src/interface/handlers/main_window.rs @@ -64,6 +64,26 @@ pub(super) fn main_handler(key_event: KeyEvent, app: &mut App) { } KeyCode::Char('+') => handle_counter_switch(app, true), KeyCode::Char('-') => handle_counter_switch(app, false), + KeyCode::Char('f') if key_event.modifiers.is_empty() && app.current_page == Page::Main => { + if let Some(index) = app.table.selected_index() { + let element = app + .database + .mut_element(index) + .expect("selected account exists"); + element.favorite = !element.favorite; + let favorite = element.favorite; + app.database.mark_modified(); + app.filter(); + app.notify( + if favorite { + "Added to favorites" + } else { + "Removed from favorites" + }, + false, + ); + } + } KeyCode::Char('h') => { app.hide_codes = !app.hide_codes; app.reset(); diff --git a/src/interface/handlers/popup.rs b/src/interface/handlers/popup.rs index 94a0f7d2..5ab3e17b 100644 --- a/src/interface/handlers/popup.rs +++ b/src/interface/handlers/popup.rs @@ -28,7 +28,7 @@ pub(super) fn popup_handler(key_event: KeyEvent, app: &mut App) { }, PopupAction::GeneralInfo => match key_event.code { KeyCode::Down | KeyCode::Char('j') => { - app.popup_scroll = app.popup_scroll.saturating_add(1).min(14) + app.popup_scroll = app.popup_scroll.saturating_add(1).min(15) } KeyCode::Up | KeyCode::Char('k') => { app.popup_scroll = app.popup_scroll.saturating_sub(1) diff --git a/src/interface/stateful_table.rs b/src/interface/stateful_table.rs index a7ec0938..1f4f56a8 100644 --- a/src/interface/stateful_table.rs +++ b/src/interface/stateful_table.rs @@ -31,11 +31,7 @@ impl StatefulTable { self.items = elements .iter() .enumerate() - .filter(|(_, element)| { - format!("{} {}", element.issuer, element.label) - .to_lowercase() - .contains(&query) - }) + .filter(|(_, element)| crate::otp::organization::matches_search(element, &query)) .map(|(index, element)| Row::new(index, element)) .collect(); let position = selected diff --git a/src/interface/tests.rs b/src/interface/tests.rs index ccfecc48..6965afac 100644 --- a/src/interface/tests.rs +++ b/src/interface/tests.rs @@ -747,3 +747,38 @@ fn record_ui_demo() { ) .unwrap(); } + +#[test] +fn organization_search_matches_tags_favorites_aliases_and_ids_and_toggle_tracks_identity() { + let mut db = database(); + let first = db.mut_element(0).unwrap(); + first.id = "stable-account-id".into(); + first.alias = Some("work-alias".into()); + first.tags = vec!["Work".into(), "Email".into()]; + first.favorite = true; + let mut app = App::new(&mut db); + let rendered = text(&render(&mut app, 120, 30)); + assert!(rendered.contains("★ GitHub")); + assert!(rendered.contains("@work-alias")); + assert!(rendered.contains("#Work #Email")); + for query in [ + "tag:work is:favorite", + "WORK-ALIAS", + "stable-account-id", + "tag:work tag:email", + ] { + app.search_query = query.into(); + app.filter(); + assert_eq!(app.table.items.len(), 1); + assert_eq!(app.table.selected_index(), Some(0)); + } + app.search_query = "tag:missing".into(); + app.filter(); + assert!(app.table.items.is_empty()); + app.search_query = "is:favorite".into(); + app.filter(); + key(&mut app, KeyCode::Char('f')); + assert!(!app.database.elements_ref()[0].favorite); + assert!(app.database.is_modified()); + assert!(app.table.items.is_empty()); +} diff --git a/src/interface/view.rs b/src/interface/view.rs index 0ebee393..76ced419 100644 --- a/src/interface/view.rs +++ b/src/interface/view.rs @@ -264,12 +264,21 @@ impl App<'_> { } else { &item.issuer }; + let issuer = if element.favorite { + format!("★ {issuer}") + } else { + issuer.to_owned() + }; + let mut details = item.label.clone(); + if let Some(alias) = &element.alias { + details.push_str(&format!(" · @{alias}")); + } + if !element.tags.is_empty() { + details.push_str(&format!(" · #{}", element.tags.join(" #"))); + } let account = Text::from(vec![ Line::from(Span::styled(issuer, Style::default().fg(TEXT).bold())), - Line::from(Span::styled( - item.label.as_str(), - Style::default().fg(MUTED), - )), + Line::from(Span::styled(details, Style::default().fg(MUTED))), ]); let code = if item.has_error { "Unavailable".into() @@ -506,6 +515,7 @@ impl App<'_> { ("Enter", "Copy code / URI", KeyCode::Enter), ("/ or Ctrl+F", "Filter accounts", KeyCode::Char('/')), ("h", "Hide / reveal codes", KeyCode::Char('h')), + ("f", "Toggle favorite", KeyCode::Char('f')), ("Space", "Show / close QR", KeyCode::Char(' ')), ("+", "Increase HOTP counter", KeyCode::Char('+')), ("−", "Decrease HOTP counter", KeyCode::Char('-')), diff --git a/src/otp/mod.rs b/src/otp/mod.rs index 4a06bdd3..db868dac 100644 --- a/src/otp/mod.rs +++ b/src/otp/mod.rs @@ -1,6 +1,7 @@ pub mod algorithms; pub mod from_otp_uri; pub mod migrations; +pub mod organization; pub mod otp_algorithm; pub mod otp_element; mod otp_error; diff --git a/src/otp/organization.rs b/src/otp/organization.rs new file mode 100644 index 00000000..4a52a958 --- /dev/null +++ b/src/otp/organization.rs @@ -0,0 +1,85 @@ +//! Account metadata matching shared by the command line and dashboard. +use super::otp_element::OTPElement; + +pub fn matches_filters(element: &OTPElement, tags: &[String], favorite: bool) -> bool { + (!favorite || element.favorite) + && tags.iter().all(|tag| { + element + .tags + .iter() + .any(|value| value.eq_ignore_ascii_case(tag)) + }) +} + +pub fn matches_search(element: &OTPElement, query: &str) -> bool { + let text = format!( + "{} {} {} {}", + element.issuer, + element.label, + element.alias.as_deref().unwrap_or(""), + element.id + ) + .to_lowercase(); + query.split_whitespace().all(|term| { + let term = term.to_lowercase(); + if let Some(tag) = term.strip_prefix("tag:") { + element.tags.iter().any(|value| value.to_lowercase() == tag) + } else if term == "is:favorite" { + element.favorite + } else { + text.contains(&term) + } + }) +} + +pub fn parse_tag(value: &str) -> Result { + let tag = value.trim(); + if tag.is_empty() || tag.chars().count() > 64 || tag.chars().any(char::is_control) { + return Err("Tags must contain 1–64 characters without control characters".into()); + } + Ok(tag.to_owned()) +} + +pub fn add_tags(existing: &mut Vec, tags: impl IntoIterator) { + for tag in tags { + if !existing + .iter() + .any(|value| value.eq_ignore_ascii_case(&tag)) + { + existing.push(tag); + } + } +} + +#[cfg(test)] +mod tests { + use crate::otp::otp_element::{OTPDatabase, OTPElementBuilder}; + + #[test] + fn explicit_maximum_position_precedes_unordered_and_ties_use_labels_then_ids() { + let element = |label: &str, id: &str, order: Option, favorite: bool| { + OTPElementBuilder::default() + .secret("AA") + .issuer("Example") + .label(label) + .id(id) + .order(order) + .favorite(favorite) + .build() + .unwrap() + }; + let mut db = OTPDatabase::from(vec![ + element("Z", "z", None, true), + element("B", "b", Some(i64::MAX), false), + element("A", "b", None, false), + element("A", "a", None, false), + ]); + db.sort(); + let ids: Vec<_> = db + .elements_ref() + .iter() + .map(|element| element.id.as_str()) + .collect(); + assert_eq!(ids, ["b", "a", "b", "z"]); + } +} diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index cbfcdc59..fbe0a2ce 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -133,10 +133,13 @@ impl OTPDatabase { } pub fn sort(&mut self) { - self.elements.sort_unstable_by(|c1, c2| { - c1.issuer - .to_ascii_lowercase() - .cmp(&c2.issuer.to_ascii_lowercase()) + self.elements.sort_by(|left, right| { + // Explicit positions precede the alphabetical accounts; favorites keep their position. + (left.order.is_none(), left.order) + .cmp(&(right.order.is_none(), right.order)) + .then_with(|| left.issuer.to_lowercase().cmp(&right.issuer.to_lowercase())) + .then_with(|| left.label.to_lowercase().cmp(&right.label.to_lowercase())) + .then_with(|| left.id.cmp(&right.id)) }); } } diff --git a/tests/organization_integration_tests.rs b/tests/organization_integration_tests.rs new file mode 100644 index 00000000..fbc5d005 --- /dev/null +++ b/tests/organization_integration_tests.rs @@ -0,0 +1,124 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use std::path::Path; + +fn command(path: &Path) -> assert_cmd::Command { + let mut command = cargo_bin_cmd!("cotp"); + command + .args(["--no-input", "--password-stdin", "--database-path"]) + .arg(path); + command +} + +fn list(path: &Path, filters: &[&str]) -> serde_json::Value { + let output = command(path) + .args(["list", "--json", "--metadata-only"]) + .args(filters) + .write_stdin("12345678\n") + .assert() + .success() + .get_output() + .stdout + .clone(); + serde_json::from_slice(&output).unwrap() +} + +#[test] +fn organization_persists_filters_and_orders_without_changing_account_identity() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault"); + command(&path) + .arg("init") + .write_stdin("12345678\n") + .assert() + .success(); + for (label, extra) in [ + ( + "alice", + vec![ + "--tag", + "work", + "--tag", + "WORK", + "--tag", + "mail", + "--favorite", + ], + ), + ("bob", vec![]), + ("carol", vec!["--order", "-1"]), + ] { + command(&path) + .args([ + "add", + "--issuer", + "Example", + "--label", + label, + "--alias", + label, + "--type", + "hotp", + "--counter", + "0", + "--secret-stdin", + ]) + .args(extra) + .write_stdin("12345678\nGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ\n") + .assert() + .success(); + } + let all = list(&path, &[]); + assert_eq!(all[0]["label"], "carol"); + assert_eq!(all[1]["label"], "alice"); + assert_eq!(all[2]["label"], "bob"); + let filtered = list(&path, &["--tag", "WORK", "--tag", "mail", "--favorite"]); + assert_eq!(filtered.as_array().unwrap().len(), 1); + assert_eq!(filtered[0]["index"], 2); + assert_eq!(filtered[0]["tags"].as_array().unwrap().len(), 2); + let id = filtered[0]["id"].as_str().unwrap(); + command(&path) + .args(["code", "--tag", "work", "--favorite", "--preview"]) + .write_stdin("12345678\n") + .assert() + .success() + .stdout("755224\n"); + command(&path) + .args([ + "edit", + "--tag", + "WORK", + "--favorite", + "--add-tag", + "bank", + "--remove-tag", + "MAIL", + "--set-favorite", + "false", + "--order", + "-2", + ]) + .write_stdin("12345678\n") + .assert() + .success(); + let edited = list(&path, &[]); + assert_eq!(edited[0]["id"], id); + assert_eq!(edited[0]["favorite"], false); + assert_eq!(edited[0]["tags"], serde_json::json!(["work", "bank"])); + assert_eq!(list(&path, &["--favorite"]), serde_json::json!([])); + command(&path) + .args(["edit", "--id", id, "--clear-tags", "--clear-order"]) + .write_stdin("12345678\n") + .assert() + .success(); + let cleared = list(&path, &[]); + assert_eq!(cleared[0]["label"], "carol"); + assert_eq!(cleared[1]["tags"], serde_json::json!([])); + assert!(cleared[1]["order"].is_null()); + let before = std::fs::read(&path).unwrap(); + command(&path) + .args(["edit", "--id", id, "--add-tag", " "]) + .write_stdin("12345678\n") + .assert() + .code(2); + assert_eq!(std::fs::read(&path).unwrap(), before); +} From 4904a29cf6f16cbb2a7df09b5713505412ec1fff Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:29:03 +0200 Subject: [PATCH 20/21] fix(converters): avoid executing command entrypoints on import --- converters/authy.py | 3 ++- converters/freeotp.py | 3 ++- converters/gauth.py | 3 ++- converters/mauth.py | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/converters/authy.py b/converters/authy.py index 12074895..72d3163a 100644 --- a/converters/authy.py +++ b/converters/authy.py @@ -34,4 +34,5 @@ def main(): output_file.close() -main() +if __name__ == "__main__": + main() diff --git a/converters/freeotp.py b/converters/freeotp.py index 742ddc87..d791861e 100644 --- a/converters/freeotp.py +++ b/converters/freeotp.py @@ -48,4 +48,5 @@ def main(): output_file.close() -main() +if __name__ == "__main__": + main() diff --git a/converters/gauth.py b/converters/gauth.py index ff02c9c0..4e0ac95f 100644 --- a/converters/gauth.py +++ b/converters/gauth.py @@ -36,4 +36,5 @@ def main(): output_file.close() -main() +if __name__ == "__main__": + main() diff --git a/converters/mauth.py b/converters/mauth.py index 42838b4d..8f3ccd16 100644 --- a/converters/mauth.py +++ b/converters/mauth.py @@ -42,4 +42,5 @@ def main(): output_file.close() -main() +if __name__ == "__main__": + main() From fee39869d1313d5b4bae363772d8e65df095f67a Mon Sep 17 00:00:00 2001 From: replydev Date: Wed, 21 Oct 2026 22:34:26 +0200 Subject: [PATCH 21/21] docs: record merged feature validation --- docs/implementation.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/implementation.md b/docs/implementation.md index f40dbeb5..8d0d8864 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -20,6 +20,27 @@ are siblings of the integration checkout, named `standard-`. | `feature/standard-keychain` | Opt-in native OS credential storage, authenticated remember/forget/status, vault-scoped credentials and explicit-password precedence | | `feature/standard-review` | Integration review fixes for plaintext/key lifetimes and malformed backup handling | | `feature/standard-docs` | Consolidated user guides and headless CI checks | +| `fix/standard-converter-tests` | Import-safe Python converter entry points | + +## Integration validation + +The merged implementation passed these checks on Linux aarch64: + +```sh +cargo fmt --all -- --check +cargo clippy -- -D warnings +cargo test --locked --quiet +cargo test --locked --release --quiet +cargo clippy --locked --no-default-features -- -D warnings +cargo test --locked --no-default-features --quiet +uv run --with pytest python -m pytest -q converters +``` + +Each Rust configuration passed 120 unit tests and 24 integration tests. The +cross-process locking helper is marked ignored in the general suite and invoked +explicitly by its parent test. All four Python converter tests passed. macOS, +Windows, physical hardware, native credential stores, and release attestation +were not exercised locally. ## Deliberate boundaries