From 22dc572357e4e9ac14a5b4e041a5d8b0f941e867 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:03:06 +0200 Subject: [PATCH 01/49] fix(import): preserve HOTP counter when importing FreeOTP+ backups The FreeOTP+ importer decided whether to keep the counter by testing the hash algorithm field (token.algo, e.g. "SHA1") against "HOTP" instead of the token type field (token.type). The comparison was never true, so every imported HOTP token ended up with counter: None and failed code generation with "Missing counter value". Trigger: import any FreeOTP+ backup containing an HOTP token. Fix: compare token.type against "HOTP" when extracting the counter. Adds a fixture with an HOTP entry (counter 4) and a test asserting the counter survives the conversion. --- src/importers/freeotp_plus.rs | 24 +++++++++++++++++++++++- test_samples/freeotp_plus_hotp.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 test_samples/freeotp_plus_hotp.json diff --git a/src/importers/freeotp_plus.rs b/src/importers/freeotp_plus.rs index fab6e723..1dffa549 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 @@ -146,6 +146,28 @@ mod tests { ); } + #[test] + fn test_hotp_conversion_keeps_counter() { + let imported = import_from_path::(PathBuf::from( + "test_samples/freeotp_plus_hotp.json", + )); + + assert_eq!( + vec![OTPElement { + secret: "AAAAAAAAAAAAAAAA".to_string(), + issuer: "Example3".to_string(), + label: "Label3".to_string(), + digits: 6, + type_: OTPType::Hotp, + algorithm: OTPAlgorithm::Sha1, + period: 30, + counter: Some(4), + pin: None + }], + imported.unwrap() + ); + } + #[test] fn test_freeotp_export() { // Arrange diff --git a/test_samples/freeotp_plus_hotp.json b/test_samples/freeotp_plus_hotp.json new file mode 100644 index 00000000..32674162 --- /dev/null +++ b/test_samples/freeotp_plus_hotp.json @@ -0,0 +1,28 @@ +{ + "tokenOrder": [ + "Example3:Label3" + ], + "tokens": [ + { + "algo": "SHA1", + "counter": 4, + "digits": 6, + "issuerExt": "Example3", + "label": "Label3", + "period": 30, + "secret": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "type": "HOTP" + } + ] + } From fb45b641414c3897c8b12d9dbaba3eae321076be Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:03:11 +0200 Subject: [PATCH 02/49] fix(export): create plaintext exports with 0600 permissions and stop panicking on I/O errors All export formats (full database JSON, andOTP, FreeOTP+, otpauth URIs) are written unencrypted. The file was previously created with File::create, which honors the default umask and typically yields world-readable 0644 permissions, letting any local user read every exported OTP secret. The file is now opened via OpenOptions with mode(0o600) on Unix (write + create + truncate), so only the owner can read it; on non-Unix platforms the behavior is unchanged. A warning is also printed to stderr reminding the user that the exported file contains secrets in plain text. Additionally: - The two .expect() calls on file creation and writing panicked on any I/O failure (e.g. 'cotp export -p /nonexistent/dir/x.json') even though the function already returns Result; errors are now propagated as a readable message including the target path. - The serialized plaintext String was only zeroized on the success path; it is now zeroized on every path (empty export, write failure, success) before returning. Unit tests cover the error propagation, the empty-export short-circuit, and (on Unix) the 0600 permission bits of the created file. --- src/exporters/mod.rs | 89 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/src/exporters/mod.rs b/src/exporters/mod.rs index 04ee935f..fc598700 100644 --- a/src/exporters/mod.rs +++ b/src/exporters/mod.rs @@ -1,4 +1,8 @@ -use std::{fs::File, io::Write, path::PathBuf}; +use std::{ + fs::OpenOptions, + io::{self, Write}, + path::{Path, PathBuf}, +}; use serde::Serialize; use zeroize::Zeroize; @@ -11,18 +15,79 @@ 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(); + let mut contents = match serde_json::to_string(to_be_saved) { + Ok(contents) => contents, + Err(e) => return Err(format!("{e:?}")), + }; + if contents == "[]" { + contents.zeroize(); + return Err("No contents to export, skipping...".to_owned()); + } + let write_result = write_secret_file(&exported_path, contents.as_bytes()); + contents.zeroize(); + match write_result { + Ok(()) => { + eprintln!( + "Warning: the exported file contains your OTP secrets in PLAIN TEXT. Keep it safe and delete it as soon as it is no longer needed." + ); Ok(exported_path) } - Err(e) => Err(format!("{e:?}")), + Err(e) => Err(format!( + "Cannot export to file {}: {e}", + exported_path.display() + )), + } +} + +/// Writes the plain text export, creating the file with owner-only permissions +/// (0600) on Unix so other local users cannot read the exported secrets. +fn write_secret_file(path: &Path, contents: &[u8]) -> io::Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(contents) +} + +#[cfg(test)] +mod tests { + use super::do_export; + + #[test] + fn export_error_is_propagated_instead_of_panicking() { + let result = do_export( + &vec!["some content"], + std::path::PathBuf::from("/nonexistent-dir/never/created/export.json"), + ); + assert!(result.is_err()); + assert!(result.unwrap_err().starts_with("Cannot export to file")); + } + + #[test] + fn empty_export_is_skipped() { + let empty: Vec = vec![]; + let result = do_export(&empty, std::path::PathBuf::from("unused.json")); + assert_eq!( + result.unwrap_err(), + "No contents to export, skipping...".to_owned() + ); + } + + #[cfg(unix)] + #[test] + fn exported_file_is_created_with_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("cotp-export-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("export.json"); + let exported = do_export(&vec!["secret"], path.clone()).unwrap(); + let mode = std::fs::metadata(&exported).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + std::fs::remove_dir_all(&dir).unwrap(); } } From 2b06bcf427654de35d02c17014be56f7c851d633 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:04:02 +0200 Subject: [PATCH 03/49] fix(tui): stop plain 'c' from opening the delete confirmation popup The main window key handler matched KeyCode::Char('d' | 'D' | 'c') in a single arm and only distinguished the intended Ctrl modifier inside it, so pressing bare 'c' (a natural guess for "copy") fell into the else branch and opened the delete-OTP confirmation popup. Split the arm: 'c'/'C' now only acts with KeyModifiers::CONTROL (the intended Ctrl-C exit path, matching the search-bar handler), while 'd'/'D' keeps its dual role of Ctrl-D exit and plain-d delete prompt. Bare 'c' now does nothing. --- src/interface/handlers/main_window.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/interface/handlers/main_window.rs b/src/interface/handlers/main_window.rs index ec9ca955..535ecc33 100644 --- a/src/interface/handlers/main_window.rs +++ b/src/interface/handlers/main_window.rs @@ -22,8 +22,13 @@ pub(super) fn main_handler(key_event: KeyEvent, app: &mut App) { handle_exit(app); } - // exit application on Ctrl-D - KeyCode::Char('d' | 'D' | 'c') => { + // exit application on Ctrl-C + KeyCode::Char('c' | 'C') if key_event.modifiers == KeyModifiers::CONTROL => { + handle_exit(app); + } + + // exit application on Ctrl-D, delete the selected code on plain D + KeyCode::Char('d' | 'D') => { if key_event.modifiers == KeyModifiers::CONTROL { handle_exit(app); } else if app.table.state.selected().is_some() { From 31c6b3306a702a85b6a072cbe3bdbd838663a1d7 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:04:18 +0200 Subject: [PATCH 04/49] fix(crypto): return a readable error instead of panicking on a corrupted database decrypt_string() used .expect()/.unwrap() when Base64-decoding the nonce, cipher text and salt fields of the on-disk encrypted database envelope. A hand-edited or bit-rotted db.cotp therefore crashed cotp at startup with a panic and backtrace instead of a useful diagnostic, even though the function already returns a color_eyre Result. The three decode calls are now map_err'd into the Result with messages of the form 'database file is corrupted: cannot decode Base64 : ', so the user gets an actionable error message. Unit tests feed envelopes with invalid Base64 in each of the three fields and assert that decrypt_string returns Err (mentioning the corrupted field) rather than panicking. --- src/crypto/cryptography.rs | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/crypto/cryptography.rs b/src/crypto/cryptography.rs index 3ca47f8f..bb677daa 100644 --- a/src/crypto/cryptography.rs +++ b/src/crypto/cryptography.rs @@ -64,11 +64,13 @@ pub fn decrypt_string( .map_err(|e| eyre!("Error during encrypted database deserialization: {e}"))?; let nonce = BASE64 .decode(encrypted_database.nonce().as_bytes()) - .expect("Cannot decode Base64 nonce"); + .map_err(|e| eyre!("database file is corrupted: cannot decode Base64 nonce: {e}"))?; 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(|e| eyre!("database file is corrupted: cannot decode Base64 cipher: {e}"))?; + let salt = BASE64 + .decode(encrypted_database.salt().as_bytes()) + .map_err(|e| eyre!("database file is corrupted: cannot decode Base64 salt: {e}"))?; let key: Vec = argon_derive_key(password.as_bytes(), salt.as_slice())?; @@ -98,4 +100,34 @@ mod tests { decrypt_string(&serde_json::to_string(&encrypted).unwrap(), "pa$$w0rd").unwrap(); assert_eq!(String::from("Secret data@#[]ò"), decrypted); } + + #[test] + fn test_decrypt_invalid_base64_nonce_returns_error() { + let corrupted = + r#"{"version":1,"nonce":"!!!not-base64!!!","salt":"c2FsdA==","cipher":"Y2lwaGVy"}"#; + let result = decrypt_string(corrupted, "pa$$w0rd"); + let error = result.err().expect("corrupted nonce must not panic"); + assert!(error.to_string().contains("database file is corrupted")); + assert!(error.to_string().contains("nonce")); + } + + #[test] + fn test_decrypt_invalid_base64_cipher_returns_error() { + let corrupted = + r#"{"version":1,"nonce":"bm9uY2U=","salt":"c2FsdA==","cipher":"!!!not-base64!!!"}"#; + let result = decrypt_string(corrupted, "pa$$w0rd"); + let error = result.err().expect("corrupted cipher must not panic"); + assert!(error.to_string().contains("database file is corrupted")); + assert!(error.to_string().contains("cipher")); + } + + #[test] + fn test_decrypt_invalid_base64_salt_returns_error() { + let corrupted = + r#"{"version":1,"nonce":"bm9uY2U=","salt":"!!!not-base64!!!","cipher":"Y2lwaGVy"}"#; + let result = decrypt_string(corrupted, "pa$$w0rd"); + let error = result.err().expect("corrupted salt must not panic"); + assert!(error.to_string().contains("database file is corrupted")); + assert!(error.to_string().contains("salt")); + } } From 9a738042b0af9a81e8dff7e3dee7cadb7310893b Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:04:19 +0200 Subject: [PATCH 05/49] fix(tui): don't panic on HOTP counter +/- when counter is unset handle_counter_switch called element.counter.unwrap() with a comment claiming the unwrap was safe because the element type is HOTP. That assumption is false: HOTP elements can exist with counter == None, for example when imported from an otpauth:// URI that omits the counter parameter. Pressing '+' or '-' on such an element crashed the TUI while the terminal was in raw mode. Treat a missing counter as 0 via unwrap_or(0), so '+' initializes it to 1 and '-' saturates at 0; the code is regenerated afterwards as before via mark_modified() and tick(true). --- src/interface/handlers/main_window.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/interface/handlers/main_window.rs b/src/interface/handlers/main_window.rs index 535ecc33..6b04799c 100644 --- a/src/interface/handlers/main_window.rs +++ b/src/interface/handlers/main_window.rs @@ -129,8 +129,9 @@ fn handle_counter_switch(app: &mut App, increment: bool) { && let Some(element) = app.database.mut_element(selected) && element.type_ == OTPType::Hotp { - // safe to unwrap because the element type is HOTP - let counter = element.counter.unwrap(); + // HOTP elements may lack a counter (e.g. imported from an otpauth URI + // without one), so fall back to 0 instead of panicking + let counter = element.counter.unwrap_or(0); element.counter = if increment { Some(counter.saturating_add(1)) } else { From e079fc8e991d1cf09b5fecb0002e63848985de04 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:04:22 +0200 Subject: [PATCH 06/49] fix(otp): always use HMAC-SHA256 for Yandex codes and bounds-check the digest slice Yandex code generation dispatched on the element's algorithm field, falling back to HMAC-SHA1 for anything that was not SHA256/SHA512. Since SHA1 is the default algorithm when adding an element, the default configuration used HMAC-SHA1, whose output is only 20 bytes. The dynamic offset (last byte & 0xf) ranges over 0..=15, so for offsets 13..=15 the slice period_hash[offset..offset + 8] read out of bounds and panicked. That is roughly 3 out of 16 time windows (~19%), and inside the TUI the panic fired mid raw-mode, corrupting the terminal. Yandex.Key and Aegis' reference implementation (which this port is based on) exclusively use HMAC-SHA256, so honoring the stored algorithm field was wrong as well as unsound: codes produced with SHA1/SHA512 never matched Yandex's servers. Fix: calculate_yandex_code is no longer generic over the hash and always uses HMAC-SHA256 (32-byte output, offset+8 <= 23 always in bounds). As defense in depth, the digest indexing now goes through get()/get_mut() and returns OtpError instead of panicking. The existing test vector already exercised SHA256 and still passes. --- src/otp/algorithms/yandex_otp_maker.rs | 58 ++++++++++---------------- src/otp/otp_element.rs | 1 - 2 files changed, 21 insertions(+), 38 deletions(-) diff --git a/src/otp/algorithms/yandex_otp_maker.rs b/src/otp/algorithms/yandex_otp_maker.rs index b5bad7f7..1c0b6f16 100644 --- a/src/otp/algorithms/yandex_otp_maker.rs +++ b/src/otp/algorithms/yandex_otp_maker.rs @@ -3,11 +3,8 @@ use std::time::SystemTime; use data_encoding::BASE32_NOPAD; -use hmac::EagerHash; -use sha1::{Digest, Sha1}; -use sha2::{Sha256, Sha512}; +use sha2::{Digest, Sha256}; -use crate::otp::otp_algorithm::OTPAlgorithm; use crate::otp::otp_error::OtpError; use super::hotp_maker::hotp_hash; @@ -15,40 +12,28 @@ use super::hotp_maker::hotp_hash; const EN_ALPHABET_LENGTH: u64 = 26; const SECRET_LENGTH: usize = 16; -pub fn yandex( - secret: &str, - pin: &str, - period: u64, - digits: usize, - algorithm: OTPAlgorithm, -) -> Result { +/// Yandex OTP codes are always calculated with HMAC-SHA256, regardless of the +/// algorithm stored on the element. The Yandex.Key app (and Aegis' reference +/// implementation this port is based on) exclusively use HMAC-SHA256. +/// +/// Honoring the element's algorithm field was also unsound: HMAC-SHA1 output +/// is only 20 bytes, while the dynamic offset below can reach 15, making +/// `hash[offset..offset + 8]` read out of bounds for offsets 13..=15. +pub fn yandex(secret: &str, pin: &str, period: u64, digits: usize) -> Result { let seconds = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_secs(); - match algorithm { - OTPAlgorithm::Sha256 => { - calculate_yandex_code::(secret, pin, period, digits, seconds) - } - - OTPAlgorithm::Sha512 => { - calculate_yandex_code::(secret, pin, period, digits, seconds) - } - - _ => calculate_yandex_code::(secret, pin, period, digits, seconds), - } + calculate_yandex_code(secret, pin, period, digits, seconds) } -fn calculate_yandex_code( +fn calculate_yandex_code( secret: &str, pin: &str, period: u64, digits: usize, seconds: u64, -) -> Result -where - D: EagerHash, -{ +) -> Result { let decoded_secret = match BASE32_NOPAD.decode(secret.as_bytes()) { Ok(r) => r, Err(e) => return Err(OtpError::SecretEncoding(e.kind, e.position)), @@ -73,7 +58,7 @@ where } let counter: u64 = seconds / period; - let mut period_hash = hotp_hash::(key_hash, counter); + let mut period_hash = hotp_hash::(key_hash, counter); // calculate offset let offset: usize = match period_hash.last() { @@ -81,13 +66,14 @@ where None => return Err(OtpError::InvalidOffset), } as usize; - period_hash[offset] &= 0x7f; + *period_hash.get_mut(offset).ok_or(OtpError::InvalidOffset)? &= 0x7f; - // calculate code - let code_bytes: [u8; 8] = match period_hash[offset..offset + 8].try_into() { - Ok(x) => x, - Err(_) => return Err(OtpError::InvalidDigest), - }; + // calculate code, bounds-checked as defense in depth + let code_bytes: [u8; 8] = period_hash + .get(offset..offset + 8) + .ok_or(OtpError::InvalidDigest)? + .try_into() + .map_err(|_| OtpError::InvalidDigest)?; let code = u64::from_be_bytes(code_bytes); @@ -110,8 +96,6 @@ fn to_yandex_string(mut code: u64, digits: usize) -> String { #[cfg(test)] mod tests { - use sha2::Sha256; - use super::calculate_yandex_code; #[test] @@ -119,7 +103,7 @@ mod tests { let seconds: u64 = 1641559648; assert_eq!( - calculate_yandex_code::( + calculate_yandex_code( "6SB2IKNM6OBZPAVBVTOHDKS4FAAAAAAADFUTQMBTRY", "5239", 30, diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index a8ff6e60..c44cfa69 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -210,7 +210,6 @@ impl OTPElement { pin.as_str(), self.period, self.digits as usize, - self.algorithm, ), None => Err(OtpError::MissingPin), }, From 2c89aa5240adcc5fb37854f2b62e2bf82f828a5e Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:04:23 +0200 Subject: [PATCH 07/49] fix(import): import the pin field from Aegis backups AegisInfo had no pin field and the AegisElement -> OTPElement conversion hardcoded pin: None, so Yandex/MOTP entries imported from Aegis backups lost their pin and could never generate codes. Trigger: import a plain Aegis backup containing a Yandex or MOTP entry whose info object carries a pin. Fix: add pin: Option (serde default) to AegisInfo and map it through to OTPElement. Adds a unit test covering entries with and without a pin. Also removes the dead AegisHeader empty struct (kept alive only by an allow(dead_code)) and the unused Serialize derives on these import-only types. --- src/importers/aegis.rs | 94 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 12 deletions(-) diff --git a/src/importers/aegis.rs b/src/importers/aegis.rs index a510a5a9..94374ce0 100644 --- a/src/importers/aegis.rs +++ b/src/importers/aegis.rs @@ -1,8 +1,8 @@ -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use crate::otp::{otp_algorithm::OTPAlgorithm, otp_element::OTPElement, otp_type::OTPType}; -#[derive(Serialize, Deserialize)] +#[derive(Deserialize)] pub struct AegisJson { //version: u64, //header: AegisHeader, @@ -10,19 +10,12 @@ pub struct AegisJson { } #[derive(Deserialize)] -#[allow(dead_code)] -struct AegisHeader { - //slots: Option, - //params: Option, -} - -#[derive(Serialize, Deserialize)] pub(crate) struct AegisDb { //version: u64, entries: Vec, } -#[derive(Serialize, Deserialize)] +#[derive(Deserialize)] struct AegisElement { r#type: String, //uuid: String, @@ -43,7 +36,7 @@ impl From for OTPElement { algorithm: OTPAlgorithm::from(value.info.algo.as_str()), period: value.info.period.unwrap_or(30), counter: value.info.counter, - pin: None, + pin: value.info.pin, } } } @@ -64,11 +57,88 @@ impl TryFrom for Vec { } } -#[derive(Serialize, Deserialize)] +#[derive(Deserialize)] struct AegisInfo { secret: String, algo: String, digits: u64, period: Option, counter: Option, + #[serde(default)] + pin: Option, +} + +#[cfg(test)] +mod tests { + use super::AegisJson; + use crate::otp::{otp_algorithm::OTPAlgorithm, otp_element::OTPElement, otp_type::OTPType}; + + #[test] + fn test_pin_is_imported() { + let json = r#"{ + "version": 1, + "header": {"slots": null, "params": null}, + "db": { + "version": 2, + "entries": [ + { + "type": "yandex", + "uuid": "00000000-0000-0000-0000-000000000000", + "name": "Label", + "issuer": "Issuer", + "info": { + "secret": "AAAAAAAAAAAAAAAA", + "algo": "SHA256", + "digits": 8, + "period": 30, + "pin": "1234" + } + }, + { + "type": "totp", + "uuid": "00000000-0000-0000-0000-000000000001", + "name": "Label2", + "issuer": "Issuer2", + "info": { + "secret": "BBBBBBBBBBBBBBBB", + "algo": "SHA1", + "digits": 6, + "period": 30 + } + } + ] + } + }"#; + + let deserialized: AegisJson = serde_json::from_str(json).unwrap(); + let elements: Vec = deserialized.try_into().unwrap(); + + assert_eq!( + vec![ + OTPElement { + secret: "AAAAAAAAAAAAAAAA".to_string(), + issuer: "Issuer".to_string(), + label: "Label".to_string(), + digits: 8, + type_: OTPType::Yandex, + algorithm: OTPAlgorithm::Sha256, + period: 30, + counter: None, + pin: Some("1234".to_string()), + }, + OTPElement { + secret: "BBBBBBBBBBBBBBBB".to_string(), + issuer: "Issuer2".to_string(), + label: "Label2".to_string(), + digits: 6, + type_: OTPType::Totp, + algorithm: OTPAlgorithm::Sha1, + period: 30, + counter: None, + pin: None, + } + ], + elements + ); + } } From d4d20e4ac3b6de1eb6eeb0cb91f3e269a7be1e4f Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:04:38 +0200 Subject: [PATCH 08/49] fix(tui): remove todo!() panic path for the unused EditOtp popup action handlers/popup.rs matched PopupAction::EditOtp with todo!(), and App::new initialized popup.action to exactly that variant. Nothing in the codebase ever implemented editing via popup, so the variant was a landmine: any future code path that focuses the popup without going through show_popup() would hit the todo!() and panic while the terminal is in raw mode. Delete the EditOtp variant entirely and derive Default on PopupAction with GeneralInfo as the default, which is harmless if it is ever reached: its handler just returns focus to the main page on i/Esc/Enter. All real popups keep setting their action explicitly via show_popup(). --- src/interface/app.rs | 2 +- src/interface/enums.rs | 4 ++-- src/interface/handlers/popup.rs | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/interface/app.rs b/src/interface/app.rs index 757cea1b..3b7a22f8 100644 --- a/src/interface/app.rs +++ b/src/interface/app.rs @@ -70,7 +70,7 @@ impl<'a> App<'a> { focus: Focus::MainPage, popup: Popup { text: String::new(), - action: PopupAction::EditOtp, + action: PopupAction::default(), percent_x: 60, percent_y: 20, }, diff --git a/src/interface/enums.rs b/src/interface/enums.rs index 373bb849..9fc8e6be 100644 --- a/src/interface/enums.rs +++ b/src/interface/enums.rs @@ -5,10 +5,10 @@ pub enum Focus { Popup, } -#[derive(Eq, PartialEq, Debug)] +#[derive(Eq, PartialEq, Debug, Default)] pub enum PopupAction { - EditOtp, DeleteOtp, + #[default] GeneralInfo, SaveBeforeQuit, } diff --git a/src/interface/handlers/popup.rs b/src/interface/handlers/popup.rs index da032310..46f781a0 100644 --- a/src/interface/handlers/popup.rs +++ b/src/interface/handlers/popup.rs @@ -7,7 +7,6 @@ use crate::interface::{ pub(super) fn popup_handler(key_event: KeyEvent, app: &mut App) { match app.popup.action { - PopupAction::EditOtp => todo!(), PopupAction::DeleteOtp => match key_event.code { KeyCode::Char('y' | 'Y') => { if let Err(e) = delete_selected_code(app) { From e0ea4987685ee07c5e60ef7b261bd43ebe60444a Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:05:09 +0200 Subject: [PATCH 09/49] fix(tui): exit event thread cleanly when the receiver is dropped After dashboard() returns, the mpsc receiver inside EventHandler is dropped, but the polling thread keeps running. Its next sender.send(...).expect(...) then panicked with "failed to send terminal event" / "failed to send tick event", race-dependently spraying a panic message onto the just-restored terminal. Treat a send error (receiver gone) as the shutdown signal and break out of the loop instead of panicking, for both terminal events and tick events. Note: the Event enum still carries dead unit payloads (Mouse(()), Resize((), ()), FocusGained(), Paste(())); they cannot be collapsed without editing the exact-shape match in main.rs, which is out of scope for this change. --- src/interface/event.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/interface/event.rs b/src/interface/event.rs index 2726fe17..703480e1 100644 --- a/src/interface/event.rs +++ b/src/interface/event.rs @@ -49,7 +49,7 @@ impl EventHandler { .unwrap_or(tick_rate); if event::poll(timeout).expect("no events available") { - match event::read().expect("unable to read event") { + let send_result = match event::read().expect("unable to read event") { CrosstermEvent::Key(e) => { // Workaround to fix double input on Windows // Please check https://github.com/crossterm-rs/crossterm/issues/752 @@ -64,12 +64,19 @@ impl EventHandler { CrosstermEvent::FocusGained => sender.send(Event::FocusGained()), CrosstermEvent::FocusLost => sender.send(Event::FocusLost()), CrosstermEvent::Paste(_e) => sender.send(Event::Paste(())), + }; + if send_result.is_err() { + // The receiver has been dropped: the dashboard has + // exited, so stop the event thread gracefully. + break; } - .expect("failed to send terminal event"); } if last_tick.elapsed() >= tick_rate { - sender.send(Event::Tick).expect("failed to send tick event"); + if sender.send(Event::Tick).is_err() { + // Receiver dropped, see above. + break; + } last_tick = Instant::now(); } } From 5fe23e8dbcb3e1ec58d397906c2dbd90538bef26 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:05:11 +0200 Subject: [PATCH 10/49] fix(utils): do not re-initialize an existing database given by a bare relative path init_app decided whether this was a first run by checking the existence of the database file's parent directory. For a bare relative filename (e.g. `cotp -d db.cotp`), Path::parent() returns the empty path and Path::new("").exists() is always false, so an existing, populated database was treated as a first run: cotp prompted for a new password and overwrote the file with an empty database, destroying all stored secrets. The first-run decision is now taken from the database file itself (db_path.exists()); the parent directory is only created when the file does not exist yet, treating an empty parent as the current directory. When directory creation fails, the underlying io::Error is now printed to stderr instead of being silently discarded (the Result signature is kept because the caller in src/main.rs matches on Err(()) and is out of scope for this change). Adds an integration test that runs the binary twice from a temporary working directory with a bare relative --database-path and asserts the database and its content survive the second run. --- src/utils.rs | 32 ++++++++++++++----- tests/init_integration_tests.rs | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 tests/init_integration_tests.rs diff --git a/src/utils.rs b/src/utils.rs index fb328dc6..f07d7e82 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,17 +1,35 @@ +use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; 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(); - if !db_dir.exists() { - if let Err(_e) = std::fs::create_dir_all(db_dir) { - return Err(()); - } - return Ok(true); + + // Decide whether this is a first run from the database file itself: relying on + // the parent directory is wrong for bare relative paths (e.g. `-d db.cotp`), + // whose parent is the empty path and never "exists", which previously caused an + // existing database to be re-initialized and overwritten. + if db_path.exists() { + return Ok(false); + } + + // First run: make sure the parent directory exists. An empty parent means the + // current working directory. + let db_dir = match db_path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + }; + if !db_dir.exists() + && let Err(e) = std::fs::create_dir_all(db_dir) + { + eprintln!( + "Cannot create the database directory {}: {e}", + db_dir.display() + ); + return Err(()); } - Ok(!db_path.exists()) + Ok(true) } pub fn millis_before_next_step() -> u64 { diff --git a/tests/init_integration_tests.rs b/tests/init_integration_tests.rs new file mode 100644 index 00000000..1276e0e5 --- /dev/null +++ b/tests/init_integration_tests.rs @@ -0,0 +1,55 @@ +#[cfg(not(target_os = "windows"))] // TODO, Integration tests currently does not work on Windows +mod init_integration_tests { + use assert_cmd::cargo::cargo_bin_cmd; + use assert_fs::TempDir; + use assert_fs::prelude::*; + use predicates::str::contains; + + const FIXTURE_DIR: &str = "test_samples/cli_integration_test"; + const FIXTURE_NAME: &str = "empty_database"; + const FIXTURE_PASSWORD: &str = "12345678"; + + /// Regression test: when `--database-path` is a bare relative filename, an + /// existing database must be loaded instead of being treated as a first run + /// (which used to prompt for a new password and overwrite it with an empty + /// database). + #[test] + fn existing_database_with_bare_relative_path_survives() { + // Arrange: put an existing populated-able database in a temp working dir + let temp = TempDir::new().unwrap(); + temp.copy_from(FIXTURE_DIR, &[FIXTURE_NAME]).unwrap(); + + // Act 1: first invocation with a bare relative -d filename adds an element + let mut command = cargo_bin_cmd!("cotp"); + let assertion = command + .current_dir(temp.path()) + .arg("--password-stdin") + .arg("--database-path") + .arg(FIXTURE_NAME) + .arg("add") + .arg("--label") + .arg("relative-path-test") + .arg("--secret-stdin") + .write_stdin(format!("{FIXTURE_PASSWORD}\nAA\n")) + .assert(); + assertion + .success() + .stdout(contains("Modifications have been persisted")); + + // Act 2: second invocation with the same bare relative -d filename must + // load the existing database (not re-initialize it) and still contain + // the element added by the first run + let mut command = cargo_bin_cmd!("cotp"); + let assertion = command + .current_dir(temp.path()) + .arg("--password-stdin") + .arg("--database-path") + .arg(FIXTURE_NAME) + .arg("list") + .write_stdin(format!("{FIXTURE_PASSWORD}\n")) + .assert(); + + // Assert + assertion.success().stdout(contains("relative-path-test")); + } +} From de32aec256ec7daa2fed1370bc0a1eab42be3abb Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:05:21 +0200 Subject: [PATCH 11/49] perf(crypto): compute Argon2 lanes in parallel for faster unlock KEY_DERIVATION_CONFIG declared lanes: 4 but ThreadMode::Sequential, so every database unlock computed the four 32 MiB lanes one after another on a single core. Switching to ThreadMode::Parallel lets rust-argon2 compute the lanes on separate threads, giving an expected 2-4x speedup of key derivation (and thus of every cotp startup) on multi-core machines. This is safe for existing databases: in Argon2 the lane count is part of the hash parameters, while the thread mode is purely an execution strategy of the implementation, so the derived key is bit-for-bit identical. This is verified by a new unit test whose expected key for a fixed password+salt was captured from the previous Sequential implementation before the change; the test still passes with Parallel. Deliberately not migrating to the RustCrypto argon2 crate here: it is single-threaded, which would forfeit exactly this multi-lane parallelism win. --- src/crypto/cryptography.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/crypto/cryptography.rs b/src/crypto/cryptography.rs index bb677daa..0a92b20f 100644 --- a/src/crypto/cryptography.rs +++ b/src/crypto/cryptography.rs @@ -18,7 +18,11 @@ const KEY_DERIVATION_CONFIG: Config = Config { secret: &[], ad: &[], hash_length: XCHACHA20_POLY1305_KEY_LENGTH as u32, - thread_mode: ThreadMode::Sequential, + // Parallel only changes the execution strategy: the derived key depends on + // the lane count (4), not on how many threads compute the lanes, so + // existing databases decrypt identically (see + // test_derived_key_unchanged_by_thread_mode). + thread_mode: ThreadMode::Parallel, }; pub fn argon_derive_key(password_bytes: &[u8], salt: &[u8]) -> color_eyre::Result> { @@ -101,6 +105,21 @@ mod tests { assert_eq!(String::from("Secret data@#[]ò"), decrypted); } + /// The expected value below was captured from the previous + /// `ThreadMode::Sequential` configuration. It must never change: the lane + /// count (4) is the Argon2 hash parameter, while the thread mode is only + /// the execution strategy, so switching to `ThreadMode::Parallel` must + /// derive the exact same key and keep existing databases decryptable. + #[test] + fn test_derived_key_unchanged_by_thread_mode() { + let key = argon_derive_key(b"pa$$w0rd", b"0123456789abcdef").unwrap(); + let hex: String = key.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!( + hex, + "1bae31857cc03a6fbb54f463a991e09e3294bdf3b8c44e4ddcec4ec1d7d6a4a7" + ); + } + #[test] fn test_decrypt_invalid_base64_nonce_returns_error() { let corrupted = From b7ac58061a85205d95da1602b1d6f4c0b218cf26 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:05:32 +0200 Subject: [PATCH 12/49] fix(tui): avoid u16 underflow panic on terminals shorter than 8 rows The main page layout computed Constraint::Length(height - 8) from the raw frame height; on a terminal shorter than 8 rows the u16 subtraction underflowed and panicked in debug builds while the terminal was in raw mode. Use height.saturating_sub(8) instead, and harden the same subtraction pattern in centered_rect() (100 - percent_x/percent_y) with saturating_sub as defense in depth against out-of-range percentages. --- src/interface/app.rs | 6 +++--- src/interface/popup.rs | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/interface/app.rs b/src/interface/app.rs index 3b7a22f8..abab86c7 100644 --- a/src/interface/app.rs +++ b/src/interface/app.rs @@ -154,9 +154,9 @@ impl<'a> App<'a> { .direction(Direction::Vertical) .constraints( [ - Constraint::Length(3), // Search bar - Constraint::Length(height - 8), // Table + Info Box - Constraint::Length(1), // Progress bar + Constraint::Length(3), // Search bar + Constraint::Length(height.saturating_sub(8)), // Table + Info Box + Constraint::Length(1), // Progress bar ] .as_ref(), ) diff --git a/src/interface/popup.rs b/src/interface/popup.rs index f542e345..a578cdde 100644 --- a/src/interface/popup.rs +++ b/src/interface/popup.rs @@ -7,9 +7,9 @@ pub(crate) fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { .direction(Direction::Vertical) .constraints( [ - Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(100u16.saturating_sub(percent_y) / 2), Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(100u16.saturating_sub(percent_y) / 2), ] .as_ref(), ) @@ -19,9 +19,9 @@ pub(crate) fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { .direction(Direction::Horizontal) .constraints( [ - Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(100u16.saturating_sub(percent_x) / 2), Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(100u16.saturating_sub(percent_x) / 2), ] .as_ref(), ) From cff4ccd3ccd30b403104caf229681df23f89da5f Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:05:33 +0200 Subject: [PATCH 13/49] fix(otp): reject zero period instead of panicking with division by zero A period of 0 was accepted everywhere an element can enter the database (add/edit flags, otpauth URIs with period=0, imports), but every time-based code generator divides the current Unix time by the period: totp_maker, yandex_otp_maker and motp_maker all computed seconds/period and panicked with a division by zero. In the TUI the panic fired on the very first render tick, crashing the dashboard while the terminal was in raw mode. Fix at both levels: - OTPElementBuilder::validate now rejects period == 0 (and digits == 0), so new elements built through the builder can never carry the invalid value. - The generators are hardened for elements that already exist in a database or bypass the builder: totp, yandex and motp return the new OtpError::InvalidPeriod instead of dividing by zero. motp's signature changes from String to Result; its only caller is OTPElement::get_otp_code which already returns that Result type. --- src/otp/algorithms/motp_maker.rs | 20 +++++-- src/otp/algorithms/totp_maker.rs | 3 + src/otp/algorithms/yandex_otp_maker.rs | 4 ++ src/otp/otp_element.rs | 59 ++++++++++++++++++- src/otp/otp_error.rs | 2 + .../cli_integration_test/empty_database | 2 +- 6 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/otp/algorithms/motp_maker.rs b/src/otp/algorithms/motp_maker.rs index 165cde7a..75b7b880 100644 --- a/src/otp/algorithms/motp_maker.rs +++ b/src/otp/algorithms/motp_maker.rs @@ -1,7 +1,9 @@ use md5::{Digest, Md5}; use std::time::SystemTime; -pub fn motp(secret: &str, pin: &str, period: u64, digits: usize) -> String { +use crate::otp::otp_error::OtpError; + +pub fn motp(secret: &str, pin: &str, period: u64, digits: usize) -> Result { let seconds = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() @@ -10,7 +12,17 @@ pub fn motp(secret: &str, pin: &str, period: u64, digits: usize) -> String { get_motp_code(secret, pin, period, digits, seconds) } -fn get_motp_code(secret: &str, pin: &str, period: u64, digits: usize, seconds: u64) -> String { +fn get_motp_code( + secret: &str, + pin: &str, + period: u64, + digits: usize, + seconds: u64, +) -> Result { + if period == 0 { + return Err(OtpError::InvalidPeriod); + } + // TODO MOTP Secrets are hex encoded, so do not use BASE32 at all let hex_secret = secret; let counter = seconds / period; @@ -19,7 +31,7 @@ fn get_motp_code(secret: &str, pin: &str, period: u64, digits: usize, seconds: u let mut md5_hasher = Md5::new(); md5_hasher.update(data.as_bytes()); let code = hex::encode(md5_hasher.finalize()); - code.as_str()[0..digits].to_owned() + Ok(code.as_str()[0..digits].to_owned()) } #[cfg(test)] @@ -33,7 +45,7 @@ mod tests { assert_eq!( "e7d8b6".to_string(), - get_motp_code("e3152afee62599c8", "1234", 10, 6, seconds) + get_motp_code("e3152afee62599c8", "1234", 10, 6, seconds).unwrap() ); } } diff --git a/src/otp/algorithms/totp_maker.rs b/src/otp/algorithms/totp_maker.rs index c82d4157..f2e9b9e4 100644 --- a/src/otp/algorithms/totp_maker.rs +++ b/src/otp/algorithms/totp_maker.rs @@ -20,6 +20,9 @@ fn generate_totp( time_step: u64, skew: i64, ) -> Result { + if time_step == 0 { + return Err(OtpError::InvalidPeriod); + } hotp(secret, algorithm, ((time as i64 + skew) as u64) / time_step) } diff --git a/src/otp/algorithms/yandex_otp_maker.rs b/src/otp/algorithms/yandex_otp_maker.rs index 1c0b6f16..a26b80b6 100644 --- a/src/otp/algorithms/yandex_otp_maker.rs +++ b/src/otp/algorithms/yandex_otp_maker.rs @@ -34,6 +34,10 @@ fn calculate_yandex_code( digits: usize, seconds: u64, ) -> Result { + if period == 0 { + return Err(OtpError::InvalidPeriod); + } + let decoded_secret = match BASE32_NOPAD.decode(secret.as_bytes()) { Ok(r) => r, Err(e) => return Err(OtpError::SecretEncoding(e.kind, e.position)), diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index c44cfa69..911d495e 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -214,12 +214,12 @@ impl OTPElement { None => Err(OtpError::MissingPin), }, OTPType::Motp => match &self.pin { - Some(pin) => Ok(motp( + Some(pin) => motp( &self.secret, pin.as_str(), self.period, self.digits as usize, - )), + ), None => Err(OtpError::MissingPin), }, } @@ -271,6 +271,14 @@ impl OTPElementBuilder { return Err(eyre!("Secret must not be empty",)); } + if self.period == Some(0) { + return Err(eyre!("Period must be greater than zero",)); + } + + if self.digits == Some(0) { + return Err(eyre!("Digits must be greater than zero",)); + } + // Validate secret encoding match self.type_.unwrap_or_default() { OTPType::Motp => hex::decode(self.secret.as_ref().unwrap()) @@ -449,6 +457,53 @@ mod test { assert_eq!("aaaf", result.unwrap().secret); } + #[test] + fn test_zero_period_is_rejected_by_builder() { + let result = OTPElementBuilder::default() + .secret("AA") + .label("label") + .issuer("") + .period(0u64) + .build(); + + assert_eq!( + "Period must be greater than zero", + result.unwrap_err().to_string() + ); + } + + #[test] + fn test_zero_digits_is_rejected_by_builder() { + let result = OTPElementBuilder::default() + .secret("AA") + .label("label") + .issuer("") + .digits(0u64) + .build(); + + assert_eq!( + "Digits must be greater than zero", + result.unwrap_err().to_string() + ); + } + + #[test] + fn test_zero_period_returns_error_instead_of_panicking() { + let element = OTPElement { + secret: "xr5gh44x7bprcqgrdtulafeevt5rxqlbh5wvked22re43dh2d4mapv5g".to_uppercase(), + issuer: String::from("IssuerText"), + label: String::from("LabelText"), + digits: 6, + type_: Totp, + algorithm: Sha1, + period: 0, + counter: None, + pin: None, + }; + + assert_eq!(Err(OtpError::InvalidPeriod), element.get_otp_code()); + } + #[test] fn invalid_secret_hex() { let result = OTPElementBuilder::default() diff --git a/src/otp/otp_error.rs b/src/otp/otp_error.rs index f8aefd9d..73c5ec7d 100644 --- a/src/otp/otp_error.rs +++ b/src/otp/otp_error.rs @@ -10,6 +10,7 @@ pub enum OtpError { InvalidOffset, // Invalid offset InvalidDigest, // Invalid digest InvalidDigits, // Invalid Digits value (too high or low) + InvalidPeriod, // Invalid period value (zero) } impl Display for OtpError { @@ -24,6 +25,7 @@ impl Display for OtpError { OtpError::InvalidOffset => f.write_str("Invalid offset"), OtpError::ShortSecret => f.write_str("Secret length less than 16 bytes"), OtpError::InvalidDigits => f.write_str("Digits value too high or low"), + OtpError::InvalidPeriod => f.write_str("Period value must be greater than zero"), } } } diff --git a/test_samples/cli_integration_test/empty_database b/test_samples/cli_integration_test/empty_database index 56c86801..0e214214 100644 --- a/test_samples/cli_integration_test/empty_database +++ b/test_samples/cli_integration_test/empty_database @@ -1 +1 @@ -{"version":1,"nonce":"MRFGJOdHjt9wrSbqlWHrwmMGENmjjCMh","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"tBtvz2AOp7XPUJGSLkm+Ss+exKFjd0eA1UWd58nM/I45P7VmdK6/28c9vw=="} \ No newline at end of file +{"version":1,"nonce":"VovAqi9NTJutd8JL1hOMgnhb4LIoqQTW","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"VqWOlDFoO0ETXFpmPstjxN3AduUPRrOPSjlYr/4ONYf4CnVqv1cMQRfRN5fYop/fzRdE9BeumghpT/gWendEhvJTmtF74VP4BW8LW1X8bJFqXJJg53PX2hCcDesiX59XrpN9QttSoaePfqcRSOiry/D4H8VqC+v6BmmE3DFKEJaJZrZMuNVO6M/Ri40mohbvqMfJMWqAvyNDUXOD2kxdaH9s+yqdTrU="} \ No newline at end of file From c49c2a30097a40d9c73b18cfb62693a35484ff44 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:05:49 +0200 Subject: [PATCH 14/49] fix(import): reject malformed encrypted Aegis backups instead of panicking The encrypted Aegis importer used unwrap()/expect() on untrusted backup contents: slot.n/p/r and slot.salt could be absent on a type-1 slot, and the nonce, tag, key and salt hex strings could be non-hex. Any such malformed (or maliciously crafted) file crashed cotp with a panic instead of reporting an invalid backup. In addition, the scrypt cost parameter was converted with (n as f32).log2() as u8, which silently truncates a non-power-of-two n, derives a key with the wrong cost and surfaces as a misleading "wrong password" decryption failure. Trigger: run cotp import --aegis-encrypted with a backup whose slot is missing n/r/p/salt or contains non-hex nonce/tag/key/salt fields. Fix: convert every unwrap/expect on backup data into a descriptive Err through the existing String error path, validate n.is_power_of_two() and derive log2(n) with n.trailing_zeros(). Adds unit tests feeding malformed slot JSON and asserting an Err is returned instead of a panic. --- src/importers/aegis_encrypted.rs | 129 ++++++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 10 deletions(-) diff --git a/src/importers/aegis_encrypted.rs b/src/importers/aegis_encrypted.rs index 2132663a..d66f8e10 100644 --- a/src/importers/aegis_encrypted.rs +++ b/src/importers/aegis_encrypted.rs @@ -62,14 +62,14 @@ impl TryFrom for Vec { master_key.zeroize(); let nonce_bytes = Vec::from_hex(&aegis_encrypted.header.params.nonce) - .expect("Failed to parse hex nonce"); + .map_err(|e| format!("Failed to parse hex nonce: {e:?}"))?; 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"), + .map_err(|e| format!("Failed to parse hex tag: {e:?}"))?, ] .concat(); @@ -113,16 +113,23 @@ fn map_results(decrypted_db: Vec) -> Result, String> { } fn get_params(slot: &AegisEncryptedSlot) -> Result { - let n = slot.n.unwrap(); - let p = slot.p.unwrap(); - let r = slot.r.unwrap(); + let n = slot.n.ok_or("Missing scrypt parameter n in backup slot")?; + let p = slot.p.ok_or("Missing scrypt parameter p in backup slot")?; + let r = slot.r.ok_or("Missing scrypt parameter r in backup slot")?; - Params::new((n as f32).log2() as u8, r, p) + if !n.is_power_of_two() { + return Err(format!( + "Invalid scrypt parameter n: {n} is not a power of two" + )); + } + + Params::new(n.trailing_zeros() as u8, r, p) .map_err(|e| format!("Error during scrypt params creation: {e:?}")) } 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 salt_hex = slot.salt.as_ref().ok_or("Missing salt in backup slot")?; + let salt = Vec::from_hex(salt_hex).map_err(|e| format!("Failed to parse hex salt: {e:?}"))?; let mut output: [u8; 32] = [0; 32]; let params = get_params(slot)?; @@ -139,12 +146,14 @@ fn calc_master_key(slot: &AegisEncryptedSlot, password: &str) -> Result, 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"), + Vec::from_hex(&slot.key).map_err(|e| format!("Failed to parse hex key: {e:?}"))?, + Vec::from_hex(&slot.key_params.tag) + .map_err(|e| format!("Failed to parse hex tag: {e:?}"))?, ] .concat(); - let nonce_bytes = Vec::from_hex(&slot.key_params.nonce).expect("Failed to parse hex nonce"); + let nonce_bytes = Vec::from_hex(&slot.key_params.nonce) + .map_err(|e| format!("Failed to parse hex nonce: {e:?}"))?; let nonce = Nonce::::try_from(nonce_bytes.as_slice()) .map_err(|e| format!("Invalid nonce length: {e:?}"))?; @@ -152,3 +161,103 @@ fn calc_master_key(slot: &AegisEncryptedSlot, password: &str) -> Result, .decrypt(&nonce, cipher_text.as_slice()) .map_err(|e| format!("Failed to derive master key: {e:?}")) } + +#[cfg(test)] +mod tests { + use super::{AegisEncryptedSlot, calc_master_key, get_params}; + + fn slot_from_json(json: &str) -> AegisEncryptedSlot { + serde_json::from_str(json).expect("Invalid test slot JSON") + } + + #[test] + fn missing_scrypt_params_return_error() { + let slot = slot_from_json( + r#"{ + "type": 1, + "key": "00", + "key_params": {"nonce": "00", "tag": "00"}, + "salt": "00" + }"#, + ); + + let result = calc_master_key(&slot, "password"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Missing scrypt parameter")); + } + + #[test] + fn missing_salt_returns_error() { + let slot = slot_from_json( + r#"{ + "type": 1, + "key": "00", + "key_params": {"nonce": "00", "tag": "00"}, + "n": 2, + "r": 8, + "p": 1 + }"#, + ); + + let result = calc_master_key(&slot, "password"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Missing salt")); + } + + #[test] + fn non_power_of_two_n_returns_error() { + let slot = slot_from_json( + r#"{ + "type": 1, + "key": "00", + "key_params": {"nonce": "00", "tag": "00"}, + "n": 15000, + "r": 8, + "p": 1, + "salt": "00" + }"#, + ); + + let result = get_params(&slot); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not a power of two")); + } + + #[test] + fn non_hex_salt_returns_error() { + let slot = slot_from_json( + r#"{ + "type": 1, + "key": "00", + "key_params": {"nonce": "00", "tag": "00"}, + "n": 2, + "r": 8, + "p": 1, + "salt": "not-hex" + }"#, + ); + + let result = calc_master_key(&slot, "password"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to parse hex salt")); + } + + #[test] + fn non_hex_key_returns_error() { + let slot = slot_from_json( + r#"{ + "type": 1, + "key": "zz", + "key_params": {"nonce": "00", "tag": "00"}, + "n": 2, + "r": 8, + "p": 1, + "salt": "00" + }"#, + ); + + let result = calc_master_key(&slot, "password"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to parse hex key")); + } +} From 0e0cf7ad5e519f21839b65e4f591c0fd57a383e4 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:05:51 +0200 Subject: [PATCH 15/49] fix(clipboard): emit OSC52 escape sequence on stderr instead of stdout The SSH clipboard path wrote the OSC52 escape sequence (with the base64-encoded secret) to io::stdout(). The TUI dashboard deliberately runs on io::stderr() so that stdout stays clean for piping; writing the escape to stdout polluted piped output with the raw escape bytes and broke that contract. Write the sequence to io::stderr() instead - the terminal the TUI owns - which is where terminals interpret the OSC52 sequence anyway when the alternate screen is active. --- src/clipboard.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/clipboard.rs b/src/clipboard.rs index 2efea28d..4e60816d 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -27,8 +27,9 @@ 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 + // Write to stderr: the TUI owns stderr, while stdout must stay clean for piping && crossterm::execute!( - io::stdout(), + io::stderr(), Print(format!( "\x1B]52;c;{}\x07", general_purpose::STANDARD.encode(content) From 59a3518e8f291492a7414b429a394d61c97e6af5 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:06:36 +0200 Subject: [PATCH 16/49] perf(tui): cache the rendered QR code instead of rebuilding it every tick render_qrcode_page called element.get_qrcode() on every render, i.e. on every 250ms tick and every key event while the QR page was open. Each call rebuilds the otpauth URI, recomputes the QR matrix and re-renders it to a unicode string, four times per second for a static image. Cache the rendered string in App keyed by the selected element index and reuse it while the same element stays selected. The cache is invalidated when the selection changes (different index), when the table is refreshed (App::tick force/period refresh, which covers HOTP counter changes and deletions) and when leaving the page via App::reset(). get_qrcode() itself is unchanged. --- src/interface/app.rs | 74 +++++++++++++++++++++++++++----------------- 1 file changed, 46 insertions(+), 28 deletions(-) diff --git a/src/interface/app.rs b/src/interface/app.rs index abab86c7..1dffc5f7 100644 --- a/src/interface/app.rs +++ b/src/interface/app.rs @@ -41,6 +41,10 @@ pub struct App<'a> { /// Info text in the `QRCode` page pub(crate) qr_code_page_label: &'static str, + + /// Cached rendered QR code for the `QRCode` page, keyed by the index of + /// the element it was generated from + qrcode_cache: Option<(usize, String)>, } pub struct Popup { @@ -75,6 +79,7 @@ impl<'a> App<'a> { percent_y: 20, }, qr_code_page_label: DEFAULT_QRCODE_LABEL, + qrcode_cache: None, } } @@ -82,6 +87,7 @@ impl<'a> App<'a> { self.current_page = Page::default(); self.print_percentage = true; self.qr_code_page_label = DEFAULT_QRCODE_LABEL; + self.qrcode_cache = None; } /// Handles the tick event of the terminal. @@ -93,6 +99,9 @@ impl<'a> App<'a> { // Update codes self.table.items.clear(); fill_table(&mut self.table, self.database.elements_ref()); + // Elements may have changed (e.g. HOTP counter increment or + // deletion), so the cached QR code may be stale + self.qrcode_cache = None; } self.progress = new_progress; } @@ -105,37 +114,46 @@ impl<'a> App<'a> { } } - fn render_qrcode_page(&self, frame: &mut Frame<'_>) { - let paragraph = self + fn render_qrcode_page(&mut self, frame: &mut Frame<'_>) { + let selected_index = self .table .state .selected() - .and_then(|index| self.database.elements_ref().get(index)) - .map_or_else( - || { - Paragraph::new("No element is selected") - .block(Block::default().title("Nope").borders(Borders::ALL)) - .style(Style::default().fg(Color::White).bg(Color::Reset)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }) - }, - |element| { - let title = if element.label.is_empty() { - element.issuer.clone() - } else { - format!("{} - {}", element.issuer, element.label) - }; - Paragraph::new(format!( - "{}\n{}", - element.get_qrcode(), - self.qr_code_page_label - )) - .block(Block::default().title(title).borders(Borders::ALL)) - .style(Style::default().fg(Color::White).bg(Color::Reset)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }) - }, - ); + .filter(|index| *index < self.database.elements_ref().len()); + + let paragraph = if let Some(index) = selected_index { + // Building the QR code (URI + matrix + unicode rendering) is + // expensive, so cache the rendered string and rebuild it only + // when the selection changes + let cache_is_valid = + matches!(&self.qrcode_cache, Some((cached, _)) if *cached == index); + if !cache_is_valid { + let qrcode = self.database.elements_ref()[index].get_qrcode(); + self.qrcode_cache = Some((index, qrcode)); + } + let element = &self.database.elements_ref()[index]; + let qrcode = self + .qrcode_cache + .as_ref() + .map(|(_, qrcode)| qrcode.as_str()) + .unwrap_or_default(); + let title = if element.label.is_empty() { + element.issuer.clone() + } else { + format!("{} - {}", element.issuer, element.label) + }; + Paragraph::new(format!("{}\n{}", qrcode, self.qr_code_page_label)) + .block(Block::default().title(title).borders(Borders::ALL)) + .style(Style::default().fg(Color::White).bg(Color::Reset)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) + } else { + Paragraph::new("No element is selected") + .block(Block::default().title("Nope").borders(Borders::ALL)) + .style(Style::default().fg(Color::White).bg(Color::Reset)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) + }; Self::render_paragraph(frame, paragraph); } From 0936606becc106ab7f3a636e049dba79286d454c Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:06:41 +0200 Subject: [PATCH 17/49] fix(add): make the conditional clap rules for hotp/steam/yandex/motp actually fire All conditional rules on the add subcommand were dead: - required_if_eq("otp_type", "HOTP") (and YANDEX/MOTP) compared against uppercase values, but ValueEnum possible values are lowercase ("hotp", "yandex", "motp"), so the predicates never matched. - default_value_if("type", "STEAM", "5") referenced a nonexistent arg id (the field id is otp_type; "type" is only the long flag name) on top of the same case mismatch. Consequences: `add -t hotp` succeeded without --counter (the element then renders ERROR and the TUI +/- counter keys panic), `add -t steam` produced 6-digit codes while Steam codes are 5 digits (every generated code invalid), and yandex/motp entries added without --pin were permanently broken. Fix the arg ids and value casing so hotp requires --counter, yandex and motp require --pin, and steam defaults digits to 5. Additionally add a post-parse backstop (validate_type_invariants) in the execution path so the invariants still hold even if the declarative clap rules silently rot again, plus unit tests covering both the clap rules and the backstop. --- src/arguments/add.rs | 130 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 126 insertions(+), 4 deletions(-) diff --git a/src/arguments/add.rs b/src/arguments/add.rs index 030978b3..039c82d2 100644 --- a/src/arguments/add.rs +++ b/src/arguments/add.rs @@ -41,7 +41,7 @@ pub struct AddArgs { short, long, default_value_t = 6, - default_value_if("type", "STEAM", "5"), + default_value_if("otp_type", "steam", "5"), value_parser=value_parser!(u64).range(1..=10) )] pub digits: u64, @@ -51,15 +51,15 @@ pub struct AddArgs { pub period: u64, /// HOTP counter - #[arg(short, long, required_if_eq("otp_type", "HOTP"))] + #[arg(short, long, required_if_eq("otp_type", "hotp"))] pub counter: Option, /// Yandex / MOTP pin #[arg( short, long, - required_if_eq("otp_type", "YANDEX"), - required_if_eq("otp_type", "MOTP") + required_if_eq("otp_type", "yandex"), + required_if_eq("otp_type", "motp") )] pub pin: Option, @@ -84,7 +84,24 @@ impl SubcommandExecutor for AddArgs { } } +/// Backstop for the conditional clap rules above: enforce the per-type +/// invariants even if the declarative rules stop firing (e.g. because of an +/// arg id or value-case mismatch, which silently disables them). +fn validate_type_invariants(matches: &AddArgs) -> color_eyre::Result<()> { + match matches.otp_type { + OTPType::Hotp if matches.counter.is_none() => { + Err(eyre::eyre!("--counter is required for HOTP codes")) + } + OTPType::Yandex | OTPType::Motp if matches.pin.is_none() => Err(eyre::eyre!( + "--pin is required for {} codes", + matches.otp_type + )), + _ => Ok(()), + } +} + fn get_from_args(matches: AddArgs) -> color_eyre::Result { + validate_type_invariants(&matches)?; let secret = if matches.take_secret_from_stdin { if let Some(password) = io::stdin().lock().lines().next() { password.map_err(ErrReport::from) @@ -110,3 +127,108 @@ fn map_args_to_code(secret: String, matches: AddArgs) -> Result { .pin(matches.pin) .build() } + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::{AddArgs, validate_type_invariants}; + use crate::otp::otp_type::OTPType; + + #[derive(Parser)] + struct TestParser { + #[command(flatten)] + args: AddArgs, + } + + fn parse(args: &[&str]) -> Result { + TestParser::try_parse_from(args).map(|parsed| parsed.args) + } + + #[test] + fn hotp_without_counter_is_rejected() { + let result = parse(&["add", "-l", "label", "-t", "hotp"]); + assert!(result.is_err()); + assert_eq!( + result.err().unwrap().kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn hotp_with_counter_is_accepted() { + let args = parse(&["add", "-l", "label", "-t", "hotp", "-c", "42"]).unwrap(); + assert_eq!(args.counter, Some(42)); + } + + #[test] + fn yandex_without_pin_is_rejected() { + let result = parse(&["add", "-l", "label", "-t", "yandex"]); + assert!(result.is_err()); + assert_eq!( + result.err().unwrap().kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn motp_without_pin_is_rejected() { + let result = parse(&["add", "-l", "label", "-t", "motp"]); + assert!(result.is_err()); + assert_eq!( + result.err().unwrap().kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn yandex_with_pin_is_accepted() { + let args = parse(&["add", "-l", "label", "-t", "yandex", "-p", "5678"]).unwrap(); + assert_eq!(args.pin.as_deref(), Some("5678")); + } + + #[test] + fn steam_defaults_to_five_digits() { + let args = parse(&["add", "-l", "label", "-t", "steam"]).unwrap(); + assert_eq!(args.digits, 5); + } + + #[test] + fn steam_explicit_digits_are_kept() { + let args = parse(&["add", "-l", "label", "-t", "steam", "-d", "7"]).unwrap(); + assert_eq!(args.digits, 7); + } + + #[test] + fn totp_defaults_to_six_digits() { + let args = parse(&["add", "-l", "label"]).unwrap(); + assert_eq!(args.digits, 6); + assert_eq!(args.otp_type, OTPType::Totp); + } + + #[test] + fn backstop_rejects_hotp_without_counter() { + let mut args = parse(&["add", "-l", "label", "-t", "hotp", "-c", "42"]).unwrap(); + // Simulate the clap rule rotting away again + args.counter = None; + assert!(validate_type_invariants(&args).is_err()); + } + + #[test] + fn backstop_rejects_yandex_and_motp_without_pin() { + for otp_type in ["yandex", "motp"] { + let mut args = parse(&["add", "-l", "label", "-t", otp_type, "-p", "1234"]).unwrap(); + // Simulate the clap rule rotting away again + args.pin = None; + assert!(validate_type_invariants(&args).is_err()); + } + } + + #[test] + fn backstop_accepts_valid_combinations() { + let args = parse(&["add", "-l", "label", "-t", "hotp", "-c", "1"]).unwrap(); + assert!(validate_type_invariants(&args).is_ok()); + let args = parse(&["add", "-l", "label"]).unwrap(); + assert!(validate_type_invariants(&args).is_ok()); + } +} From 6ababed58141bae118815fd6df33b5b2ac8f858e Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:06:47 +0200 Subject: [PATCH 18/49] fix(otp): stop percent-decoding the whole otpauth URI before parsing from_otp_uri ran urlencoding::decode over the entire URI and only then handed it to Url::parse. Any percent-encoded structural character in a label or query value therefore corrupted parsing: - otpauth://totp/C%23Corp?secret=... failed entirely, because "%23" became "#" and the whole query turned into a URL fragment - "%3F" in a label became "?" and truncated the label / shifted the query, "%26" in a value became "&" and split the query pair - every value was effectively decoded twice (the pre-decode plus Url::query_pairs, which decodes again), so labels containing a literal "%25" decoded to "%" instead of "%25" Fix: parse the RAW URI with Url::parse and percent-decode only the individual components. Query values are decoded exactly once by Url::query_pairs; the first path segment is decoded once before being split on ':' into issuer and label (decoding before splitting preserves the GH-548 behavior of treating an encoded %3A as the separator). Also renames the meaningless helper fn get() to issuer_label_segments() and adds regression tests for labels containing %23, %3F, %26, %25 and a double-encoded literal %25 (%2525). --- src/otp/from_otp_uri.rs | 111 ++++++++++++++---- .../cli_integration_test/empty_database | 2 +- 2 files changed, 89 insertions(+), 24 deletions(-) diff --git a/src/otp/from_otp_uri.rs b/src/otp/from_otp_uri.rs index 04400ff1..9195d355 100644 --- a/src/otp/from_otp_uri.rs +++ b/src/otp/from_otp_uri.rs @@ -9,8 +9,13 @@ pub trait FromOtpUri: Sized { 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)?; + // Parse the raw URI: percent-decoding must only ever happen on the + // individual components. Decoding the whole URI up front turns encoded + // structural characters into real ones (e.g. "%23" -> "#" makes the + // rest of the URI a fragment, "%26" -> "&" splits a query value) and + // decodes every query value twice, corrupting values that contain a + // literal "%25". + let parsed_uri = Url::parse(otp_uri).map_err(ErrReport::from)?; let otp_type = parsed_uri .host_str() @@ -58,36 +63,34 @@ impl FromOtpUri for OTPElement { } } -fn get(parsed_uri: &Url) -> color_eyre::Result> { - let first_segment: Vec = parsed_uri +/// Extracts the "issuer:label" parts from the first path segment of the URI. +/// +/// The raw segment is percent-decoded first, then split on ':'. Decoding +/// before splitting keeps the historical behavior of treating an encoded +/// colon ("%3A") as the issuer/label separator (see GH issue 548). +fn issuer_label_segments(parsed_uri: &Url) -> color_eyre::Result> { + let raw_segment = 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"))? + .next() + .ok_or(ErrReport::msg("No path segments found"))?; + + let decoded = urlencoding::decode(raw_segment) + .map_err(ErrReport::from)? + .into_owned(); + + Ok(decoded .split(':') - .collect::>() - .into_iter() .map(std::borrow::ToOwned::to_owned) - .collect(); - Ok(first_segment) + .collect()) } 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_segment = issuer_label_segments(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() - }); + let first = first_segment.first().cloned(); + let second = first_segment.get(1).cloned(); match (first, second) { (Some(i), Some(l)) => Ok((i, l)), @@ -102,3 +105,65 @@ fn get_issuer_and_label(parsed_uri: &Url) -> color_eyre::Result<(String, String) _ => Err(ErrReport::msg("No label found in OTP uri")), } } + +#[cfg(test)] +mod tests { + use super::FromOtpUri; + use crate::otp::otp_element::OTPElement; + + #[test] + fn test_encoded_hash_in_label_does_not_break_query_parsing() { + // "%23" must stay part of the label; pre-decoding the whole URI turned + // it into "#", making everything after it a fragment and losing the + // secret. + let uri = "otpauth://totp/C%23Corp?secret=JBSWY3DPEHPK3PXP"; + + let element = OTPElement::from_otp_uri(uri).unwrap(); + + assert_eq!("C#Corp", element.label); + assert_eq!("JBSWY3DPEHPK3PXP", element.secret); + } + + #[test] + fn test_encoded_question_mark_in_label_does_not_break_query_parsing() { + let uri = "otpauth://totp/Que%3FStion?secret=JBSWY3DPEHPK3PXP"; + + let element = OTPElement::from_otp_uri(uri).unwrap(); + + assert_eq!("Que?Stion", element.label); + assert_eq!("JBSWY3DPEHPK3PXP", element.secret); + } + + #[test] + fn test_encoded_ampersand_in_label_and_query_value() { + let uri = "otpauth://totp/A%26B?secret=JBSWY3DPEHPK3PXP&issuer=C%26D"; + + let element = OTPElement::from_otp_uri(uri).unwrap(); + + assert_eq!("A&B", element.label); + assert_eq!("C&D", element.issuer); + assert_eq!("JBSWY3DPEHPK3PXP", element.secret); + } + + #[test] + fn test_percent_encoded_label_is_decoded_exactly_once() { + // Label text "50%off" is encoded as "50%25off" and must not be + // decoded twice. + let uri = "otpauth://totp/50%25off?secret=JBSWY3DPEHPK3PXP"; + + let element = OTPElement::from_otp_uri(uri).unwrap(); + + assert_eq!("50%off", element.label); + } + + #[test] + fn test_double_encoded_label_keeps_literal_percent_sequence() { + // Label text literally containing "%25" is encoded as "%2525" and + // must decode back to "%25", not to "%". + let uri = "otpauth://totp/x%2525y?secret=JBSWY3DPEHPK3PXP"; + + let element = OTPElement::from_otp_uri(uri).unwrap(); + + assert_eq!("x%25y", element.label); + } +} diff --git a/test_samples/cli_integration_test/empty_database b/test_samples/cli_integration_test/empty_database index 0e214214..1612ff3f 100644 --- a/test_samples/cli_integration_test/empty_database +++ b/test_samples/cli_integration_test/empty_database @@ -1 +1 @@ -{"version":1,"nonce":"VovAqi9NTJutd8JL1hOMgnhb4LIoqQTW","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"VqWOlDFoO0ETXFpmPstjxN3AduUPRrOPSjlYr/4ONYf4CnVqv1cMQRfRN5fYop/fzRdE9BeumghpT/gWendEhvJTmtF74VP4BW8LW1X8bJFqXJJg53PX2hCcDesiX59XrpN9QttSoaePfqcRSOiry/D4H8VqC+v6BmmE3DFKEJaJZrZMuNVO6M/Ri40mohbvqMfJMWqAvyNDUXOD2kxdaH9s+yqdTrU="} \ No newline at end of file +{"version":1,"nonce":"FtWq1p8us2kNJ3mnRRw5DjwUWIsjWLn0","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"v8ZQD+OPI7MscnRKnsekGc2QbITzYdU5eYG0TbH+FYiHjR+N+pdybOAIK7Y1jBJv6xSoMmavwzfme9yngZE1mDjO/3IE+NIKMPE8cV0C8H0IwX6wS2LufvLYqC1Ihg0pmuDXqUaZhe0GH998O6RpNialD40n2vNL9O+WjxWbxosF3HeWtB3lauQl/e8k29DlML2VkZ8Fd6boYodntT/g0I9wnUHgg/DvAX1k0RT2dP5SA1srAHIk0YaOehhRuoo6mqHstgEMbj3fe/1AS/JDxvMlHtQBLq3wEDft2LkPuInF7W6QYRjwBjOWSjLvhWMkisXelQ8/Gm/7/aN3xpLmDrf7CPUqe6tGJf/NNkhWcqyggyqFSxso9Y9QcRkDlZsol2uv0A=="} \ No newline at end of file From 238d6c7551933dfd72753ac9ec7f10dd085ec74f Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:06:55 +0200 Subject: [PATCH 19/49] refactor(import): move Aegis password prompt out of the conversion layer The TryFrom for Vec impl called the blocking terminal prompt utils::password() inside a pure conversion trait. That coupled decryption to an interactive terminal, making the whole encrypted-Aegis import path impossible to unit test and inconsistent with the rest of the codebase, where interactive decisions live in the argument layer. Fix: replace the TryFrom impl with an explicit AegisEncryptedDatabase::decrypt(self, password) -> Result, String> method, and move the prompting into a dedicated import_aegis_encrypted() branch in arguments/import.rs, which reads the file, deserializes it, asks for the password (zeroizing it after use, as before) and delegates to decrypt(). Adds a unit test invoking decrypt() on a backup with no usable key slot and asserting a graceful error. No happy-path decryption test is added because no encrypted Aegis fixture exists in test_samples/. --- src/arguments/import.rs | 27 +++++++++++++++++- src/importers/aegis_encrypted.rs | 48 ++++++++++++++++++++++++-------- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/arguments/import.rs b/src/arguments/import.rs index c9273e46..6e4ee0c5 100644 --- a/src/arguments/import.rs +++ b/src/arguments/import.rs @@ -1,7 +1,9 @@ +use std::fs::read_to_string; use std::path::PathBuf; use clap::Args; use color_eyre::eyre::eyre; +use zeroize::Zeroize; use crate::{ exporters::otp_uri::OtpUriList, @@ -12,6 +14,7 @@ use crate::{ importer::import_from_path, }, otp::otp_element::{OTPDatabase, OTPElement}, + utils, }; use super::SubcommandExecutor; @@ -88,7 +91,7 @@ impl SubcommandExecutor for ImportArgs { } else if backup_type.aegis { import_from_path::(path) } else if backup_type.aegis_encrypted { - import_from_path::(path) + import_aegis_encrypted(path) } else if backup_type.freeotp_plus { import_from_path::(path) } else if backup_type.authy_exported { @@ -109,3 +112,25 @@ impl SubcommandExecutor for ImportArgs { Ok(database) } } + +/// Imports an encrypted Aegis backup, prompting the user for the backup +/// password before decrypting it. +fn import_aegis_encrypted(path: PathBuf) -> color_eyre::Result> { + let json = read_to_string(path)?; + let encrypted: AegisEncryptedDatabase = serde_json::from_str(json.as_str()).map_err(|e| { + eyre!( + "Invalid JSON import format. + Please check the file you are trying to import. For further information please check these guidelines: + https://github.com/replydev/cotp?tab=readme-ov-file#migration-from-other-apps + + Specific error: {:?}", + e + ) + })?; + + let mut password = utils::password("Insert your Aegis password: ", 0); + let result = encrypted.decrypt(password.as_str()); + password.zeroize(); + + result.map_err(|e| eyre!("{e}")) +} diff --git a/src/importers/aegis_encrypted.rs b/src/importers/aegis_encrypted.rs index d66f8e10..ec7375ba 100644 --- a/src/importers/aegis_encrypted.rs +++ b/src/importers/aegis_encrypted.rs @@ -6,7 +6,6 @@ use serde::Deserialize; use zeroize::Zeroize; use crate::otp::otp_element::OTPElement; -use crate::utils; use scrypt::{Params, scrypt}; use super::aegis::AegisDb; @@ -43,32 +42,30 @@ struct AegisEncryptedSlot { //repaired: Option, } -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(); +impl AegisEncryptedDatabase { + /// Decrypts the backup contents using the given password and maps the + /// entries into `OTPElement` values. + pub fn decrypt(self, password: &str) -> Result, String> { + let master_key: Option> = get_master_key(&self, password); match master_key { Some(mut master_key) => { let content = BASE64 - .decode(aegis_encrypted.db.as_bytes()) + .decode(self.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) + let nonce_bytes = Vec::from_hex(&self.header.params.nonce) .map_err(|e| format!("Failed to parse hex nonce: {e:?}"))?; 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) + Vec::from_hex(&self.header.params.tag) .map_err(|e| format!("Failed to parse hex tag: {e:?}"))?, ] .concat(); @@ -164,12 +161,39 @@ fn calc_master_key(slot: &AegisEncryptedSlot, password: &str) -> Result, #[cfg(test)] mod tests { - use super::{AegisEncryptedSlot, calc_master_key, get_params}; + use super::{AegisEncryptedDatabase, AegisEncryptedSlot, calc_master_key, get_params}; fn slot_from_json(json: &str) -> AegisEncryptedSlot { serde_json::from_str(json).expect("Invalid test slot JSON") } + #[test] + fn decrypt_with_no_usable_slot_returns_error() { + // The only type-1 slot is malformed (missing scrypt parameters), so no + // master key can be derived and decrypt must fail gracefully. + let database: AegisEncryptedDatabase = serde_json::from_str( + r#"{ + "version": 1, + "header": { + "slots": [ + { + "type": 1, + "key": "00", + "key_params": {"nonce": "00", "tag": "00"}, + "salt": "00" + } + ], + "params": {"nonce": "00", "tag": "00"} + }, + "db": "AAAA" + }"#, + ) + .expect("Invalid test database JSON"); + + let result = database.decrypt("password"); + assert_eq!(Err("Failed to derive master key".to_string()), result); + } + #[test] fn missing_scrypt_params_return_error() { let slot = slot_from_json( From 1566d0b1c22a7052dc6fd71bb71c9d0ed296a29c Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:07:19 +0200 Subject: [PATCH 20/49] fix(extract): treat --index as 1-based like every other command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extract compared the user-supplied --index directly against the 0-based enumerate() position, while list, edit, delete and the TUI all present and accept 1-based indexes. As a result `extract --index 1` silently returned the SECOND element's OTP code — off by one from what the user sees in `list` — and `extract --index 0` returned the first element instead of being rejected. The index filter now subtracts 1 before comparing against the array position (i.checked_sub(1) == Some(index)), and --index 0 is rejected with an explicit error explaining that indexes are 1-based. Unit tests cover 1-based selection, out-of-range indexes and the index-0 rejection. --- src/arguments/extract.rs | 89 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/src/arguments/extract.rs b/src/arguments/extract.rs index 0f271303..a2bd3377 100644 --- a/src/arguments/extract.rs +++ b/src/arguments/extract.rs @@ -36,6 +36,12 @@ impl TryFrom for ExtractFilterGlob { type Error = color_eyre::eyre::ErrReport; fn try_from(value: ExtractArgs) -> Result { + if value.index == Some(0) { + return Err(eyre!( + "Invalid index 0: indexes are 1-based, use --index 1 for the first code" + )); + } + let issuer_glob = if let Some(issuer) = value.issuer { Some(create_matcher(&issuer)?) } else { @@ -96,7 +102,9 @@ fn find_match(otp_database: &OTPDatabase, globbed: ExtractFilterGlob) -> Option< } fn filter_extract(args: &ExtractFilterGlob, index: usize, candidate: &OTPElement) -> bool { - let match_by_index = args.index.is_none_or(|i| i == index); + // The user-facing index is 1-based (like list, edit, delete and the TUI), + // while `index` here is the 0-based position in the database + let match_by_index = args.index.is_none_or(|i| i.checked_sub(1) == Some(index)); let match_by_issuer = args .issuer_glob @@ -293,6 +301,85 @@ mod tests { assert!(found_match.is_none()); } + #[test] + fn test_index_filtering_is_one_based() { + // Arrange + let mut otp_database = OTPDatabase::default(); + otp_database.add_element( + OTPElementBuilder::default() + .issuer("first-issuer") + .label("first-label") + .secret("AA") + .build() + .unwrap(), + ); + + otp_database.add_element( + OTPElementBuilder::default() + .issuer("second-issuer") + .label("second-label") + .secret("AA") + .build() + .unwrap(), + ); + + // Act / Assert: --index 1 must return the FIRST element + let filter = ExtractArgs { + index: Some(1), + ..Default::default() + }; + let found_match = find_match(&otp_database, filter.try_into().unwrap()); + assert_eq!("first-issuer", found_match.unwrap().issuer); + + // Act / Assert: --index 2 must return the SECOND element + let filter = ExtractArgs { + index: Some(2), + ..Default::default() + }; + let found_match = find_match(&otp_database, filter.try_into().unwrap()); + assert_eq!("second-issuer", found_match.unwrap().issuer); + } + + #[test] + fn test_index_out_of_range_matches_nothing() { + // Arrange + let mut otp_database = OTPDatabase::default(); + otp_database.add_element( + OTPElementBuilder::default() + .issuer("first-issuer") + .label("first-label") + .secret("AA") + .build() + .unwrap(), + ); + + let filter = ExtractArgs { + index: Some(2), + ..Default::default() + }; + + // Act + let found_match = find_match(&otp_database, filter.try_into().unwrap()); + + // Assert + assert!(found_match.is_none()); + } + + #[test] + fn test_index_zero_is_rejected() { + // Arrange + let filter = ExtractArgs { + index: Some(0), + ..Default::default() + }; + + // Act + let result: Result = filter.try_into(); + + // Assert + assert!(result.is_err()); + } + #[test] fn test_glob_filtering_case_insensitive() { // Arrange From 76b065cb8f522ae77d3cc6e6b0daeca27e10f6da Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:07:23 +0200 Subject: [PATCH 21/49] fix(import): zeroize decrypted Aegis backup plaintext after parsing After decrypting an encrypted Aegis backup, map_results() converted the plaintext into a String containing every OTP secret in the backup and dropped it without zeroization, leaving the secrets recoverable from freed memory. The raw bytes kept inside the FromUtf8Error on the invalid-utf8 path leaked the same way. Trigger: any successful (or utf8-invalid) cotp import --aegis-encrypted run. Fix: zeroize the plaintext JSON String once parsing is done, and zeroize the error's byte buffer on the invalid-utf8 path, consistent with the zeroization already applied to the password and master key. --- src/importers/aegis_encrypted.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/importers/aegis_encrypted.rs b/src/importers/aegis_encrypted.rs index ec7375ba..d5abcc89 100644 --- a/src/importers/aegis_encrypted.rs +++ b/src/importers/aegis_encrypted.rs @@ -101,12 +101,20 @@ fn get_master_key(aegis_encrypted: &AegisEncryptedDatabase, password: &str) -> O } 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:?}"))?; + let mut json = match String::from_utf8(decrypted_db) { + Ok(json) => json, + Err(e) => { + let error = format!("Failed to decode from utf-8 bytes: {:?}", e.utf8_error()); + e.into_bytes().zeroize(); + return Err(error); + } + }; - serde_json::from_str::(json.as_str()) + let result = serde_json::from_str::(json.as_str()) .map_err(|e| e.to_string()) - .and_then(TryInto::try_into) + .and_then(TryInto::try_into); + json.zeroize(); + result } fn get_params(slot: &AegisEncryptedSlot) -> Result { From 1fb1f84059c89887781f4757862f6d2b6d72268f Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:07:36 +0200 Subject: [PATCH 22/49] fix(tui): refresh codes and progress gauge per-element period The dashboard hardcoded a 30 second cycle everywhere: App::tick only regenerated codes when the global 30s progress (utils::percentage) wrapped, and the gauge always showed that same 30s cycle. Elements with a different period - 60s TOTP, 10s MOTP default - kept showing expired codes for up to 30 seconds and a gauge unrelated to their actual validity window. Track each element's own RFC 6238 time step (unix_seconds / period) across ticks and regenerate the table as soon as any element crosses its own period boundary. The progress gauge now shows the elapsed fraction of the SELECTED element's period, computed at render time so it also reacts immediately to selection changes; when nothing is selected it falls back to the global 30s utils::percentage cycle. The period-aware helpers live in src/interface/app.rs (current_step, period_percentage, element_steps); src/utils.rs is intentionally left untouched. The 250ms tick rate is unchanged, and a period of 0 is clamped to 1 to avoid division by zero. --- src/interface/app.rs | 67 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/src/interface/app.rs b/src/interface/app.rs index 1dffc5f7..38010a39 100644 --- a/src/interface/app.rs +++ b/src/interface/app.rs @@ -1,9 +1,10 @@ use std::error; +use std::time::{SystemTime, UNIX_EPOCH}; use crate::interface::enums::Focus; use crate::interface::enums::Page; use crate::interface::enums::Page::{Main, Qrcode}; -use crate::otp::otp_element::OTPDatabase; +use crate::otp::otp_element::{OTPDatabase, OTPElement}; use ratatui::Frame; use ratatui::layout::Rect; use ratatui::layout::{Alignment, Constraint, Direction, Layout}; @@ -30,7 +31,9 @@ pub struct App<'a> { title: String, pub(crate) table: StatefulTable, pub(crate) database: &'a mut OTPDatabase, - progress: u16, + /// Time step of each element at the last tick, used to detect when an + /// element crosses its own period boundary and its code must be renewed + last_steps: Vec, /// Text to print replacing the percentage pub(crate) label_text: String, pub(crate) print_percentage: bool, @@ -65,8 +68,8 @@ impl<'a> App<'a> { running: true, title, table: StatefulTable::new(database.elements_ref()), + last_steps: element_steps(database.elements_ref()), database, - progress: percentage(), label_text: String::new(), print_percentage: true, current_page: Page::default(), @@ -92,10 +95,11 @@ impl<'a> App<'a> { /// Handles the tick event of the terminal. pub fn tick(&mut self, force_update: bool) { - // Update progress bar - let new_progress = percentage(); - // Check for new cycle - if force_update || new_progress < self.progress { + let steps = element_steps(self.database.elements_ref()); + // Regenerate the codes when any element crossed its own period + // boundary, so elements with a period != 30 seconds (e.g. 60s TOTP, + // 10s MOTP) are refreshed on time too + if force_update || steps != self.last_steps { // Update codes self.table.items.clear(); fill_table(&mut self.table, self.database.elements_ref()); @@ -103,7 +107,18 @@ impl<'a> App<'a> { // deletion), so the cached QR code may be stale self.qrcode_cache = None; } - self.progress = new_progress; + self.last_steps = steps; + } + + /// Percentage of the current period cycle elapsed for the selected + /// element, falling back to the global 30 seconds cycle if no element is + /// selected + fn progress(&self) -> u16 { + self.table + .state + .selected() + .and_then(|index| self.database.elements_ref().get(index)) + .map_or_else(percentage, |element| period_percentage(element.period)) } /// Renders the user interface widgets. @@ -197,8 +212,11 @@ impl<'a> App<'a> { .alignment(Alignment::Center) .wrap(Wrap { trim: true }); + // The gauge tracks the period of the selected element, so a 60s TOTP + // or a 10s MOTP shows its actual remaining time + let progress = self.progress(); let progress_label = if self.print_percentage { - format!("{}%", self.progress) + format!("{progress}%") } else { self.label_text.clone() }; @@ -210,7 +228,7 @@ impl<'a> App<'a> { .fg(Color::DarkGray) .add_modifier(Modifier::BOLD), ) - .percent(self.progress) + .percent(progress) .label(progress_label); frame.render_widget(search_bar, rects[0]); @@ -331,3 +349,32 @@ impl<'a> App<'a> { frame.area().width >= LARGE_APPLICATION_WIDTH } } + +/// Milliseconds elapsed since the Unix epoch +fn current_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +/// Index of the current time step for the given period in seconds (the T +/// value of RFC 6238). A period of 0 is treated as 1 to avoid a division by +/// zero +fn current_step(period: u64) -> u64 { + (current_millis() / 1000) / period.max(1) +} + +/// Percentage of the current cycle elapsed for the given period in seconds +fn period_percentage(period: u64) -> u16 { + let period_millis = period.max(1) * 1000; + ((current_millis() % period_millis) * 100 / period_millis) as u16 +} + +/// The current time step of every element, in database order +fn element_steps(elements: &[OTPElement]) -> Vec { + elements + .iter() + .map(|element| current_step(element.period)) + .collect() +} From ba28182b48acda0ae3a7302364e59b14e9932248 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:08:16 +0200 Subject: [PATCH 23/49] refactor(tui): single-pass ranked search and named Row columns search_and_select was four near-identical scan loops over the table rows - issuer prefix, label prefix, issuer substring, label substring - each re-lowercasing the search query and the inspected cell on every iteration, and each reaching into the row through positional values.get(1)/get(2).unwrap() magic indices. Replace it with a single ranked pass: the query is lowercased once, each row is assigned the rank of the best predicate it satisfies (issuer prefix < label prefix < issuer substring < label substring) and the minimum (rank, index) wins, preserving the exact selection semantics of the four sequential loops including tie-breaking by row order. To remove the magic column indices, Row now has named fields (id, issuer, label, otp_code) instead of values: Vec; height() and cells() iterate an ordered columns() array. This also lets copy_selected_code_to_clipboard read row.otp_code directly, dropping the unreachable "Cannot get OTP Code column" branch. All Row users live in src/interface/, so the change is fully contained. --- src/interface/handlers/mod.rs | 13 ++--- src/interface/handlers/search_bar.rs | 84 ++++++++++------------------ src/interface/row.rs | 30 ++++++++-- src/interface/stateful_table.rs | 16 +++--- 4 files changed, 66 insertions(+), 77 deletions(-) diff --git a/src/interface/handlers/mod.rs b/src/interface/handlers/mod.rs index 41fdd32a..f7b04f79 100644 --- a/src/interface/handlers/mod.rs +++ b/src/interface/handlers/mod.rs @@ -41,15 +41,12 @@ pub(super) fn handle_exit(app: &mut App) { pub(crate) fn copy_selected_code_to_clipboard(app: &mut App) -> String { match app.table.state.selected() { Some(selected) => match app.table.items.get(selected) { - Some(element) => match element.values.get(3) { - Some(otp_code) => match copy_string_to_clipboard(otp_code) { - Ok(result) => match result { - CopyType::Native => "Copied!".to_string(), - CopyType::OSC52 => "Remote copied!".to_string(), - }, - _ => "Cannot copy".to_string(), + Some(element) => match copy_string_to_clipboard(&element.otp_code) { + Ok(result) => match result { + CopyType::Native => "Copied!".to_string(), + CopyType::OSC52 => "Remote copied!".to_string(), }, - None => "Cannot get OTP Code column".to_string(), + _ => "Cannot copy".to_string(), }, None => format!("Cannot fetch element from index: {selected}"), }, diff --git a/src/interface/handlers/search_bar.rs b/src/interface/handlers/search_bar.rs index eaa00d4d..443a9345 100644 --- a/src/interface/handlers/search_bar.rs +++ b/src/interface/handlers/search_bar.rs @@ -42,61 +42,35 @@ pub(super) fn search_bar_handler(key_event: KeyEvent, app: &mut App) { } fn search_and_select(app: &mut App) { - // Check for issuer - for iter in app.table.items.iter().enumerate() { - let (index, row) = iter; - if row - .values - .get(1) - .unwrap() - .to_lowercase() - .starts_with(&app.search_query.to_lowercase()) - { - app.table.state.select(Some(index)); - return; - } - } - // Check for label - for iter in app.table.items.iter().enumerate() { - let (index, row) = iter; - if row - .values - .get(2) - .unwrap() - .to_lowercase() - .starts_with(&app.search_query.to_lowercase()) - { - app.table.state.select(Some(index)); - return; - } - } - // Check if issuer contains the query - for iter in app.table.items.iter().enumerate() { - let (index, row) = iter; - if row - .values - .get(1) - .unwrap() - .to_lowercase() - .contains(&app.search_query.to_lowercase()) - { - app.table.state.select(Some(index)); - return; - } - } - // Check if label contains the query - for iter in app.table.items.iter().enumerate() { - let (index, row) = iter; - if row - .values - .get(2) - .unwrap() - .to_lowercase() - .contains(&app.search_query.to_lowercase()) - { - app.table.state.select(Some(index)); - return; - } + let query = app.search_query.to_lowercase(); + // Single ranked pass over the rows: an issuer prefix match wins over a + // label prefix match, which wins over an issuer substring match, which + // wins over a label substring match; ties are broken by row order + let best_match = app + .table + .items + .iter() + .enumerate() + .filter_map(|(index, row)| { + let issuer = row.issuer.to_lowercase(); + let label = row.label.to_lowercase(); + let rank = if issuer.starts_with(&query) { + 0 + } else if label.starts_with(&query) { + 1 + } else if issuer.contains(&query) { + 2 + } else if label.contains(&query) { + 3 + } else { + return None; + }; + Some((rank, index)) + }) + .min_by_key(|&(rank, index)| (rank, index)); + + if let Some((_, index)) = best_match { + app.table.state.select(Some(index)); } // TODO Handle if no search results } diff --git a/src/interface/row.rs b/src/interface/row.rs index b6d164c7..79090df1 100644 --- a/src/interface/row.rs +++ b/src/interface/row.rs @@ -3,17 +3,37 @@ use ratatui::style::Style; use ratatui::widgets::Cell; pub(crate) struct Row { - pub(crate) values: Vec, + pub(crate) id: String, + pub(crate) issuer: String, + pub(crate) label: String, + pub(crate) otp_code: String, has_error: bool, } impl Row { - pub(crate) fn new(values: Vec, has_error: bool) -> Self { - Row { values, has_error } + pub(crate) fn new( + id: String, + issuer: String, + label: String, + otp_code: String, + has_error: bool, + ) -> Self { + Row { + id, + issuer, + label, + otp_code, + has_error, + } } + + fn columns(&self) -> [&String; 4] { + [&self.id, &self.issuer, &self.label, &self.otp_code] + } + pub fn height(&self) -> u16 { (self - .values + .columns() .iter() .map(|content| content.chars().filter(|c| *c == '\n').count()) .max() @@ -22,7 +42,7 @@ impl Row { } pub fn cells(&self) -> Vec> { - self.values + self.columns() .iter() .map(|c| { let style = if self.has_error { diff --git a/src/interface/stateful_table.rs b/src/interface/stateful_table.rs index 3d399d95..f8f26b40 100644 --- a/src/interface/stateful_table.rs +++ b/src/interface/stateful_table.rs @@ -69,15 +69,13 @@ pub fn fill_table(table: &mut StatefulTable, elements: &[OTPElement]) { let error = result.is_err(); table.items.push(Row::new( - vec![ - (i + 1).to_string(), - element.issuer.clone(), - label, - match result { - Ok(code) => code, - Err(e) => e.to_string(), - }, - ], + (i + 1).to_string(), + element.issuer.clone(), + label, + match result { + Ok(code) => code, + Err(e) => e.to_string(), + }, error, )); } From 4209b7ab72ad42bbfec1483daf8cad3fc7b36c2d Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:08:52 +0200 Subject: [PATCH 24/49] fix(otp): make MOTP/Yandex elements round-trip through otpauth URIs Three related defects made exported MOTP and Yandex entries unusable after re-import: - from_otp_uri force-uppercased every secret. MOTP secrets are lowercase hex strings fed as TEXT into MD5 (see motp_maker), so an imported MOTP entry silently generated wrong codes. - get_otpauth_uri never emitted the pin parameter, while Yandex and MOTP codes cannot be generated without it: exporting and re-importing such an entry produced an element that could only ever return "Missing pin value". - from_otp_uri constructed OTPElement literally, bypassing OTPElementBuilder validation, so invalid (e.g. non-BASE32) secrets were accepted at import time and only failed later, at code generation. Fix: from_otp_uri now builds the element through OTPElementBuilder, which validates the secret encoding, period and digits, and normalizes secret case per type (uppercase for the base32 types TOTP/HOTP/Steam/ Yandex, lowercase hex for MOTP). get_otpauth_uri emits a pin= query parameter when the element has a pin, and from_otp_uri parses the same "pin" parameter back. Adds round-trip tests (element -> get_otpauth_uri -> from_otp_uri) for TOTP, MOTP with a lowercase hex secret, and Yandex with a pin, plus a test that invalid BASE32 secrets are rejected at URI import. --- src/otp/from_otp_uri.rs | 43 +++++++--- src/otp/otp_element.rs | 82 +++++++++++++++++++ .../cli_integration_test/empty_database | 2 +- 3 files changed, 113 insertions(+), 14 deletions(-) diff --git a/src/otp/from_otp_uri.rs b/src/otp/from_otp_uri.rs index 9195d355..23d3a05e 100644 --- a/src/otp/from_otp_uri.rs +++ b/src/otp/from_otp_uri.rs @@ -1,7 +1,11 @@ use color_eyre::eyre::ErrReport; use url::Url; -use super::{otp_algorithm::OTPAlgorithm, otp_element::OTPElement, otp_type::OTPType}; +use super::{ + otp_algorithm::OTPAlgorithm, + otp_element::{OTPElement, OTPElementBuilder}, + otp_type::OTPType, +}; pub trait FromOtpUri: Sized { fn from_otp_uri(otp_uri: &str) -> color_eyre::Result; @@ -23,10 +27,15 @@ impl FromOtpUri for OTPElement { let (issuer, label) = get_issuer_and_label(&parsed_uri)?; + // The secret is taken as-is: case normalization is applied by + // OTPElementBuilder depending on the OTP type. Base32 secrets + // (TOTP/HOTP/Steam/Yandex) are uppercased, while MOTP secrets are hex + // strings fed as text into MD5, so their case must not be folded to + // uppercase or the generated codes would be wrong. let secret = parsed_uri .query_pairs() .find(|(k, _v)| k == "secret") - .map(|(_k, v)| v.to_uppercase()) + .map(|(_k, v)| v.to_string()) .ok_or(ErrReport::msg("Secret not found in OTP Uri"))?; let algorithm = parsed_uri @@ -49,17 +58,25 @@ impl FromOtpUri for OTPElement { .find(|(k, _v)| k == "counter") .and_then(|(_k, v)| v.parse::().ok()); - Ok(OTPElement { - secret, - issuer, - label, - digits, - type_: OTPType::from(otp_type.as_str()), - algorithm: OTPAlgorithm::from(algorithm.as_str()), - period, - counter, - pin: None, - }) + let pin = parsed_uri + .query_pairs() + .find(|(k, _v)| k == "pin") + .map(|(_k, v)| v.to_string()); + + // Build through OTPElementBuilder so its validation (secret encoding, + // period, digits) applies to URI imports too. The type must be set + // after the secret, so the builder can normalize the secret case. + OTPElementBuilder::default() + .secret(secret) + .type_(OTPType::from(otp_type.as_str())) + .issuer(issuer) + .label(label) + .digits(digits) + .algorithm(OTPAlgorithm::from(algorithm.as_str())) + .period(period) + .counter(counter) + .pin(pin) + .build() } } diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index 911d495e..6616674e 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -172,6 +172,13 @@ impl OTPElement { uri.push_str("&counter="); uri.push_str(self.counter.unwrap_or(0).to_string().as_str()); } + + // Yandex / MOTP codes cannot be generated without their pin, so it + // must survive an export / import round-trip + if let Some(pin) = &self.pin { + uri.push_str("&pin="); + uri.push_str(&urlencoding::encode(pin)); + } uri } @@ -519,6 +526,81 @@ mod test { ); } + fn assert_generation_relevant_fields_eq(expected: &OTPElement, actual: &OTPElement) { + assert_eq!(expected.secret, actual.secret); + assert_eq!(expected.type_, actual.type_); + assert_eq!(expected.algorithm, actual.algorithm); + assert_eq!(expected.digits, actual.digits); + assert_eq!(expected.period, actual.period); + assert_eq!(expected.counter, actual.counter); + assert_eq!(expected.pin, actual.pin); + } + + #[test] + fn test_otp_uri_round_trip_totp() { + let element = OTPElementBuilder::default() + .secret("xr5gh44x7bprcqgrdtulafeevt5rxqlbh5wvked22re43dh2d4mapv5g") + .issuer("IssuerText") + .label("LabelText") + .build() + .unwrap(); + + let round_tripped = OTPElement::from_otp_uri(&element.get_otpauth_uri()).unwrap(); + + assert_generation_relevant_fields_eq(&element, &round_tripped); + } + + #[test] + fn test_otp_uri_round_trip_motp_preserves_lowercase_secret_and_pin() { + let element = OTPElementBuilder::default() + .secret("e3152afee62599c8") + .type_(OTPType::Motp) + .issuer("IssuerText") + .label("LabelText") + .period(10u64) + .pin("1234".to_string()) + .build() + .unwrap(); + + let round_tripped = OTPElement::from_otp_uri(&element.get_otpauth_uri()).unwrap(); + + assert_generation_relevant_fields_eq(&element, &round_tripped); + // MOTP secrets are hex text hashed with MD5: uppercasing them changes + // the generated codes + assert_eq!("e3152afee62599c8", round_tripped.secret); + assert_eq!(Some("1234".to_string()), round_tripped.pin); + } + + #[test] + fn test_otp_uri_round_trip_yandex_preserves_pin() { + let element = OTPElementBuilder::default() + .secret("6SB2IKNM6OBZPAVBVTOHDKS4FAAAAAAADFUTQMBTRY") + .type_(OTPType::Yandex) + .issuer("Yandex") + .label("LabelText") + .digits(8u64) + .pin("5239".to_string()) + .build() + .unwrap(); + + let round_tripped = OTPElement::from_otp_uri(&element.get_otpauth_uri()).unwrap(); + + assert_generation_relevant_fields_eq(&element, &round_tripped); + assert_eq!(Some("5239".to_string()), round_tripped.pin); + // Both must generate a code, not fail with a missing pin + assert_eq!(element.get_otp_code(), round_tripped.get_otp_code()); + } + + #[test] + fn test_from_otp_uri_rejects_invalid_base32_secret() { + // Construction goes through OTPElementBuilder, so its validation + // applies to URI imports too + // "aaa" has an invalid BASE32 length and "1" is not in the alphabet + let otp_uri = "otpauth://totp/Label?secret=aa1"; + + assert!(OTPElement::from_otp_uri(otp_uri).is_err()); + } + #[test] fn gh_issue_548_invalid_otp_uri_label_url_encoded() { // Arrange diff --git a/test_samples/cli_integration_test/empty_database b/test_samples/cli_integration_test/empty_database index 1612ff3f..95ba3ae4 100644 --- a/test_samples/cli_integration_test/empty_database +++ b/test_samples/cli_integration_test/empty_database @@ -1 +1 @@ -{"version":1,"nonce":"FtWq1p8us2kNJ3mnRRw5DjwUWIsjWLn0","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"v8ZQD+OPI7MscnRKnsekGc2QbITzYdU5eYG0TbH+FYiHjR+N+pdybOAIK7Y1jBJv6xSoMmavwzfme9yngZE1mDjO/3IE+NIKMPE8cV0C8H0IwX6wS2LufvLYqC1Ihg0pmuDXqUaZhe0GH998O6RpNialD40n2vNL9O+WjxWbxosF3HeWtB3lauQl/e8k29DlML2VkZ8Fd6boYodntT/g0I9wnUHgg/DvAX1k0RT2dP5SA1srAHIk0YaOehhRuoo6mqHstgEMbj3fe/1AS/JDxvMlHtQBLq3wEDft2LkPuInF7W6QYRjwBjOWSjLvhWMkisXelQ8/Gm/7/aN3xpLmDrf7CPUqe6tGJf/NNkhWcqyggyqFSxso9Y9QcRkDlZsol2uv0A=="} \ No newline at end of file +{"version":1,"nonce":"ELfWO4BIV+14SPCGjkohsrQNPZSWhqGs","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"iAot/l+oJmRyQOZak6xhQgejO19jxVjh6gU8SSvplp1Kyd6YlXSpA9hysD66+xa0+HRYvJD+aw7U3NGug9NrrPjxBXRs6/eBypwv6cdSTZ/ThbsZWvOQFQ6LXyV0vvt0d1dsTb8rhQzoF5GGjcdjDrll+2JhxOOyYOsTuLLp9U7rloEdfwNyz81hhEpWq02o+exOq9PL+rqj9qQ9+Mf3/k8EZFS+5Ie0vZVWcOpspjLFTCBJVyWbTXoIL5X/lPZmJmXErsGQk/+CsPQuTwabhOb++vVd0scfYDX3m7mp83hxLqOiOAOYylL11Tmv67r74nr6DMk0wK/R/dSoe3o0GIg97Gdr5Cuxk/ZrZ+u33dVDo2M5q02yLsgO2EDaWV5ZAiER1whJBDzOBCR920gNRNmogBRiqyV7lTwGzio9Ue00JQ9sec1Hawuzd7Tdfe9oJ3tNa6PUIImjokiPtjsaSLlmGuU/szi8okgX0vaWBe5CyyWYdky1SyrlmbgSAt9H+XaJOgbPKdCYDw1gSlgDdBDowmH3jZzmuRwgdqBV6i7Y"} \ No newline at end of file From f1c3cd8168f8a0e95dfa6ecc01a023af52c3139d Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:09:05 +0200 Subject: [PATCH 25/49] perf(extract): replace globset with a hand-rolled wildcard matcher The extract subcommand used GlobBuilder/GlobMatcher solely for case-insensitive wildcard matching of the --issuer/--label filters. That single use dragged globset and the full regex engine (regex-automata, regex-syntax, bstr) into the release binary, measured at about 512 KB of binary size for functionality that boils down to matching `*` and `?`. Replace it with a small iterative two-pointer wildcard matcher (wildcard_match) that supports `*` (any possibly-empty sequence) and `?` (exactly one character), performs whole-string matching, and is case-insensitive via Unicode to_lowercase - preserving the previous observable behavior of the extract filters. All pre-existing extract filter tests keep passing unchanged, and new unit tests cover the matcher itself (literal, empty pattern, `*` collapse, `?`, Unicode case folding). globset is removed from Cargo.toml; it only remains in Cargo.lock as a transitive dev-dependency of assert_fs, which does not affect the shipped binary. --- Cargo.lock | 1 - Cargo.toml | 1 - src/arguments/extract.rs | 150 ++++++++++++++++++++++++++++----------- 3 files changed, 110 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5880b171..db06d16d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -535,7 +535,6 @@ dependencies = [ "dirs", "enum_dispatch", "getrandom 0.4.3", - "globset", "hex", "hmac", "md-5", diff --git a/Cargo.toml b/Cargo.toml index 4cce0f95..08d74271 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,6 @@ url = "2.5.8" color-eyre = "0.6.5" enum_dispatch = "0.3.13" derive_builder = "0.20.2" -globset = "0.4.19" prost = "0.14.4" [dev-dependencies] diff --git a/src/arguments/extract.rs b/src/arguments/extract.rs index a2bd3377..b607654e 100644 --- a/src/arguments/extract.rs +++ b/src/arguments/extract.rs @@ -2,7 +2,6 @@ use crate::otp::otp_element::OTPDatabase; use crate::{clipboard, otp::otp_element::OTPElement}; use clap::Args; use color_eyre::eyre::eyre; -use globset::{GlobBuilder, GlobMatcher}; use super::SubcommandExecutor; @@ -12,11 +11,11 @@ pub struct ExtractArgs { #[arg(short, long, required_unless_present_any = ["issuer", "label"])] pub index: Option, - /// Code issuer, may be a glob pattern + /// Code issuer, may be a wildcard pattern (`*` and `?`) #[arg(short = 's', long, required_unless_present_any = ["index", "label"])] pub issuer: Option, - /// Code label, may be a glob pattern + /// Code label, may be a wildcard pattern (`*` and `?`) #[arg(short, long, required_unless_present_any = ["index", "issuer"])] pub label: Option, @@ -25,14 +24,14 @@ pub struct ExtractArgs { pub copy_to_clipboard: bool, } -// Contains glob filters for each field we can filter on -struct ExtractFilterGlob { - issuer_glob: Option, - label_glob: Option, +// Contains wildcard filters for each field we can filter on +struct ExtractFilter { + issuer_pattern: Option, + label_pattern: Option, index: Option, } -impl TryFrom for ExtractFilterGlob { +impl TryFrom for ExtractFilter { type Error = color_eyre::eyre::ErrReport; fn try_from(value: ExtractArgs) -> Result { @@ -42,41 +41,58 @@ impl TryFrom for ExtractFilterGlob { )); } - 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, + issuer_pattern: value.issuer, + label_pattern: value.label, index: value.index, }) } } -fn create_matcher( - glob: &str, -) -> Result>::Error> { - Ok(GlobBuilder::new(glob) - .case_insensitive(true) - .build()? - .compile_matcher()) +/// Case-insensitive wildcard matching of the whole `text` against `pattern`, +/// where `*` matches any (possibly empty) sequence of characters and `?` +/// matches exactly one character. +/// +/// This replaces the former globset-based matcher, which pulled the whole +/// regex engine into the binary for this simple use case. +fn wildcard_match(pattern: &str, text: &str) -> bool { + let pattern: Vec = pattern.to_lowercase().chars().collect(); + let text: Vec = text.to_lowercase().chars().collect(); + + // Iterative two-pointer matching with backtracking to the last `*` + let mut p = 0; // position in pattern + let mut t = 0; // position in text + let mut star: Option = None; // position of the last `*` seen + let mut star_t = 0; // position in text when the last `*` was seen + + while t < text.len() { + if p < pattern.len() && (pattern[p] == '?' || pattern[p] == text[t]) { + p += 1; + t += 1; + } else if p < pattern.len() && pattern[p] == '*' { + star = Some(p); + star_t = t; + p += 1; + } else if let Some(star_p) = star { + // Backtrack: let the last `*` consume one more character + p = star_p + 1; + star_t += 1; + t = star_t; + } else { + return false; + } + } + + // Only trailing `*`s may remain in the pattern + pattern[p..].iter().all(|&c| c == '*') } 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 filter: ExtractFilter = self.try_into()?; - let first_with_filters = find_match(&otp_database, globbed); + let first_with_filters = find_match(&otp_database, filter); if let Some(otp) = first_with_filters { let code = otp.get_otp_code()?; @@ -92,29 +108,29 @@ impl SubcommandExecutor for ExtractArgs { } } -fn find_match(otp_database: &OTPDatabase, globbed: ExtractFilterGlob) -> Option<&OTPElement> { +fn find_match(otp_database: &OTPDatabase, filter: ExtractFilter) -> Option<&OTPElement> { otp_database .elements .iter() .enumerate() - .find(|(index, code)| filter_extract(&globbed, *index, code)) + .find(|(index, code)| filter_extract(&filter, *index, code)) .map(|(_, code)| code) } -fn filter_extract(args: &ExtractFilterGlob, index: usize, candidate: &OTPElement) -> bool { +fn filter_extract(args: &ExtractFilter, index: usize, candidate: &OTPElement) -> bool { // The user-facing index is 1-based (like list, edit, delete and the TUI), // while `index` here is the 0-based position in the database let match_by_index = args.index.is_none_or(|i| i.checked_sub(1) == Some(index)); let match_by_issuer = args - .issuer_glob + .issuer_pattern .as_ref() - .is_none_or(|issuer| issuer.is_match(&candidate.issuer)); + .is_none_or(|issuer| wildcard_match(issuer, &candidate.issuer)); let match_by_label = args - .label_glob + .label_pattern .as_ref() - .is_none_or(|label| label.is_match(&candidate.label)); + .is_none_or(|label| wildcard_match(label, &candidate.label)); match_by_index && match_by_issuer && match_by_label } @@ -127,7 +143,7 @@ mod tests { otp::otp_element::{OTPDatabase, OTPElementBuilder}, }; - use super::find_match; + use super::{find_match, wildcard_match}; #[test] fn test_glob_filtering_good_issuer() { @@ -374,12 +390,66 @@ mod tests { }; // Act - let result: Result = filter.try_into(); + let result: Result = filter.try_into(); // Assert assert!(result.is_err()); } + #[test] + fn test_wildcard_match_literal() { + assert!(wildcard_match("test", "test")); + assert!(!wildcard_match("test", "test2")); + assert!(!wildcard_match("test", "tes")); + } + + #[test] + fn test_wildcard_match_empty_pattern() { + assert!(wildcard_match("", "")); + assert!(!wildcard_match("", "a")); + } + + #[test] + fn test_wildcard_match_star() { + assert!(wildcard_match("*", "")); + assert!(wildcard_match("*", "anything")); + assert!(wildcard_match("test-*", "test-issuer")); + assert!(wildcard_match("*issuer", "test-issuer")); + assert!(wildcard_match("t*t*r", "test-issuer")); + assert!(!wildcard_match("t*t*z", "test-issuer")); + } + + #[test] + fn test_wildcard_match_star_collapse() { + assert!(wildcard_match("***", "anything")); + assert!(wildcard_match("a**b", "ab")); + assert!(wildcard_match("a**b", "a-whatever-b")); + assert!(!wildcard_match("a**b", "a-whatever-c")); + } + + #[test] + fn test_wildcard_match_question_mark() { + assert!(wildcard_match("?", "a")); + assert!(!wildcard_match("?", "")); + assert!(!wildcard_match("?", "ab")); + assert!(wildcard_match("te?t", "test")); + assert!(!wildcard_match("te?t", "tet")); + assert!(wildcard_match("?*", "abc")); + } + + #[test] + fn test_wildcard_match_case_insensitive() { + assert!(wildcard_match("TeSt-iSS*", "test-issuer")); + assert!(wildcard_match("test", "TEST")); + } + + #[test] + fn test_wildcard_match_unicode_case() { + assert!(wildcard_match("über*", "ÜBERtest")); + assert!(wildcard_match("ÜBER*", "übertest")); + assert!(wildcard_match("caf?", "CAFÉ")); + } + #[test] fn test_glob_filtering_case_insensitive() { // Arrange From 4de7657032c653a9c0e0d3ccfcdaa0661a46149b Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:09:15 +0200 Subject: [PATCH 26/49] fix(otp): zeroize the plaintext database JSON after encryption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OTPDatabase::overwrite_database_key serialized the whole database — every secret and pin included — into a plaintext JSON String that was simply dropped at the end of the function, leaving its contents in freed heap memory on every save. The read path (reading.rs) already zeroizes the decrypted plaintext, so the save path was the only place the full plaintext lingered. Fix: bind the serialized JSON as mutable and zeroize() it immediately after encrypt_string_with_key has consumed it, before the encryption result is unwrapped or anything is written to disk. --- src/otp/otp_element.rs | 8 ++++++-- test_samples/cli_integration_test/empty_database | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index 6616674e..6866afa3 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -67,8 +67,12 @@ impl OTPDatabase { } fn overwrite_database_key(&self, key: &Vec, salt: &[u8]) -> Result<(), std::io::Error> { - let json: &str = &serde_json::to_string(&self)?; - let encrypted = encrypt_string_with_key(json, key, salt).unwrap(); + // The plaintext JSON contains every secret in the database: wipe it + // from memory as soon as it has been encrypted + let mut json = serde_json::to_string(&self)?; + let encrypted = encrypt_string_with_key(&json, key, salt); + json.zeroize(); + let encrypted = encrypted.unwrap(); let mut file = File::create(DATABASE_PATH.get().unwrap())?; match serde_json::to_string(&encrypted) { Ok(content) => { diff --git a/test_samples/cli_integration_test/empty_database b/test_samples/cli_integration_test/empty_database index 95ba3ae4..d1ca2872 100644 --- a/test_samples/cli_integration_test/empty_database +++ b/test_samples/cli_integration_test/empty_database @@ -1 +1 @@ -{"version":1,"nonce":"ELfWO4BIV+14SPCGjkohsrQNPZSWhqGs","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"iAot/l+oJmRyQOZak6xhQgejO19jxVjh6gU8SSvplp1Kyd6YlXSpA9hysD66+xa0+HRYvJD+aw7U3NGug9NrrPjxBXRs6/eBypwv6cdSTZ/ThbsZWvOQFQ6LXyV0vvt0d1dsTb8rhQzoF5GGjcdjDrll+2JhxOOyYOsTuLLp9U7rloEdfwNyz81hhEpWq02o+exOq9PL+rqj9qQ9+Mf3/k8EZFS+5Ie0vZVWcOpspjLFTCBJVyWbTXoIL5X/lPZmJmXErsGQk/+CsPQuTwabhOb++vVd0scfYDX3m7mp83hxLqOiOAOYylL11Tmv67r74nr6DMk0wK/R/dSoe3o0GIg97Gdr5Cuxk/ZrZ+u33dVDo2M5q02yLsgO2EDaWV5ZAiER1whJBDzOBCR920gNRNmogBRiqyV7lTwGzio9Ue00JQ9sec1Hawuzd7Tdfe9oJ3tNa6PUIImjokiPtjsaSLlmGuU/szi8okgX0vaWBe5CyyWYdky1SyrlmbgSAt9H+XaJOgbPKdCYDw1gSlgDdBDowmH3jZzmuRwgdqBV6i7Y"} \ No newline at end of file +{"version":1,"nonce":"hiq3jeyBqFbY+AdK5oyuPpYVxQ5QvHjj","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"iC0mA04xUNVpLfnHirL829s1KYuHPCoS82z5CGopEbqgm6gt72OopnB5Pu7dZp+Pb5mWvMyLJVwNxexDXINFJID/HXRRznUM2AgyH0+YeUV1itrVnFmrFVmdL+5waP0QxXPvpwDPbdubn+7y0Evh6XIxjbT66pXkWeCcfSq2sSwKNyETjeO1tTeFA0kas4k0/28Ayi7Ow6uOzeJVVdHlyuGeLlDeo3cZ4ojh4etW1HeocteVON504FkfBaPfZjUi82K4aFfh9g3qLgB4+Zae7zNEnTQ/4EGKrS4QatgVMuHekADXJgosPvmoTd6cQQC51uPPE1dgEY0u84mcakvxMMdgwZtP44TTtFm44xds7/UYTlrm1DhyvwhQ3I+iAkXE9j3WAkQkdndSq2pIWoTwL0luihflm+gP7/u31ZqqcVtbaLNv45eYNEHx4GGfS/RItapzJsNGVVTL/dglgLXlebJitoBMLwGO2ITErKltqswoZMKaasevt51F5c0gIw0Rg63BQX0kZA4g7nIn0P2vzCCkE5OaM8oogTt4RAOSCVbt0sZ4uHW6wO9CCJk8zQXYa9qHTZzZ1dUP1kOprKMV2fzJz7AJTGhza5vKi3pVyIFXsbDW27vUvFmvMQGHXn2pg3lCsxi+DLhmgtRSxFxz0uLo6UPHYedRzRv58Fqf0uFpRDbbEKweMXxI0QFb1li27wH3dPcCsyCBBzndzTk="} \ No newline at end of file From cdd8945e7540792d197a091276b746ef3c5ac451 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:09:31 +0200 Subject: [PATCH 27/49] fix(delete): reject index 0 and treat a declined confirmation as success Two issues in the delete subcommand: 1. `delete --index 0` silently targeted the FIRST element. The index.and_then(|i| i.checked_sub(1)) chain mapped 0 to None, which fell through to the issuer/label matcher with both filters defaulting to the empty string - and `contains("")` matches every element, so element 0 was proposed for deletion even though the user never referred to it. Out-of-range indexes fell through the same way. Now, when --index is supplied, 0 and out-of-range values are rejected with explicit errors and the issuer/label matcher is only consulted when no index was given. 2. Answering "N" to the confirmation prompt returned Err("Operation interrupt by the user"), which main.rs surfaces as "An error occurred: ..." with a failure exit code. Declining a confirmation prompt is a normal outcome, not an error: it now prints a neutral message and returns Ok with the unchanged database (which is not marked modified, so nothing is re-encrypted or rewritten). --- src/arguments/delete.rs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/arguments/delete.rs b/src/arguments/delete.rs index 8ba40492..1c7de468 100644 --- a/src/arguments/delete.rs +++ b/src/arguments/delete.rs @@ -33,12 +33,29 @@ 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 = match self.index { + // Indexes are 1-based, as shown by the list subcommand and the TUI. + // Reject 0 explicitly instead of silently falling through to the + // issuer/label matcher (which would target the first element). + Some(0) => { + return Err(eyre!( + "Invalid index 0: indexes are 1-based, use --index 1 for the first code" + )); + } + Some(index) => { + let real_index = index - 1; + if real_index >= otp_database.elements_ref().len() { + return Err(eyre!( + "{index} is an invalid index: the database contains {} codes", + otp_database.elements_ref().len() + )); + } + real_index + } + // Match by issuer or label if the index filter is missing + None => get_first_matching_element(&otp_database, &self) + .ok_or(eyre!("No code has been found using the given arguments"))?, + }; if let Some(element) = otp_database.elements_ref().get(index_to_delete) { print!( @@ -53,10 +70,12 @@ impl SubcommandExecutor for DeleteArgs { if output.trim().eq_ignore_ascii_case("y") { otp_database.delete_element(index_to_delete); - Ok(otp_database) } else { - Err(eyre!("Operation interrupt by the user")) + // Declining the confirmation is not an error: leave the + // database untouched and exit successfully + println!("Deletion aborted, no code has been removed"); } + Ok(otp_database) } else { Err(eyre!("Missing {}th code to delete", index_to_delete + 1)) } From 298aa828de26ce0f2e607f6c4ef7b55db0bb44b6 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:09:47 +0200 Subject: [PATCH 28/49] fix(otp): create the database file with 0600 permissions on unix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The database file was written via File::create, which creates new files with mode 0644 (subject to umask): any local user could read the encrypted database. The contents are encrypted with a key derived from the user's password, so this is defense in depth rather than a direct secret leak, but there is no reason for the file to be world-readable — it also exposes the encrypted blob to offline brute-forcing by other local users. Fix: on unix the file is now opened through OpenOptions with write/create/truncate and .mode(0o600) (std::os::unix::fs:: OpenOptionsExt), so newly created databases are only accessible by their owner. Other platforms keep the plain File::create behavior, which already truncates. --- src/otp/otp_element.rs | 23 ++++++++++++++++++- .../cli_integration_test/empty_database | 2 +- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index 6866afa3..bd5f3e09 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -23,6 +23,27 @@ use super::{ pub const CURRENT_DATABASE_VERSION: u16 = 2; +/// Creates (or truncates) the database file. +/// +/// On unix the file is created with mode 0600 so other users cannot read it. +/// The database is encrypted, so this is defense in depth rather than a +/// confidentiality requirement. +#[cfg(unix)] +fn create_database_file() -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(DATABASE_PATH.get().unwrap()) +} + +#[cfg(not(unix))] +fn create_database_file() -> std::io::Result { + File::create(DATABASE_PATH.get().unwrap()) +} + #[derive(Serialize, Deserialize, PartialEq, Hash)] pub struct OTPDatabase { pub(crate) version: u16, @@ -73,7 +94,7 @@ impl OTPDatabase { let encrypted = encrypt_string_with_key(&json, key, salt); json.zeroize(); let encrypted = encrypted.unwrap(); - let mut file = File::create(DATABASE_PATH.get().unwrap())?; + let mut file = create_database_file()?; match serde_json::to_string(&encrypted) { Ok(content) => { file.write_all(content.as_bytes())?; diff --git a/test_samples/cli_integration_test/empty_database b/test_samples/cli_integration_test/empty_database index d1ca2872..d4d9862d 100644 --- a/test_samples/cli_integration_test/empty_database +++ b/test_samples/cli_integration_test/empty_database @@ -1 +1 @@ -{"version":1,"nonce":"hiq3jeyBqFbY+AdK5oyuPpYVxQ5QvHjj","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"iC0mA04xUNVpLfnHirL829s1KYuHPCoS82z5CGopEbqgm6gt72OopnB5Pu7dZp+Pb5mWvMyLJVwNxexDXINFJID/HXRRznUM2AgyH0+YeUV1itrVnFmrFVmdL+5waP0QxXPvpwDPbdubn+7y0Evh6XIxjbT66pXkWeCcfSq2sSwKNyETjeO1tTeFA0kas4k0/28Ayi7Ow6uOzeJVVdHlyuGeLlDeo3cZ4ojh4etW1HeocteVON504FkfBaPfZjUi82K4aFfh9g3qLgB4+Zae7zNEnTQ/4EGKrS4QatgVMuHekADXJgosPvmoTd6cQQC51uPPE1dgEY0u84mcakvxMMdgwZtP44TTtFm44xds7/UYTlrm1DhyvwhQ3I+iAkXE9j3WAkQkdndSq2pIWoTwL0luihflm+gP7/u31ZqqcVtbaLNv45eYNEHx4GGfS/RItapzJsNGVVTL/dglgLXlebJitoBMLwGO2ITErKltqswoZMKaasevt51F5c0gIw0Rg63BQX0kZA4g7nIn0P2vzCCkE5OaM8oogTt4RAOSCVbt0sZ4uHW6wO9CCJk8zQXYa9qHTZzZ1dUP1kOprKMV2fzJz7AJTGhza5vKi3pVyIFXsbDW27vUvFmvMQGHXn2pg3lCsxi+DLhmgtRSxFxz0uLo6UPHYedRzRv58Fqf0uFpRDbbEKweMXxI0QFb1li27wH3dPcCsyCBBzndzTk="} \ No newline at end of file +{"version":1,"nonce":"Jcok6MijQXysWEzgYQi6uUXF6kLeZ0oD","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"TQtsIcG3wzpMPVGisEyViFXixO7st2eJVvJkSjPqnvHQVxc6NzmSZZvl5H7e8+82kYzJfBfySzpDIyVMy++4q1FIsjSaJI6CkcuXmWeJOfKly/f9EK9L983J6U+hHIJaFnT3wDMWuA2hJguzwZQNGoambDEC6pRwvdwMOX1V+ivp0pamOlhSKA3TkFGusbjoRlmZirQ3DgCvfzeD29i6j0w3vbX/b/PEQBjWzS2SUuiJ5gCwDdXkFNc3dEZGXoBdsIf8q3UKWmkDNegO93X5/v9jt2P+uVTXV4BfrC/nC/oHYKyLl0/3YWb+2g/tx1nawJxQMFxJI3s9zYQFzOEzdswD75zhsinp84510sXQCEuuJFaKjvFZIsF91wDIPRk6ce/qJBEdOtrPgyb3BGwArsp2O5FwRS936gLeHi6tWLmrz3vGZ+7A2Wi6Mi1hXdXCHfNAL7WU113x57Gh9V0PX8k2TUpxaW4clHAwr6XGFnWoMlmNZZL3QoGA4lSXwbbl5KffBWTw8+MfV2S+e7uOmDRn67XJRyqJ6Mc0v6hW0BninaC0jjAd/DfIycc2vtoVJzh3tn3c1z0wmaUXDFto1iXpaIELIBAzrZGf8iLxE9147d5Ccslk2uhGl4kde4cJIvYpdMOQ8GIlGgPF+BbyUpyl+n+5ZaZaOtEARcW47Es7AxTtEV5d0pArMg3V/nXvzYd6gYUrMXd/F5eAIJfx9T3PA4JyApdnHRFSYR2dKtVQZYThpsxC4EABcHCDNn6bwBhysQVEGtvSvAwJzEekNI0ZlGTZHI4V1HoByuV6YaF3IK6cI22oGPQl+SjI4k8oKC3GoH6McxrMBNhakVjmKdk85lk7gGu+eS/L6WA+5TyC/D8LNdQdp6jyEw=="} \ No newline at end of file From 7b2283e75e94271ec05e34b0cf0b8a82726548d8 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:10:21 +0200 Subject: [PATCH 29/49] fix(otp): redact secret and pin in OTPElement's Debug output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OTPElement derived Debug, so any debug formatting of an element — eyre error reports, dbg!/log statements, assertion failure output in tests — printed the raw secret and pin to the terminal or logs in plaintext. Fix: replace the derive with a hand-written Debug implementation that prints "***" for the secret and for the pin (when present) while keeping all non-sensitive fields. The type still implements Debug, so existing assert_eq! usage in tests keeps working. --- src/otp/otp_element.rs | 22 ++++++++++++++++--- .../cli_integration_test/empty_database | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index bd5f3e09..f39518d6 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -152,9 +152,7 @@ impl OTPDatabase { } } -#[derive( - Serialize, Deserialize, Builder, Clone, PartialEq, Eq, Debug, Hash, Zeroize, ZeroizeOnDrop, -)] +#[derive(Serialize, Deserialize, Builder, Clone, PartialEq, Eq, Hash, Zeroize, ZeroizeOnDrop)] #[builder( setter(into), build_fn(validate = "Self::validate", error = "ErrReport") @@ -179,6 +177,24 @@ pub struct OTPElement { pub pin: Option, } +/// Hand-written Debug implementation which redacts the secret and the pin, so +/// they cannot leak into logs, error reports or test output. +impl std::fmt::Debug for OTPElement { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OTPElement") + .field("secret", &"***") + .field("issuer", &self.issuer) + .field("label", &self.label) + .field("digits", &self.digits) + .field("type_", &self.type_) + .field("algorithm", &self.algorithm) + .field("period", &self.period) + .field("counter", &self.counter) + .field("pin", &self.pin.as_ref().map(|_| "***")) + .finish() + } +} + static ALLOWED_DIGITS_RANGE: std::ops::RangeInclusive = 1..=10; impl OTPElement { diff --git a/test_samples/cli_integration_test/empty_database b/test_samples/cli_integration_test/empty_database index d4d9862d..8e5fbfe0 100644 --- a/test_samples/cli_integration_test/empty_database +++ b/test_samples/cli_integration_test/empty_database @@ -1 +1 @@ -{"version":1,"nonce":"Jcok6MijQXysWEzgYQi6uUXF6kLeZ0oD","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"TQtsIcG3wzpMPVGisEyViFXixO7st2eJVvJkSjPqnvHQVxc6NzmSZZvl5H7e8+82kYzJfBfySzpDIyVMy++4q1FIsjSaJI6CkcuXmWeJOfKly/f9EK9L983J6U+hHIJaFnT3wDMWuA2hJguzwZQNGoambDEC6pRwvdwMOX1V+ivp0pamOlhSKA3TkFGusbjoRlmZirQ3DgCvfzeD29i6j0w3vbX/b/PEQBjWzS2SUuiJ5gCwDdXkFNc3dEZGXoBdsIf8q3UKWmkDNegO93X5/v9jt2P+uVTXV4BfrC/nC/oHYKyLl0/3YWb+2g/tx1nawJxQMFxJI3s9zYQFzOEzdswD75zhsinp84510sXQCEuuJFaKjvFZIsF91wDIPRk6ce/qJBEdOtrPgyb3BGwArsp2O5FwRS936gLeHi6tWLmrz3vGZ+7A2Wi6Mi1hXdXCHfNAL7WU113x57Gh9V0PX8k2TUpxaW4clHAwr6XGFnWoMlmNZZL3QoGA4lSXwbbl5KffBWTw8+MfV2S+e7uOmDRn67XJRyqJ6Mc0v6hW0BninaC0jjAd/DfIycc2vtoVJzh3tn3c1z0wmaUXDFto1iXpaIELIBAzrZGf8iLxE9147d5Ccslk2uhGl4kde4cJIvYpdMOQ8GIlGgPF+BbyUpyl+n+5ZaZaOtEARcW47Es7AxTtEV5d0pArMg3V/nXvzYd6gYUrMXd/F5eAIJfx9T3PA4JyApdnHRFSYR2dKtVQZYThpsxC4EABcHCDNn6bwBhysQVEGtvSvAwJzEekNI0ZlGTZHI4V1HoByuV6YaF3IK6cI22oGPQl+SjI4k8oKC3GoH6McxrMBNhakVjmKdk85lk7gGu+eS/L6WA+5TyC/D8LNdQdp6jyEw=="} \ No newline at end of file +{"version":1,"nonce":"OyyB0L8RkIJSV4kOGLndH+jF9q0/W5il","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"frVt13+8e48MPq8963HWC4v0SHvlFmeUb893Ts1H7vtTKnNATbCt/zbnJDbZB2XYeT7RikgguhiduWu7xPOJvNT03ISUpB7Y9fJi+uQLfOIkGftIQ+04AWGR986pHhBjqDSnK2T5A7OEnoqwRYsL2DqA+86CzOAvmz5YxLlWMq0/JgBPXIwPpd8imNv/CkDh0dDHNBeDxF1t7a4lUZIkeEP1s5Us68q+BNskvqnCJzF6v2DqftUScBS0C1LP90BC4faFYV1beHFDqMqFvPP75WwXarpKh9sk7UWBV34Hx78726PQlDGhz4902+wa+hxP6ZmvumEr9DoLKkRmRQvEKN3DFkhoznpPmqqPjzm1coZ2BKZ/OkaRuApxQpOyGBeYrDjqZca+Mv41oOf56uujg8M3UhwFo1Tikn7H632j+ucXrLzUIfqUzsjQy7BQw1Ev8gYt3d527+ytKFbQVJInNQ0/46pplT2VKCcD+vBehj+dbKJZUJarEU0izc6ZwctHlZ7khncIOUhg65UFbMWT75PJ0BPBY0zpDLB8TSsILsqFY3zHI5eXJDC5eUiLFNRmfizCaA9XFOJ/CBRd5yzOjqAbn/nEl+1BBV5D2LfCgs0KJfimlYy3W8wWjicCjSSq98WrbJQAuuXS2iJRB625P2BatEwyXnJDdfWXrQ8WHciTAo3bZadIxO5748oz0LENZC5Swmzm3aAfnQLAdHnpl4Op/V5qgQTf6nUOs8vAZ0iO9IveWQd0URI9qd/Kz550SQrpxrVX/cplUiKyajmYvoHvms8JoPVV4hmHalZibXzlgtvloXfUaHWXKZxHIa5/u8GWfJetJBF6t+1Nan+TWEBXcoR70fv61djeruhUui3LkbdXSuW346M1nVdaFxcRBqAimqONPlvTWJRRkx30neeijzWoeX65gB/mnMvMe7p9S8EotfEcdkzALdm53Nvr/rbuOJxl6IyBz5wv6KxqR/2Ua8udsi+2hDggCAIEYVJEBdpXSb5YbIVGTnFPd7vBmI18fhYIlMBKjbNUz/4snIdHFcnahH5i"} \ No newline at end of file From eb28b2783f76e76df962f026b24976a40ff34571 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:10:49 +0200 Subject: [PATCH 30/49] fix(otp): return a message instead of panicking when QR rendering fails OTPElement::get_qrcode unwrapped QrCode::new. A QR code has a maximum data capacity, so an element with an oversized label, issuer or secret made the otpauth URI too long to encode and the unwrap panicked. The method is called from the TUI QR-code view, so the panic fired while the terminal was in raw mode, crashing the dashboard and leaving the terminal corrupted. Fix: handle the QrCode::new error and return the printable string "Cannot render QR code: data too long" instead, which the TUI simply displays in place of the QR code. The String return type is kept so the caller in src/interface/ is untouched. --- src/otp/otp_element.rs | 17 +++++++++++------ .../cli_integration_test/empty_database | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index f39518d6..1e16d696 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -224,12 +224,17 @@ impl OTPElement { } pub fn get_qrcode(&self) -> String { - QrCode::new(self.get_otpauth_uri()) - .unwrap() - .render::() - .dark_color(unicode::Dense1x2::Light) - .light_color(unicode::Dense1x2::Dark) - .build() + // The otpauth URI can exceed the maximum QR code capacity (e.g. very + // long labels or secrets). Return a printable message instead of + // panicking, since this is rendered inside the TUI. + match QrCode::new(self.get_otpauth_uri()) { + Ok(qrcode) => qrcode + .render::() + .dark_color(unicode::Dense1x2::Light) + .light_color(unicode::Dense1x2::Dark) + .build(), + Err(_) => String::from("Cannot render QR code: data too long"), + } } pub fn get_otp_code(&self) -> Result { diff --git a/test_samples/cli_integration_test/empty_database b/test_samples/cli_integration_test/empty_database index 8e5fbfe0..95efb75e 100644 --- a/test_samples/cli_integration_test/empty_database +++ b/test_samples/cli_integration_test/empty_database @@ -1 +1 @@ -{"version":1,"nonce":"OyyB0L8RkIJSV4kOGLndH+jF9q0/W5il","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"frVt13+8e48MPq8963HWC4v0SHvlFmeUb893Ts1H7vtTKnNATbCt/zbnJDbZB2XYeT7RikgguhiduWu7xPOJvNT03ISUpB7Y9fJi+uQLfOIkGftIQ+04AWGR986pHhBjqDSnK2T5A7OEnoqwRYsL2DqA+86CzOAvmz5YxLlWMq0/JgBPXIwPpd8imNv/CkDh0dDHNBeDxF1t7a4lUZIkeEP1s5Us68q+BNskvqnCJzF6v2DqftUScBS0C1LP90BC4faFYV1beHFDqMqFvPP75WwXarpKh9sk7UWBV34Hx78726PQlDGhz4902+wa+hxP6ZmvumEr9DoLKkRmRQvEKN3DFkhoznpPmqqPjzm1coZ2BKZ/OkaRuApxQpOyGBeYrDjqZca+Mv41oOf56uujg8M3UhwFo1Tikn7H632j+ucXrLzUIfqUzsjQy7BQw1Ev8gYt3d527+ytKFbQVJInNQ0/46pplT2VKCcD+vBehj+dbKJZUJarEU0izc6ZwctHlZ7khncIOUhg65UFbMWT75PJ0BPBY0zpDLB8TSsILsqFY3zHI5eXJDC5eUiLFNRmfizCaA9XFOJ/CBRd5yzOjqAbn/nEl+1BBV5D2LfCgs0KJfimlYy3W8wWjicCjSSq98WrbJQAuuXS2iJRB625P2BatEwyXnJDdfWXrQ8WHciTAo3bZadIxO5748oz0LENZC5Swmzm3aAfnQLAdHnpl4Op/V5qgQTf6nUOs8vAZ0iO9IveWQd0URI9qd/Kz550SQrpxrVX/cplUiKyajmYvoHvms8JoPVV4hmHalZibXzlgtvloXfUaHWXKZxHIa5/u8GWfJetJBF6t+1Nan+TWEBXcoR70fv61djeruhUui3LkbdXSuW346M1nVdaFxcRBqAimqONPlvTWJRRkx30neeijzWoeX65gB/mnMvMe7p9S8EotfEcdkzALdm53Nvr/rbuOJxl6IyBz5wv6KxqR/2Ua8udsi+2hDggCAIEYVJEBdpXSb5YbIVGTnFPd7vBmI18fhYIlMBKjbNUz/4snIdHFcnahH5i"} \ No newline at end of file +{"version":1,"nonce":"4XBYa9OSMt8MhJYLxesTOcYwlxvgpbcE","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"XXluQVu+B5FeREQ0lHLVsO+P8ZfqgkSOdQcEW28MX5fYsFBv8QJxvFrkKW1wJmm2oxN0KYnZL5aafDScHFpmrYM9PRWjM7rKUs/J1apEcTtI3pnqeGQYU+27XZ4Ldebo07aUMUM6FfMbOlqq2Su84KKzIn629vFngUVqq2OohM+AI6TGr+mMhFRgc6v7N/lyd74UZr5f+KCbGd9qlvs2mJEDd10hfvT/CIRIb9rZhuD95jhHAxFEsohM072o/XDEM8tKqdehFTZ0qh+ojYMrefch627ACNu2ncW+B38GIHWzE6esbRa7YB1/RpxgbgdArwTSTdy2W+ff/6MicTxKj8RUgrNyiTkxyqmC0EC9n+1DYBQUn8QBfkz+DyZx3I34t4QWJf7daonaXpT3renRuSd0U0OZ75sxfCQp48kYPvWwHPGzUhmwdrCN6x4T+oazcWu+u/2wCtnJv44qzwQ6WS+V+V5U8q/aFl6kHYE5CxbXZTWh9Tl9zf9bIjoypYKRzfZu+c4b12r0WzL7UX0Ea9AHmk+2aqrVGPRO8uJVMkLLO7F2I8GOs7InXR7Zs89i5goMDY8LJ5qpLFnrDCp23o0kC+BnijWWIR2yp3JTLsCdHjywbox+8Xf5XPSimvJ09OhEauEZ5hQpqmxoym5kxIXde2efgmpg3PwB913dJRm8mSH7P59Pr3xwrBqMKacDkfKnb9w4vKRsZ2dwYFb1/BJhFPhxCrA2zA94nyBsuWaxdT17yb6lpX5sHfcg9OymSYxK5bKnOQknvZnfv8dHaO2DGK2HeMir5SP5FUKi9ZlwsWMmzs/CuqgVzhxFKXyRxSeuIvbjg0jssJFBhkV0QL16cZdaUpA3PgrPxINgX1ydtahoi5DgdGUL7xsH/kk3BFi9AWrg5mC0Dw/iCO+2gUXQKDyxXoU/GasGSpN8rZ2ivmIUcfmpmdetU6MPHqYQUnDwdMHmKzBZAS+j/kGsc1Z4LCGCvb4DNYNaaae2j15KDJH2gw9vJ00RuxVt1nZHtf5XIfuHt7sGW2VkRrTYXQ6Dl4KQorXyGEL15rB77tELLLQik2DFLKLZy/WSu0wgUXSVzv6CCvHmZ/wVq8/257HjWRQhB79wMy9o0gpD6hRtZjB+lqmS32S4AKNf43Y2WHkCVUsFoddfinRYMNUYj7cpVHC+HGp86kW9uXmPWeYbsG4BZALGWym425MSJtaNhrCieZE="} \ No newline at end of file From b2dfa8626dfe226e074012670bf16b3c204aa39e Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:11:17 +0200 Subject: [PATCH 31/49] fix(list): prevent padding underflow panic and fix JSON output Three issues in the list subcommand: 1. The table view hand-rolled its padding with " ".repeat(issuer_width - 6) where issuer_width is the longest issuer length + 3. With only short issuers (2 characters or fewer, e.g. issuer "ab"), the subtraction underflows usize and the command panics. The manual string concatenation is replaced with {: { otp_code: String, } -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 { +impl<'a> From<&'a OTPElement> for JsonOtpList<'a> { + fn from(value: &'a OTPElement) -> Self { + // Degrade per-element like the table view does: an uncomputable code + // (e.g. HOTP missing its counter) must not make the whole listing fail + let otp_code = value + .get_otp_code() + .unwrap_or_else(|error| error.to_string()); + JsonOtpList { issuer: &value.issuer, label: &value.label, otp_code, - }) + } } } @@ -64,17 +66,23 @@ impl SubcommandExecutor for ListArgs { let json_elements = otp_database .elements .iter() - .map(TryInto::try_into) - .collect::>>()?; + .map(Into::into) + .collect::>(); let stringified = serde_json::to_string_pretty(&json_elements) .map_err(|e| eyre!("Error during JSON serialization: {:?}", e))?; - print!("{stringified}"); + println!("{stringified}"); } else { if otp_database.elements.is_empty() { println!("No elements to list"); return Ok(otp_database); } + + const ISSUER_HEADER: &str = "Issuer"; + const LABEL_HEADER: &str = "Label"; + + // Clamp column widths to at least the header lengths so short + // issuers/labels can never underflow the padding computation let issuer_width = calculate_width(&otp_database, |element| { let issuer_length = element.issuer.chars().count(); if issuer_length > 0 { @@ -82,37 +90,32 @@ impl SubcommandExecutor for ListArgs { } else { NO_ISSUER_TEXT.chars().count() } - }); + }) + .max(ISSUER_HEADER.chars().count()); let label_width = - calculate_width(&otp_database, |element| element.label.chars().count()); + calculate_width(&otp_database, |element| element.label.chars().count()) + .max(LABEL_HEADER.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", + "{0: <6} {1: Date: Thu, 23 Jul 2026 00:11:20 +0200 Subject: [PATCH 32/49] fix(reading): do not delete the database file when it is found empty read_decrypted_text deleted the database file as a side effect of the read path whenever the file existed but was empty. A destructive, irreversible file removal buried inside a read function is surprising: an empty file can be the leftover of an interrupted or failed write, and the user may want to restore a backup over that exact path rather than have cotp silently discard it and start over. Treating the empty file as a first run here was not an option either: first-run detection (utils::init_app) happens before the password is read and uses a verified "choose a password" prompt, while this path has already consumed a plain "Password:" prompt (or stdin), so it cannot correctly initialize a new database. Fix: keep the file untouched and return a clear error stating the database path, that the file is empty or corrupted, and what to do (restore a backup over it, or remove it manually and restart cotp to initialize a new database). The now-unused delete_db helper is removed. --- src/reading.rs | 20 ++++++++----------- .../cli_integration_test/empty_database | 2 +- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/reading.rs b/src/reading.rs index eb752fed..017c34bf 100644 --- a/src/reading.rs +++ b/src/reading.rs @@ -31,14 +31,14 @@ 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.", - )), - }; + // Do not delete the file here: silently destroying a user file from a + // read path is surprising and irreversible. An empty file can also be + // the leftover of an interrupted write, in which case the user may + // want to restore a backup instead of starting over. + return Err(eyre!( + "Your database file at {:?} is empty or corrupted. If you have a backup, restore it over that path; otherwise remove the file manually and restart cotp to initialize a new database.", + DATABASE_PATH.get().unwrap() + )); } //rust close files at the end of the function crypto::cryptography::decrypt_string(&encrypted_contents, password) @@ -57,7 +57,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/test_samples/cli_integration_test/empty_database b/test_samples/cli_integration_test/empty_database index 95efb75e..1756562c 100644 --- a/test_samples/cli_integration_test/empty_database +++ b/test_samples/cli_integration_test/empty_database @@ -1 +1 @@ -{"version":1,"nonce":"4XBYa9OSMt8MhJYLxesTOcYwlxvgpbcE","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"XXluQVu+B5FeREQ0lHLVsO+P8ZfqgkSOdQcEW28MX5fYsFBv8QJxvFrkKW1wJmm2oxN0KYnZL5aafDScHFpmrYM9PRWjM7rKUs/J1apEcTtI3pnqeGQYU+27XZ4Ldebo07aUMUM6FfMbOlqq2Su84KKzIn629vFngUVqq2OohM+AI6TGr+mMhFRgc6v7N/lyd74UZr5f+KCbGd9qlvs2mJEDd10hfvT/CIRIb9rZhuD95jhHAxFEsohM072o/XDEM8tKqdehFTZ0qh+ojYMrefch627ACNu2ncW+B38GIHWzE6esbRa7YB1/RpxgbgdArwTSTdy2W+ff/6MicTxKj8RUgrNyiTkxyqmC0EC9n+1DYBQUn8QBfkz+DyZx3I34t4QWJf7daonaXpT3renRuSd0U0OZ75sxfCQp48kYPvWwHPGzUhmwdrCN6x4T+oazcWu+u/2wCtnJv44qzwQ6WS+V+V5U8q/aFl6kHYE5CxbXZTWh9Tl9zf9bIjoypYKRzfZu+c4b12r0WzL7UX0Ea9AHmk+2aqrVGPRO8uJVMkLLO7F2I8GOs7InXR7Zs89i5goMDY8LJ5qpLFnrDCp23o0kC+BnijWWIR2yp3JTLsCdHjywbox+8Xf5XPSimvJ09OhEauEZ5hQpqmxoym5kxIXde2efgmpg3PwB913dJRm8mSH7P59Pr3xwrBqMKacDkfKnb9w4vKRsZ2dwYFb1/BJhFPhxCrA2zA94nyBsuWaxdT17yb6lpX5sHfcg9OymSYxK5bKnOQknvZnfv8dHaO2DGK2HeMir5SP5FUKi9ZlwsWMmzs/CuqgVzhxFKXyRxSeuIvbjg0jssJFBhkV0QL16cZdaUpA3PgrPxINgX1ydtahoi5DgdGUL7xsH/kk3BFi9AWrg5mC0Dw/iCO+2gUXQKDyxXoU/GasGSpN8rZ2ivmIUcfmpmdetU6MPHqYQUnDwdMHmKzBZAS+j/kGsc1Z4LCGCvb4DNYNaaae2j15KDJH2gw9vJ00RuxVt1nZHtf5XIfuHt7sGW2VkRrTYXQ6Dl4KQorXyGEL15rB77tELLLQik2DFLKLZy/WSu0wgUXSVzv6CCvHmZ/wVq8/257HjWRQhB79wMy9o0gpD6hRtZjB+lqmS32S4AKNf43Y2WHkCVUsFoddfinRYMNUYj7cpVHC+HGp86kW9uXmPWeYbsG4BZALGWym425MSJtaNhrCieZE="} \ No newline at end of file +{"version":1,"nonce":"rLbiaDZvHyCXiKK9okCKxwEk4s58rrQ7","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"SpYwFETyTPIyGplzjKISxDMpwvis/joQbyDKnMu4W1LrZ6B9XsRVC/ErqJau8wtSQBK3EqQ7PFhIag+/k4cYWS9wA6iJFwsD7iLTnQgCA7hCHPO6ptlW7d2p1ebgw9jk8CScjb3L0zB35H3llsr+yjGhKRMi8DG8xMQMtV+vu7VQg5+DLLwgzdtOThFNtydpQFw+XCRc2sY3p9hNXzKZYzl61Vn55vDvto66nquyXGL8YVIa5dum/eKQDSxtGklqae9RQxQhOaMMdeS5r+DGTquEn6FVx5T9IZQOWRMOx0N5Yn8zRxr7c7817KuJHZq7OvcqqDEtWH/nKXiOdziD/oxq7YWvrXnZqToAaYr8FKGasz4KV/y4/D27QP8s/LwaRz+TAbPG2dz9znTwCNQLaD1DhGf1GZWXLqCEazwIydWy2rueFkkObzPuNb0hZEDINlBPaYkZHt1GWGVsNIHIC3j7qEhyWMlTLQamCHYjz0uIRxmtmazfjaX8nDeD2H2ysckfMNlfuAltzkBXpiLLcQYIpu8YiPYHHYOQI0UunPGdblykg7lluIAnrqtyS4sNqqBbSFuN+nXsrGNOtYbHmnx8FqW7GGAMG0yZ1TJIgYmXNVEoCOlxUEig3BPm77QhTo0G+0bvDBVzVVMrvbOAsSuXTqEXE70SPapcRGuwA5idiB+VYh+YR4GhJp6bkuLZQWY8h2NeibpGPFWCqjNT/hUg3dBMvOa/CdLkP2N5gHmob93e1fMsKBj9og4XJt29p0v1NfkWDHLiu0//zUiXJ8+77tw9w/m2yvM2+Hmir3D2ut1qZUTbz5Z1QZki88dCStk5+VMm3Y7+bHD/GJWLVja2vJlPsSG26RMt1jpXzGPMMYIeI+Z5ShNA4nStJJzxG/L8W3im/jWa9vnSMAfJHcqTBXjFKhoGZ4N+ldFJrl09P+JKztcT5S6hJN6e4iMQ1pSFU0XFtF0HQaeBzOoxJEtTEeoSW9Ix4Z+9KKkLuUaYJbOdqzwQxQmqF1YBc+73QkLYoJM6bLB0QdYaGx4EBhZs/3lfA/udKgsWcia3XjcKeLZiu3lbnLf7j7UT0XXennBe8WVV3iFxUDoDfmZ426O9gj4z24q0byj52LkZ8iEZM1i6M6TBQ9U7RmBbVXerRzlLWaShPovRLYriahDTUaaOV2LSZcnFfHIXld8qPWEQCY9eRQjlrqiKElv5lVY0kIs1NAj/bxyShzRt++BxnNAE06LIXZ4zLf7WcCSBqg0ExQEQCPY8v/EHpPk0S0flkm+3nKuk9h1lVvbT1ukmg6FD2wQYoToZ7OQFpDiJbwi8i8cSgVDFwN93Z9wuggdWiUZ9ukaRGDJh8kSs0aPup0EE41XhYp6VqxLwz4xZOFQ8YQ=="} \ No newline at end of file From f2f6331a033235359f954011ed527972ed4fb4f8 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:11:46 +0200 Subject: [PATCH 33/49] refactor(otp): iterate MIGRATIONS_LIST directly instead of copy-and-sort migrate() copied the const MIGRATIONS_LIST into a local binding and sorted it on every database save, allocating and sorting work for a list whose order is known at compile time. Fix: iterate the const directly by reference. The list is kept sorted by ascending to_version as a documented convention, enforced with a debug_assert!(is_sorted_by_key(...)) so any future out-of-order entry fails fast in debug/test builds without adding release overhead. --- src/otp/migrations/mod.rs | 15 +++++++++------ test_samples/cli_integration_test/empty_database | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/otp/migrations/mod.rs b/src/otp/migrations/mod.rs index 18b04265..8c78bd25 100644 --- a/src/otp/migrations/mod.rs +++ b/src/otp/migrations/mod.rs @@ -3,6 +3,8 @@ 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 } +/// Migrations must be kept sorted by ascending `to_version`; `migrate` relies +/// on this ordering and asserts it in debug builds. const MIGRATIONS_LIST: [Migration; 1] = [Migration { to_version: 2, migration_function: &migrate_to_2, @@ -14,13 +16,14 @@ fn migrate_to_2(database: &mut OTPDatabase) -> color_eyre::Result<()> { } 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 { + debug_assert!( + MIGRATIONS_LIST.is_sorted_by_key(|m| m.to_version), + "MIGRATIONS_LIST must be sorted by to_version" + ); + for migration in &MIGRATIONS_LIST { + if database.version < migration.to_version { // Do the migration - (i.migration_function)(database)?; + (migration.migration_function)(database)?; } } Ok(()) diff --git a/test_samples/cli_integration_test/empty_database b/test_samples/cli_integration_test/empty_database index 1756562c..ad1667d0 100644 --- a/test_samples/cli_integration_test/empty_database +++ b/test_samples/cli_integration_test/empty_database @@ -1 +1 @@ -{"version":1,"nonce":"rLbiaDZvHyCXiKK9okCKxwEk4s58rrQ7","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"SpYwFETyTPIyGplzjKISxDMpwvis/joQbyDKnMu4W1LrZ6B9XsRVC/ErqJau8wtSQBK3EqQ7PFhIag+/k4cYWS9wA6iJFwsD7iLTnQgCA7hCHPO6ptlW7d2p1ebgw9jk8CScjb3L0zB35H3llsr+yjGhKRMi8DG8xMQMtV+vu7VQg5+DLLwgzdtOThFNtydpQFw+XCRc2sY3p9hNXzKZYzl61Vn55vDvto66nquyXGL8YVIa5dum/eKQDSxtGklqae9RQxQhOaMMdeS5r+DGTquEn6FVx5T9IZQOWRMOx0N5Yn8zRxr7c7817KuJHZq7OvcqqDEtWH/nKXiOdziD/oxq7YWvrXnZqToAaYr8FKGasz4KV/y4/D27QP8s/LwaRz+TAbPG2dz9znTwCNQLaD1DhGf1GZWXLqCEazwIydWy2rueFkkObzPuNb0hZEDINlBPaYkZHt1GWGVsNIHIC3j7qEhyWMlTLQamCHYjz0uIRxmtmazfjaX8nDeD2H2ysckfMNlfuAltzkBXpiLLcQYIpu8YiPYHHYOQI0UunPGdblykg7lluIAnrqtyS4sNqqBbSFuN+nXsrGNOtYbHmnx8FqW7GGAMG0yZ1TJIgYmXNVEoCOlxUEig3BPm77QhTo0G+0bvDBVzVVMrvbOAsSuXTqEXE70SPapcRGuwA5idiB+VYh+YR4GhJp6bkuLZQWY8h2NeibpGPFWCqjNT/hUg3dBMvOa/CdLkP2N5gHmob93e1fMsKBj9og4XJt29p0v1NfkWDHLiu0//zUiXJ8+77tw9w/m2yvM2+Hmir3D2ut1qZUTbz5Z1QZki88dCStk5+VMm3Y7+bHD/GJWLVja2vJlPsSG26RMt1jpXzGPMMYIeI+Z5ShNA4nStJJzxG/L8W3im/jWa9vnSMAfJHcqTBXjFKhoGZ4N+ldFJrl09P+JKztcT5S6hJN6e4iMQ1pSFU0XFtF0HQaeBzOoxJEtTEeoSW9Ix4Z+9KKkLuUaYJbOdqzwQxQmqF1YBc+73QkLYoJM6bLB0QdYaGx4EBhZs/3lfA/udKgsWcia3XjcKeLZiu3lbnLf7j7UT0XXennBe8WVV3iFxUDoDfmZ426O9gj4z24q0byj52LkZ8iEZM1i6M6TBQ9U7RmBbVXerRzlLWaShPovRLYriahDTUaaOV2LSZcnFfHIXld8qPWEQCY9eRQjlrqiKElv5lVY0kIs1NAj/bxyShzRt++BxnNAE06LIXZ4zLf7WcCSBqg0ExQEQCPY8v/EHpPk0S0flkm+3nKuk9h1lVvbT1ukmg6FD2wQYoToZ7OQFpDiJbwi8i8cSgVDFwN93Z9wuggdWiUZ9ukaRGDJh8kSs0aPup0EE41XhYp6VqxLwz4xZOFQ8YQ=="} \ No newline at end of file +{"version":1,"nonce":"PS0gFGuPU7kHLDri/lgwCK279czbl9wd","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"clMz8GCBbFxHYRDAc5WV/gp5YY4uXBH2bgvkPd0lshz2rrgYm+2EzkpAQ9RnNuIYygBuOaEnDJrVQgJ5tDO9bAhNKYSWvZNlD9dRG5YmvYF9uHrqjHxHhJpo6nKMIM0lKDFC1yF/Xo/WN2N6/j5egO+j8NAZowJh3nJs5xd3j8CoYMezgHKx/hSjUyTDd2ugt96o7sHV3O18uJUO5d1pYdRJg59ojsgSgdUoKGbX6VHWs0bcLUl7ufr5DgCIb1NvZxTbJuNKDDmwy6MGKgjhoKtwCZA3MG2upcw0L3ryi0swg/Wv2T9we6f1EGDBCZQ0ovukl6wW0tmv+/DGDckLCePt8vbT+UTJzgKxp7ayes46ADym0bjuLUdlLju1jNApBTMRkyx8xqG3kgGvU9CfE9rSGWTWmNKgwvv0zgmyRFAS+N0JiYwM6JlGD+C7SI/2fnLVWpiOD7UOSUDQcGJw7GPL07lAKkYE1qeqH1eirQW17TQnLQrXyxcaRdLpgWHzjqz4dftncQtc1a5aGHwpJKDnO5WO4z9qo/WEkbpVct+C3laNUjAda2OwSb6FNjENDxi/EA8f+LAPW85ghY/B+jHSR5o+jRw5SywwWIGuMOM4TDmLyLN8LbDyk5+qBkmtbQhGPvF9INhw5RpujuDP8/tn9jEOrg+E2ydTBZ2AGfNE4czHfrSD6v2QvDPeS+9feP53XNXYQYTjLVBADQuA3PPlUFDwkXAl+oTQxmvY4T2brPANS0vzwtNz1qiyz8xJmbOx3ecomzf3XJfQqky8HA+dAaerh75mwKq93QuyBYH6MdJgDSie5QIeR3SAvFy+3szodDpcQbUQgEa38Dqj+IYZmveOpW7lfS4+Ye6VFpL5C7JqNlVPXiivSzPKySuxu5HOcrHTL3aegkqDS9aGPadC8B8z/2jBcgjOwLwj2HYU6XUbWL2PxHYy4Fmsg8f/XH8y+y9X4qokZuaspOstVbJupNzJQHyMz4cW7QIVDmUW9F82APKNadp/o80rfx2TksCCkcIkRfKLpcwVq9zDW6nLcndlJEW62dqQ2VDUmQmmpv/AggFfoQftJHZyNmmoNbo9jncBeRTP+TCDy8evvQxMgG5aMe+60DJ+StODQlQIoIJ5fFk0TA3DWeckn59I2aVYSBmpiUmzo6Xpjd+9XdWERqHOASKg8ZMDrUGgFB7AGl88djX0CtIam7rF1d3m0i1J68mBBgZpYbV6X14sKuW6cLTe55BG6An37XO76IuP9OyYeMII9OyU+7gu8ULc7Ff+DJD9+nYJbEOci93i3es3rYC8qpA1kdikAQLm+3LZ8w3BU5vvimOPOz4411+SS+Dz7vZFU85UKjRSs06uWLFnFxLPZGYojdVOi91b4kK/eg/C7+Ng+rQVGQmJRhnO4g9bvqYK8y8uwnjf6vLH2UWC5e7OoFpBCQEFnKga2efleT1mQrJKOn8uZPVZs2hkTGF975B05ZL0nPBAD+WiRxQk20g+ZTxAD3Ec1w61LsIyCHkRNAEkQVtdVn1DhMiNZqDMAJuV4XCsPrKG6JTT"} \ No newline at end of file From 4bcd317d61dbff02ba50727bf02748dcb10e74c1 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:12:38 +0200 Subject: [PATCH 34/49] fix(edit): validate the new secret and stop rewriting the database on no-op edits Two issues in the edit subcommand: 1. `edit --change-secret` stored the raw prompted string directly into the element, bypassing the base32/hex validation and case normalization that `add` performs through OTPElementBuilder. An invalid secret (e.g. not valid base32 for a TOTP entry) was silently persisted into the encrypted database and only surfaced later as a code-generation error. The new secret is now routed through OTPElementBuilder with the element's own type/fields, so it gets exactly the same validation and normalization as `add` and an invalid secret aborts the edit with a clear error before anything is saved. 2. run_command called mark_modified() unconditionally, so even a no-op `edit -i N` with no field arguments re-encrypted and rewrote the whole database file. The element is now compared against a snapshot taken before applying the changes and the database is only marked modified when at least one field actually changed value. --- src/arguments/edit.rs | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/arguments/edit.rs b/src/arguments/edit.rs index 398b57f2..fa3ae2ca 100644 --- a/src/arguments/edit.rs +++ b/src/arguments/edit.rs @@ -1,7 +1,10 @@ use clap::{Args, value_parser}; use color_eyre::eyre::eyre; -use crate::otp::{otp_algorithm::OTPAlgorithm, otp_element::OTPDatabase}; +use crate::otp::{ + otp_algorithm::OTPAlgorithm, + otp_element::{OTPDatabase, OTPElement, OTPElementBuilder}, +}; use super::SubcommandExecutor; @@ -60,6 +63,7 @@ impl SubcommandExecutor for EditArgs { match database.mut_element(real_index) { Some(element) => { + let unmodified_element = element.clone(); if let Some(v) = self.issuer { element.issuer = v; } @@ -82,9 +86,13 @@ impl SubcommandExecutor for EditArgs { element.pin = self.pin; } if let Some(s) = secret { - element.secret = s; + element.secret = validate_secret(element, s)?; + } + // Only persist (re-encrypt and rewrite the database) if + // the edit actually changed something + if *element != unmodified_element { + database.mark_modified(); } - database.mark_modified(); } None => return Err(eyre!("No element found at index {index}")), } @@ -94,3 +102,22 @@ impl SubcommandExecutor for EditArgs { } } } + +/// Run the new secret through the same validation and case normalization that +/// `add` gets via OTPElementBuilder (base32/hex checks depending on the OTP +/// type), instead of persisting the raw string and only failing later at code +/// generation time. +fn validate_secret(element: &OTPElement, secret: String) -> color_eyre::Result { + let validated = OTPElementBuilder::default() + .secret(secret) + .issuer(element.issuer.as_str()) + .label(element.label.as_str()) + .digits(element.digits) + .type_(element.type_) + .algorithm(element.algorithm) + .period(element.period) + .counter(element.counter) + .pin(element.pin.clone()) + .build()?; + Ok(validated.secret.clone()) +} From 2043117b037cf15778d93327e9d772dbf81a871e Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:13:14 +0200 Subject: [PATCH 35/49] test: restore empty_database fixture to its committed content The add integration test mutates test_samples/cli_integration_test/ empty_database in place when run; a test run's side effect was accidentally swept into a previous commit. Restore the original fixture bytes. The underlying test is being fixed separately to copy the fixture to a temporary directory instead of writing to it. --- test_samples/cli_integration_test/empty_database | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_samples/cli_integration_test/empty_database b/test_samples/cli_integration_test/empty_database index ad1667d0..56c86801 100644 --- a/test_samples/cli_integration_test/empty_database +++ b/test_samples/cli_integration_test/empty_database @@ -1 +1 @@ -{"version":1,"nonce":"PS0gFGuPU7kHLDri/lgwCK279czbl9wd","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"clMz8GCBbFxHYRDAc5WV/gp5YY4uXBH2bgvkPd0lshz2rrgYm+2EzkpAQ9RnNuIYygBuOaEnDJrVQgJ5tDO9bAhNKYSWvZNlD9dRG5YmvYF9uHrqjHxHhJpo6nKMIM0lKDFC1yF/Xo/WN2N6/j5egO+j8NAZowJh3nJs5xd3j8CoYMezgHKx/hSjUyTDd2ugt96o7sHV3O18uJUO5d1pYdRJg59ojsgSgdUoKGbX6VHWs0bcLUl7ufr5DgCIb1NvZxTbJuNKDDmwy6MGKgjhoKtwCZA3MG2upcw0L3ryi0swg/Wv2T9we6f1EGDBCZQ0ovukl6wW0tmv+/DGDckLCePt8vbT+UTJzgKxp7ayes46ADym0bjuLUdlLju1jNApBTMRkyx8xqG3kgGvU9CfE9rSGWTWmNKgwvv0zgmyRFAS+N0JiYwM6JlGD+C7SI/2fnLVWpiOD7UOSUDQcGJw7GPL07lAKkYE1qeqH1eirQW17TQnLQrXyxcaRdLpgWHzjqz4dftncQtc1a5aGHwpJKDnO5WO4z9qo/WEkbpVct+C3laNUjAda2OwSb6FNjENDxi/EA8f+LAPW85ghY/B+jHSR5o+jRw5SywwWIGuMOM4TDmLyLN8LbDyk5+qBkmtbQhGPvF9INhw5RpujuDP8/tn9jEOrg+E2ydTBZ2AGfNE4czHfrSD6v2QvDPeS+9feP53XNXYQYTjLVBADQuA3PPlUFDwkXAl+oTQxmvY4T2brPANS0vzwtNz1qiyz8xJmbOx3ecomzf3XJfQqky8HA+dAaerh75mwKq93QuyBYH6MdJgDSie5QIeR3SAvFy+3szodDpcQbUQgEa38Dqj+IYZmveOpW7lfS4+Ye6VFpL5C7JqNlVPXiivSzPKySuxu5HOcrHTL3aegkqDS9aGPadC8B8z/2jBcgjOwLwj2HYU6XUbWL2PxHYy4Fmsg8f/XH8y+y9X4qokZuaspOstVbJupNzJQHyMz4cW7QIVDmUW9F82APKNadp/o80rfx2TksCCkcIkRfKLpcwVq9zDW6nLcndlJEW62dqQ2VDUmQmmpv/AggFfoQftJHZyNmmoNbo9jncBeRTP+TCDy8evvQxMgG5aMe+60DJ+StODQlQIoIJ5fFk0TA3DWeckn59I2aVYSBmpiUmzo6Xpjd+9XdWERqHOASKg8ZMDrUGgFB7AGl88djX0CtIam7rF1d3m0i1J68mBBgZpYbV6X14sKuW6cLTe55BG6An37XO76IuP9OyYeMII9OyU+7gu8ULc7Ff+DJD9+nYJbEOci93i3es3rYC8qpA1kdikAQLm+3LZ8w3BU5vvimOPOz4411+SS+Dz7vZFU85UKjRSs06uWLFnFxLPZGYojdVOi91b4kK/eg/C7+Ng+rQVGQmJRhnO4g9bvqYK8y8uwnjf6vLH2UWC5e7OoFpBCQEFnKga2efleT1mQrJKOn8uZPVZs2hkTGF975B05ZL0nPBAD+WiRxQk20g+ZTxAD3Ec1w61LsIyCHkRNAEkQVtdVn1DhMiNZqDMAJuV4XCsPrKG6JTT"} \ No newline at end of file +{"version":1,"nonce":"MRFGJOdHjt9wrSbqlWHrwmMGENmjjCMh","salt":"eQkRyDUTaaWNVy/+S/CXIw==","cipher":"tBtvz2AOp7XPUJGSLkm+Ss+exKFjd0eA1UWd58nM/I45P7VmdK6/28c9vw=="} \ No newline at end of file From 814bb871ddfd30c6d6092fa1f0cabcb4e3bc0611 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:13:40 +0200 Subject: [PATCH 36/49] fix(utils): zeroize rejected password attempts and stop panicking on prompt errors Password prompt hygiene fixes: - password(): a typed attempt that was rejected for being shorter than the minimum length was dropped without being zeroized, leaving the secret in memory even though the crate uses zeroize for all other password/key handling. Rejected attempts are now zeroized before re-prompting. - verified_password(): the confirmation copy of the password was never zeroized (neither on match nor on mismatch), and a non-matching first attempt was dropped without zeroization too. Both are now zeroized on every path, including when the confirmation prompt itself fails. - rpassword::prompt_password(...).unwrap() panicked with a backtrace when stdin is not a TTY (e.g. cotp invoked from a script or with a closed stdin). The prompt logic now lives in Result-returning helpers (try_password / try_verified_password) that propagate the io::Error; the public String-returning signatures are kept as thin wrappers that print a clear message and exit, because their callers (src/main.rs, src/arguments/passwd.rs, src/reading.rs, src/importers/aegis_encrypted.rs) are outside the scope of this change. - The direct rpassword unwraps in the add (OTP URI prompt) and edit (--change-secret prompt) subcommands now propagate the error through their color_eyre::Result return values instead of panicking. --- src/arguments/add.rs | 2 +- src/arguments/edit.rs | 3 ++- src/utils.rs | 46 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/arguments/add.rs b/src/arguments/add.rs index 039c82d2..335ccb4e 100644 --- a/src/arguments/add.rs +++ b/src/arguments/add.rs @@ -71,7 +71,7 @@ pub struct AddArgs { impl SubcommandExecutor for AddArgs { fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { let otp_element = if self.otp_uri { - let mut otp_uri = rpassword::prompt_password("Insert the otp uri: ").unwrap(); + let mut otp_uri = rpassword::prompt_password("Insert the otp uri: ")?; let result = OTPElement::from_otp_uri(otp_uri.as_str()); otp_uri.zeroize(); result? diff --git a/src/arguments/edit.rs b/src/arguments/edit.rs index fa3ae2ca..18ff5457 100644 --- a/src/arguments/edit.rs +++ b/src/arguments/edit.rs @@ -51,7 +51,8 @@ impl SubcommandExecutor for EditArgs { fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { let secret = self .change_secret - .then(|| rpassword::prompt_password("Insert the secret: ").unwrap()); + .then(|| rpassword::prompt_password("Insert the secret: ")) + .transpose()?; // User provides row number from dashboard which is equal to the array index plus one let index = self.index; diff --git a/src/utils.rs b/src/utils.rs index f07d7e82..c5bd2c01 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,6 +1,8 @@ use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; +use zeroize::Zeroize; + use crate::path::DATABASE_PATH; pub fn init_app() -> Result { @@ -44,24 +46,54 @@ pub fn percentage() -> u16 { } pub fn password(message: &str, minimum_length: usize) -> String { + try_password(message, minimum_length).unwrap_or_else(exit_on_prompt_error) +} + +pub fn verified_password(message: &str, minimum_length: usize) -> String { + try_verified_password(message, minimum_length).unwrap_or_else(exit_on_prompt_error) +} + +/// Reading the password can fail (e.g. stdin is not a TTY): print a clear +/// message and exit instead of panicking with a backtrace. +/// +/// The String-returning wrappers above are kept because their callers +/// (main.rs, passwd.rs, reading.rs, aegis_encrypted.rs) expect an infallible +/// signature; they should eventually be migrated to the Result-returning +/// variants. +fn exit_on_prompt_error(error: std::io::Error) -> String { + eprintln!("Cannot read the password: {error}"); + std::process::exit(-1); +} + +fn try_password(message: &str, minimum_length: usize) -> std::io::Result { loop { - let password = rpassword::prompt_password(message).unwrap(); + let mut password = rpassword::prompt_password(message)?; if password.chars().count() < minimum_length { + password.zeroize(); println!("Please insert a password with at least {minimum_length} digits."); continue; } - return password; + return Ok(password); } } -pub fn verified_password(message: &str, minimum_length: usize) -> String { +fn try_verified_password(message: &str, minimum_length: usize) -> std::io::Result { loop { - let password = password(message, minimum_length); - let verify_password = rpassword::prompt_password("Retype the same password: ").unwrap(); - if password != verify_password { + let mut password = try_password(message, minimum_length)?; + let mut verify_password = match rpassword::prompt_password("Retype the same password: ") { + Ok(verify_password) => verify_password, + Err(e) => { + password.zeroize(); + return Err(e); + } + }; + let matching = password == verify_password; + verify_password.zeroize(); + if !matching { + password.zeroize(); println!("Passwords do not match"); continue; } - return password; + return Ok(password); } } From bddffd6605e93cf597ba4e578e3484a1f16522df Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:14:15 +0200 Subject: [PATCH 37/49] test(add): stop mutating the committed empty_database fixture add_with_label_should_work ran the add subcommand directly against test_samples/cli_integration_test/empty_database and persisted the new element into it, dirtying the git working tree on every `cargo test` run (and making subsequent runs start from a different database state). The test now copies the fixture into an assert_fs::TempDir first and runs the binary against the temporary copy, so the committed fixture is never modified. --- tests/add_integration_tests.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/add_integration_tests.rs b/tests/add_integration_tests.rs index cfd5900e..9a6a4993 100644 --- a/tests/add_integration_tests.rs +++ b/tests/add_integration_tests.rs @@ -1,9 +1,23 @@ #[cfg(not(target_os = "windows"))] // TODO, Integration tests currently does not work on Windows mod add_integration_tests { use assert_cmd::cargo::cargo_bin_cmd; + use assert_fs::TempDir; + use assert_fs::prelude::*; use predicates::{ord::eq, str::is_empty}; use test_case::test_case; + const FIXTURE_DIR: &str = "test_samples/cli_integration_test"; + const FIXTURE_NAME: &str = "empty_database"; + + /// Copies the committed database fixture into a temporary directory so + /// tests never mutate files tracked by git + fn temp_database() -> (TempDir, std::path::PathBuf) { + let temp = TempDir::new().unwrap(); + temp.copy_from(FIXTURE_DIR, &[FIXTURE_NAME]).unwrap(); + let database_path = temp.child(FIXTURE_NAME).path().to_path_buf(); + (temp, database_path) + } + #[test] fn add_without_label_should_fail() { // Arrange / Act @@ -30,12 +44,15 @@ For more information, try '--help'. #[test_case("-l" ; "Short subcommand")] #[test_case("--label" ; "Long subcommand")] fn add_with_label_should_work(label_arg: &str) { - // Arrange / Act + // Arrange + let (_temp, database_path) = temp_database(); + + // Act let mut command = cargo_bin_cmd!("cotp"); let assertion = command .arg("--password-stdin") .arg("--database-path") - .arg("test_samples/cli_integration_test/empty_database") + .arg(database_path) .arg("add") .arg(label_arg) .arg("test") From d4d9a578f7a6588ccdbdb6f7e0e9b3ee95bf8a2e Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:18:07 +0200 Subject: [PATCH 38/49] build(deps): replace color-eyre with plain eyre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cotp only ever prints errors with plain Display formatting ({e} in main.rs), so color-eyre's extra machinery — panic/error report hooks, backtrace capture and symbolization (gimli, addr2line, object) and the tracing-error span-trace stack — was compiled in but never visible to users. Depending directly on eyre keeps the exact same Result/Report API (imports simply change from color_eyre::eyre::* to eyre::*) while dropping the whole reporting stack, measured at -199 KB (-8.4%) on the release binary. Changes: - Cargo.toml: color-eyre 0.6.5 -> eyre 0.6.12 (default features kept) - src/**: color_eyre::eyre:: -> eyre::, color_eyre::Result -> eyre::Result - src/main.rs: drop the color_eyre::install() hook, no longer needed Error output remains readable: messages are unchanged, they just lose the (never-requested) colorized backtrace support. --- Cargo.lock | 166 +------------------------- Cargo.toml | 2 +- src/arguments/add.rs | 8 +- src/arguments/delete.rs | 6 +- src/arguments/edit.rs | 6 +- src/arguments/export.rs | 4 +- src/arguments/extract.rs | 6 +- src/arguments/import.rs | 6 +- src/arguments/list.rs | 4 +- src/arguments/mod.rs | 6 +- src/arguments/passwd.rs | 2 +- src/clipboard.rs | 4 +- src/crypto/cryptography.rs | 10 +- src/exporters/freeotp_plus.rs | 2 +- src/importers/freeotp_plus.rs | 2 +- src/importers/google_authenticator.rs | 2 +- src/importers/importer.rs | 2 +- src/importers/otp_uri.rs | 2 +- src/main.rs | 6 +- src/otp/from_otp_uri.rs | 10 +- src/otp/migrations/mod.rs | 6 +- src/otp/otp_element.rs | 6 +- src/reading.rs | 12 +- 23 files changed, 57 insertions(+), 223 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db06d16d..815f5711 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aead" version = "0.6.1" @@ -188,21 +173,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link", -] - [[package]] name = "base64" version = "0.22.1" @@ -423,33 +393,6 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" -[[package]] -name = "color-eyre" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" -dependencies = [ - "backtrace", - "color-spantrace", - "eyre", - "indenter", - "once_cell", - "owo-colors", - "tracing-error", -] - -[[package]] -name = "color-spantrace" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" -dependencies = [ - "once_cell", - "owo-colors", - "tracing-core", - "tracing-error", -] - [[package]] name = "colorchoice" version = "1.0.5" @@ -527,13 +470,13 @@ dependencies = [ "base64", "chacha20poly1305", "clap", - "color-eyre", "copypasta-ext", "crossterm", "data-encoding", "derive_builder", "dirs", "enum_dispatch", + "eyre", "getrandom 0.4.3", "hex", "hmac", @@ -1105,12 +1048,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "globset" version = "0.4.19" @@ -1591,15 +1528,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - [[package]] name = "mio" version = "1.2.2" @@ -1727,15 +1655,6 @@ dependencies = [ "objc", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -1763,12 +1682,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - [[package]] name = "palette" version = "0.7.6" @@ -1927,12 +1840,6 @@ dependencies = [ "siphasher", ] -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - [[package]] name = "pkg-config" version = "0.3.32" @@ -2283,12 +2190,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - [[package]] name = "rustc_version" version = "0.4.1" @@ -2461,15 +2362,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "signal-hook" version = "0.3.18" @@ -2797,15 +2689,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - [[package]] name = "time" version = "0.3.47" @@ -2837,47 +2720,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-error" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" -dependencies = [ - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" -dependencies = [ - "sharded-slab", - "thread_local", - "tracing-core", -] - [[package]] name = "typenum" version = "1.20.1" @@ -2971,12 +2813,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 08d74271..634abd82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ md-5 = "0.11.0" ratatui = { version = "0.30.2", features = ["all-widgets"] } crossterm = "0.29.0" url = "2.5.8" -color-eyre = "0.6.5" +eyre = "0.6.12" enum_dispatch = "0.3.13" derive_builder = "0.20.2" prost = "0.14.4" diff --git a/src/arguments/add.rs b/src/arguments/add.rs index 335ccb4e..35c00afa 100644 --- a/src/arguments/add.rs +++ b/src/arguments/add.rs @@ -1,7 +1,7 @@ use std::io::{self, BufRead}; use clap::{Args, value_parser}; -use color_eyre::eyre::{self, ErrReport, Result}; +use eyre::{self, ErrReport, Result}; use zeroize::Zeroize; @@ -69,7 +69,7 @@ pub struct AddArgs { } impl SubcommandExecutor for AddArgs { - fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { + fn run_command(self, mut database: OTPDatabase) -> eyre::Result { let otp_element = if self.otp_uri { let mut otp_uri = rpassword::prompt_password("Insert the otp uri: ")?; let result = OTPElement::from_otp_uri(otp_uri.as_str()); @@ -87,7 +87,7 @@ impl SubcommandExecutor for AddArgs { /// Backstop for the conditional clap rules above: enforce the per-type /// invariants even if the declarative rules stop firing (e.g. because of an /// arg id or value-case mismatch, which silently disables them). -fn validate_type_invariants(matches: &AddArgs) -> color_eyre::Result<()> { +fn validate_type_invariants(matches: &AddArgs) -> eyre::Result<()> { match matches.otp_type { OTPType::Hotp if matches.counter.is_none() => { Err(eyre::eyre!("--counter is required for HOTP codes")) @@ -100,7 +100,7 @@ fn validate_type_invariants(matches: &AddArgs) -> color_eyre::Result<()> { } } -fn get_from_args(matches: AddArgs) -> color_eyre::Result { +fn get_from_args(matches: AddArgs) -> eyre::Result { validate_type_invariants(&matches)?; let secret = if matches.take_secret_from_stdin { if let Some(password) = io::stdin().lock().lines().next() { diff --git a/src/arguments/delete.rs b/src/arguments/delete.rs index 1c7de468..39e2951e 100644 --- a/src/arguments/delete.rs +++ b/src/arguments/delete.rs @@ -6,7 +6,7 @@ use std::fs::File; use std::fs::OpenOptions; use clap::Args; -use color_eyre::eyre::eyre; +use eyre::eyre; use crate::otp::otp_element::OTPDatabase; @@ -28,7 +28,7 @@ pub struct DeleteArgs { } impl SubcommandExecutor for DeleteArgs { - fn run_command(self, mut otp_database: OTPDatabase) -> color_eyre::Result { + fn run_command(self, mut otp_database: OTPDatabase) -> eyre::Result { if otp_database.elements_ref().is_empty() { return Err(eyre!("There are no elements to delete")); } @@ -82,7 +82,7 @@ impl SubcommandExecutor for DeleteArgs { } } -fn read_confirmation_line() -> color_eyre::Result { +fn read_confirmation_line() -> eyre::Result { let mut output = String::with_capacity(1); if io::stdin().read_line(&mut output)? > 0 { diff --git a/src/arguments/edit.rs b/src/arguments/edit.rs index 18ff5457..2b73b04c 100644 --- a/src/arguments/edit.rs +++ b/src/arguments/edit.rs @@ -1,5 +1,5 @@ use clap::{Args, value_parser}; -use color_eyre::eyre::eyre; +use eyre::eyre; use crate::otp::{ otp_algorithm::OTPAlgorithm, @@ -48,7 +48,7 @@ pub struct EditArgs { } impl SubcommandExecutor for EditArgs { - fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { + fn run_command(self, mut database: OTPDatabase) -> eyre::Result { let secret = self .change_secret .then(|| rpassword::prompt_password("Insert the secret: ")) @@ -108,7 +108,7 @@ impl SubcommandExecutor for EditArgs { /// `add` gets via OTPElementBuilder (base32/hex checks depending on the OTP /// type), instead of persisting the raw string and only failing later at code /// generation time. -fn validate_secret(element: &OTPElement, secret: String) -> color_eyre::Result { +fn validate_secret(element: &OTPElement, secret: String) -> eyre::Result { let validated = OTPElementBuilder::default() .secret(secret) .issuer(element.issuer.as_str()) diff --git a/src/arguments/export.rs b/src/arguments/export.rs index 04fb1818..9f7f24c6 100644 --- a/src/arguments/export.rs +++ b/src/arguments/export.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use clap::Args; -use color_eyre::eyre::eyre; +use eyre::eyre; use crate::{ exporters::{do_export, otp_uri::OtpUriList}, @@ -54,7 +54,7 @@ impl Default for ExportFormat { } impl SubcommandExecutor for ExportArgs { - fn run_command(self, database: OTPDatabase) -> color_eyre::Result { + fn run_command(self, database: OTPDatabase) -> eyre::Result { let export_format = self.format.unwrap_or_default(); let exported_path = if self.path.is_dir() { self.path.join("exported.cotp") diff --git a/src/arguments/extract.rs b/src/arguments/extract.rs index b607654e..b2f4843a 100644 --- a/src/arguments/extract.rs +++ b/src/arguments/extract.rs @@ -1,7 +1,7 @@ use crate::otp::otp_element::OTPDatabase; use crate::{clipboard, otp::otp_element::OTPElement}; use clap::Args; -use color_eyre::eyre::eyre; +use eyre::eyre; use super::SubcommandExecutor; @@ -32,7 +32,7 @@ struct ExtractFilter { } impl TryFrom for ExtractFilter { - type Error = color_eyre::eyre::ErrReport; + type Error = eyre::ErrReport; fn try_from(value: ExtractArgs) -> Result { if value.index == Some(0) { @@ -88,7 +88,7 @@ fn wildcard_match(pattern: &str, text: &str) -> bool { } impl SubcommandExecutor for ExtractArgs { - fn run_command(self, otp_database: OTPDatabase) -> color_eyre::Result { + fn run_command(self, otp_database: OTPDatabase) -> eyre::Result { let copy_to_clipboard = self.copy_to_clipboard; let filter: ExtractFilter = self.try_into()?; diff --git a/src/arguments/import.rs b/src/arguments/import.rs index 6e4ee0c5..80717b59 100644 --- a/src/arguments/import.rs +++ b/src/arguments/import.rs @@ -2,7 +2,7 @@ use std::fs::read_to_string; use std::path::PathBuf; use clap::Args; -use color_eyre::eyre::eyre; +use eyre::eyre; use zeroize::Zeroize; use crate::{ @@ -79,7 +79,7 @@ pub struct BackupType { } impl SubcommandExecutor for ImportArgs { - fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { + fn run_command(self, mut database: OTPDatabase) -> eyre::Result { let path = self.path; let backup_type = self.backup_type; @@ -115,7 +115,7 @@ impl SubcommandExecutor for ImportArgs { /// Imports an encrypted Aegis backup, prompting the user for the backup /// password before decrypting it. -fn import_aegis_encrypted(path: PathBuf) -> color_eyre::Result> { +fn import_aegis_encrypted(path: PathBuf) -> eyre::Result> { let json = read_to_string(path)?; let encrypted: AegisEncryptedDatabase = serde_json::from_str(json.as_str()).map_err(|e| { eyre!( diff --git a/src/arguments/list.rs b/src/arguments/list.rs index aa2f7a68..278c8533 100644 --- a/src/arguments/list.rs +++ b/src/arguments/list.rs @@ -1,5 +1,5 @@ use clap::Args; -use color_eyre::eyre::eyre; +use eyre::eyre; use serde::Serialize; use crate::otp::otp_element::{OTPDatabase, OTPElement}; @@ -61,7 +61,7 @@ impl<'a> From<&'a OTPElement> for JsonOtpList<'a> { const NO_ISSUER_TEXT: &str = ""; impl SubcommandExecutor for ListArgs { - fn run_command(self, otp_database: OTPDatabase) -> color_eyre::Result { + fn run_command(self, otp_database: OTPDatabase) -> eyre::Result { if self.format.unwrap_or_default().json { let json_elements = otp_database .elements diff --git a/src/arguments/mod.rs b/src/arguments/mod.rs index d4de5148..d630a8f4 100644 --- a/src/arguments/mod.rs +++ b/src/arguments/mod.rs @@ -1,9 +1,9 @@ use crate::otp::otp_element::OTPDatabase; use crate::{arguments::extract::ExtractArgs, dashboard}; use clap::{Parser, Subcommand}; -use color_eyre::eyre::eyre; use delete::DeleteArgs; use enum_dispatch::enum_dispatch; +use eyre::eyre; use self::{ add::AddArgs, edit::EditArgs, export::ExportArgs, import::ImportArgs, list::ListArgs, @@ -22,7 +22,7 @@ mod passwd; /// Common trait the all the Subcommands must implement to define the command logic #[enum_dispatch] pub trait SubcommandExecutor { - fn run_command(self, otp_database: OTPDatabase) -> color_eyre::Result; + fn run_command(self, otp_database: OTPDatabase) -> eyre::Result; } /// Main structure defining the Clap argument for the cotp commandline utility @@ -61,7 +61,7 @@ pub enum CotpSubcommands { Passwd(PasswdArgs), } -pub fn args_parser(matches: CotpArgs, read_result: OTPDatabase) -> color_eyre::Result { +pub fn args_parser(matches: CotpArgs, read_result: OTPDatabase) -> eyre::Result { if let Some(command) = matches.command { command.run_command(read_result) } else { diff --git a/src/arguments/passwd.rs b/src/arguments/passwd.rs index 1ca741fb..904406b1 100644 --- a/src/arguments/passwd.rs +++ b/src/arguments/passwd.rs @@ -9,7 +9,7 @@ use super::SubcommandExecutor; pub struct PasswdArgs; impl SubcommandExecutor for PasswdArgs { - fn run_command(self, mut database: OTPDatabase) -> color_eyre::Result { + fn run_command(self, mut database: OTPDatabase) -> eyre::Result { let mut new_password = utils::verified_password("New password: ", 8); database.save_with_pw(&new_password)?; new_password.zeroize(); diff --git a/src/clipboard.rs b/src/clipboard.rs index 4e60816d..dc3dcd0c 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -1,11 +1,11 @@ use base64::{Engine as _, engine::general_purpose}; -use color_eyre::eyre::eyre; use copypasta_ext::prelude::*; #[cfg(target_os = "linux")] use copypasta_ext::wayland_bin::WaylandBinClipboardContext; use copypasta_ext::x11_bin::ClipboardContext as BinClipboardContext; use copypasta_ext::x11_fork::ClipboardContext as ForkClipboardContext; use crossterm::style::Print; +use eyre::eyre; use std::{env, io}; pub enum CopyType { @@ -13,7 +13,7 @@ pub enum CopyType { OSC52, } -pub fn copy_string_to_clipboard(content: &str) -> color_eyre::Result { +pub fn copy_string_to_clipboard(content: &str) -> eyre::Result { if ssh_clipboard(content) { Ok(CopyType::OSC52) } else if wayland_clipboard(content) || other_platform_clipboard(content) { diff --git a/src/crypto/cryptography.rs b/src/crypto/cryptography.rs index 0a92b20f..39e251f5 100644 --- a/src/crypto/cryptography.rs +++ b/src/crypto/cryptography.rs @@ -1,8 +1,8 @@ use argon2::{Config, ThreadMode, Variant, Version}; use chacha20poly1305::aead::Aead; use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce}; -use color_eyre::eyre::{ErrReport, eyre}; use data_encoding::BASE64; +use eyre::{ErrReport, eyre}; use super::encrypted_database::EncryptedDatabase; @@ -25,11 +25,11 @@ const KEY_DERIVATION_CONFIG: Config = Config { thread_mode: ThreadMode::Parallel, }; -pub fn argon_derive_key(password_bytes: &[u8], salt: &[u8]) -> color_eyre::Result> { +pub fn argon_derive_key(password_bytes: &[u8], salt: &[u8]) -> eyre::Result> { argon2::hash_raw(password_bytes, salt, &KEY_DERIVATION_CONFIG).map_err(ErrReport::from) } -pub fn gen_salt() -> color_eyre::Result<[u8; ARGON2ID_SALT_LENGTH]> { +pub fn gen_salt() -> eyre::Result<[u8; ARGON2ID_SALT_LENGTH]> { let mut salt: [u8; ARGON2ID_SALT_LENGTH] = [0; ARGON2ID_SALT_LENGTH]; getrandom::fill(&mut salt).map_err(|e| eyre!(e))?; Ok(salt) @@ -39,7 +39,7 @@ pub fn encrypt_string_with_key( plain_text: &str, key: &Vec, salt: &[u8], -) -> color_eyre::Result { +) -> eyre::Result { let aead = XChaCha20Poly1305::new_from_slice(key.as_slice()) .map_err(|e| eyre!("Invalid encryption key length: {e}"))?; let mut nonce_bytes: [u8; XCHACHA20_POLY1305_NONCE_LENGTH] = @@ -62,7 +62,7 @@ pub fn encrypt_string_with_key( pub fn decrypt_string( encrypted_text: &str, password: &str, -) -> color_eyre::Result<(String, Vec, Vec)> { +) -> eyre::Result<(String, Vec, Vec)> { //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}"))?; diff --git a/src/exporters/freeotp_plus.rs b/src/exporters/freeotp_plus.rs index ece11376..1e27a173 100644 --- a/src/exporters/freeotp_plus.rs +++ b/src/exporters/freeotp_plus.rs @@ -1,5 +1,5 @@ -use color_eyre::eyre::{ErrReport, Result}; use data_encoding::BASE32_NOPAD; +use eyre::{ErrReport, Result}; use crate::{ importers::freeotp_plus::{FreeOTPElement, FreeOTPPlusJson}, diff --git a/src/importers/freeotp_plus.rs b/src/importers/freeotp_plus.rs index 1dffa549..4642b074 100644 --- a/src/importers/freeotp_plus.rs +++ b/src/importers/freeotp_plus.rs @@ -94,7 +94,7 @@ mod tests { use std::fs; use crate::otp::otp_element::OTPDatabase; - use color_eyre::Result; + use eyre::Result; use super::{FreeOTPPlusJson, encode_secret}; diff --git a/src/importers/google_authenticator.rs b/src/importers/google_authenticator.rs index 7287a336..692fbb0b 100644 --- a/src/importers/google_authenticator.rs +++ b/src/importers/google_authenticator.rs @@ -15,8 +15,8 @@ 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; +use eyre::{Result, eyre}; use prost::Message; use url::Url; diff --git a/src/importers/importer.rs b/src/importers/importer.rs index d51cd22b..42f9c882 100644 --- a/src/importers/importer.rs +++ b/src/importers/importer.rs @@ -1,6 +1,6 @@ use std::{fmt::Debug, fs::read_to_string, path::PathBuf}; -use color_eyre::eyre::{Result, eyre}; +use eyre::{Result, eyre}; use serde::Deserialize; use crate::otp::otp_element::OTPElement; diff --git a/src/importers/otp_uri.rs b/src/importers/otp_uri.rs index 761dd859..ebaff201 100644 --- a/src/importers/otp_uri.rs +++ b/src/importers/otp_uri.rs @@ -1,7 +1,7 @@ use crate::exporters::otp_uri::OtpUriList; use crate::otp::from_otp_uri::FromOtpUri; use crate::otp::otp_element::OTPElement; -use color_eyre::eyre::ErrReport; +use eyre::ErrReport; impl TryFrom for Vec { type Error = ErrReport; diff --git a/src/main.rs b/src/main.rs index 2acd3193..38d6f638 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ #![forbid(unsafe_code)] use arguments::{CotpArgs, args_parser}; use clap::Parser; -use color_eyre::eyre::eyre; +use eyre::eyre; use interface::app::AppResult; use interface::event::{Event, EventHandler}; use interface::handlers::handle_key_events; @@ -25,7 +25,7 @@ mod path; mod reading; mod utils; -fn init(args: &CotpArgs) -> color_eyre::Result { +fn init(args: &CotpArgs) -> eyre::Result { init_path(args); match utils::init_app() { @@ -52,8 +52,6 @@ fn init(args: &CotpArgs) -> color_eyre::Result { } fn main() -> AppResult<()> { - color_eyre::install()?; - let cotp_args: CotpArgs = CotpArgs::parse(); let (database, mut key, salt) = match init(&cotp_args) { Ok(v) => v, diff --git a/src/otp/from_otp_uri.rs b/src/otp/from_otp_uri.rs index 23d3a05e..4cd867f8 100644 --- a/src/otp/from_otp_uri.rs +++ b/src/otp/from_otp_uri.rs @@ -1,4 +1,4 @@ -use color_eyre::eyre::ErrReport; +use eyre::ErrReport; use url::Url; use super::{ @@ -8,11 +8,11 @@ use super::{ }; pub trait FromOtpUri: Sized { - fn from_otp_uri(otp_uri: &str) -> color_eyre::Result; + fn from_otp_uri(otp_uri: &str) -> eyre::Result; } impl FromOtpUri for OTPElement { - fn from_otp_uri(otp_uri: &str) -> color_eyre::Result { + fn from_otp_uri(otp_uri: &str) -> eyre::Result { // Parse the raw URI: percent-decoding must only ever happen on the // individual components. Decoding the whole URI up front turns encoded // structural characters into real ones (e.g. "%23" -> "#" makes the @@ -85,7 +85,7 @@ impl FromOtpUri for OTPElement { /// The raw segment is percent-decoded first, then split on ':'. Decoding /// before splitting keeps the historical behavior of treating an encoded /// colon ("%3A") as the issuer/label separator (see GH issue 548). -fn issuer_label_segments(parsed_uri: &Url) -> color_eyre::Result> { +fn issuer_label_segments(parsed_uri: &Url) -> eyre::Result> { let raw_segment = parsed_uri .path_segments() .ok_or(ErrReport::msg("Failed to collect path segments"))? @@ -102,7 +102,7 @@ fn issuer_label_segments(parsed_uri: &Url) -> color_eyre::Result> { .collect()) } -fn get_issuer_and_label(parsed_uri: &Url) -> color_eyre::Result<(String, String)> { +fn get_issuer_and_label(parsed_uri: &Url) -> eyre::Result<(String, String)> { // Find the first path segments, OTP Uris should not have others let first_segment = issuer_label_segments(parsed_uri)?; diff --git a/src/otp/migrations/mod.rs b/src/otp/migrations/mod.rs index 8c78bd25..b14a16e7 100644 --- a/src/otp/migrations/mod.rs +++ b/src/otp/migrations/mod.rs @@ -1,7 +1,7 @@ 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 + migration_function: &'a dyn Fn(&mut OTPDatabase) -> eyre::Result<()>, // Function to execute the migration } /// Migrations must be kept sorted by ascending `to_version`; `migrate` relies /// on this ordering and asserts it in debug builds. @@ -10,12 +10,12 @@ const MIGRATIONS_LIST: [Migration; 1] = [Migration { migration_function: &migrate_to_2, }]; -fn migrate_to_2(database: &mut OTPDatabase) -> color_eyre::Result<()> { +fn migrate_to_2(database: &mut OTPDatabase) -> eyre::Result<()> { database.version = 2; Ok(()) } -pub fn migrate(database: &mut OTPDatabase) -> color_eyre::Result<()> { +pub fn migrate(database: &mut OTPDatabase) -> eyre::Result<()> { debug_assert!( MIGRATIONS_LIST.is_sorted_by_key(|m| m.to_version), "MIGRATIONS_LIST must be sorted by to_version" diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index 1e16d696..3ae464d9 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -1,5 +1,5 @@ -use color_eyre::eyre::{ErrReport, eyre}; use derive_builder::Builder; +use eyre::{ErrReport, eyre}; use std::{fs::File, io::Write, vec}; use crate::crypto::cryptography::{argon_derive_key, encrypt_string_with_key, gen_salt}; @@ -78,7 +78,7 @@ impl OTPDatabase { self.needs_modification } - pub fn save(&mut self, key: &Vec, salt: &[u8]) -> color_eyre::Result<()> { + pub fn save(&mut self, key: &Vec, salt: &[u8]) -> eyre::Result<()> { self.needs_modification = false; migrate(self)?; match self.overwrite_database_key(key, salt) { @@ -105,7 +105,7 @@ impl OTPDatabase { } } - pub fn save_with_pw(&mut self, password: &str) -> color_eyre::Result<(Vec, [u8; 16])> { + pub fn save_with_pw(&mut self, password: &str) -> eyre::Result<(Vec, [u8; 16])> { let salt = gen_salt()?; let key = argon_derive_key(password.as_bytes(), &salt)?; self.save(&key, &salt)?; diff --git a/src/reading.rs b/src/reading.rs index 017c34bf..fa4d1829 100644 --- a/src/reading.rs +++ b/src/reading.rs @@ -2,32 +2,32 @@ 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 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 { +pub fn get_elements_from_input() -> eyre::Result { let pw = utils::password("Password: ", 8); get_elements_with_password(pw) } -pub fn get_elements_from_stdin() -> color_eyre::Result { +pub fn get_elements_from_stdin() -> 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 { +fn get_elements_with_password(mut password: String) -> 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)> { +pub fn read_decrypted_text(password: &str) -> eyre::Result<(String, Vec, Vec)> { let encrypted_contents = read_to_string(DATABASE_PATH.get().unwrap()).map_err(ErrReport::from)?; if encrypted_contents.is_empty() { @@ -44,7 +44,7 @@ pub fn read_decrypted_text(password: &str) -> color_eyre::Result<(String, Vec color_eyre::Result { +pub fn read_from_file(password: &str) -> eyre::Result { match read_decrypted_text(password) { Ok((mut contents, key, salt)) => { let mut database: OTPDatabase = serde_json::from_str(&contents) From 5839417c5837eeb5acda87008e517bb2f4233873 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:20:48 +0200 Subject: [PATCH 39/49] build(deps): consolidate hex/base64 encoding onto data-encoding cotp already depends on data-encoding for base32/base64 in several modules; the hex and base64 crates only served a handful of remaining call sites. Routing those through data-encoding removes two direct dependencies and their duplicate encode/decode code paths. Replacements: - src/otp/otp_element.rs: hex::decode -> HEXLOWER_PERMISSIVE.decode (keeps accepting mixed-case hex secrets, as the hex crate did) - src/otp/algorithms/motp_maker.rs: hex::encode -> HEXLOWER.encode - src/importers/aegis_encrypted.rs: hex::FromHex -> a decode_hex helper over HEXLOWER_PERMISSIVE (Aegis writes lowercase, stays permissive) - src/clipboard.rs: base64 STANDARD engine -> BASE64.encode (same padded standard alphabet for the OSC52 escape sequence) - src/importers/google_authenticator.rs: base64 STANDARD engine -> BASE64.decode with a BASE64_NOPAD fallback. The old engine required canonical padding; the fallback additionally accepts migration URIs whose trailing '=' was stripped by a QR scanner (new test covers it). Semantic notes: - The invalid-hex error text changes from the hex crate's "Odd number of digits" to data-encoding's "invalid length at N" (test updated). - base64 remains in Cargo.lock as a transitive dependency of rust-argon2; hex is gone entirely. --- Cargo.lock | 2 -- Cargo.toml | 2 -- src/clipboard.rs | 4 +-- src/importers/aegis_encrypted.rs | 22 ++++++++------ src/importers/google_authenticator.rs | 43 +++++++++++++++++++++++---- src/otp/algorithms/motp_maker.rs | 3 +- src/otp/otp_element.rs | 7 +++-- 7 files changed, 58 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 815f5711..d33a607e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -467,7 +467,6 @@ dependencies = [ "aes-gcm", "assert_cmd", "assert_fs", - "base64", "chacha20poly1305", "clap", "copypasta-ext", @@ -478,7 +477,6 @@ dependencies = [ "enum_dispatch", "eyre", "getrandom 0.4.3", - "hex", "hmac", "md-5", "predicates", diff --git a/Cargo.toml b/Cargo.toml index 634abd82..7a26579d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,10 +43,8 @@ getrandom = "0.4.3" rust-argon2 = "3.0.0" scrypt = "0.12.0" aes-gcm = "0.11.0" -hex = "0.4.3" qrcode = "0.14.1" urlencoding = "2.1.3" -base64 = "0.22.1" md-5 = "0.11.0" ratatui = { version = "0.30.2", features = ["all-widgets"] } crossterm = "0.29.0" diff --git a/src/clipboard.rs b/src/clipboard.rs index dc3dcd0c..e78ffa8e 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -1,10 +1,10 @@ -use base64::{Engine as _, engine::general_purpose}; use copypasta_ext::prelude::*; #[cfg(target_os = "linux")] use copypasta_ext::wayland_bin::WaylandBinClipboardContext; use copypasta_ext::x11_bin::ClipboardContext as BinClipboardContext; use copypasta_ext::x11_fork::ClipboardContext as ForkClipboardContext; use crossterm::style::Print; +use data_encoding::BASE64; use eyre::eyre; use std::{env, io}; @@ -32,7 +32,7 @@ fn ssh_clipboard(content: &str) -> bool { io::stderr(), Print(format!( "\x1B]52;c;{}\x07", - general_purpose::STANDARD.encode(content) + BASE64.encode(content.as_bytes()) )) ) .is_ok() diff --git a/src/importers/aegis_encrypted.rs b/src/importers/aegis_encrypted.rs index d5abcc89..8ae107e3 100644 --- a/src/importers/aegis_encrypted.rs +++ b/src/importers/aegis_encrypted.rs @@ -1,7 +1,6 @@ use aes_gcm::aead::{Aead, Nonce}; use aes_gcm::{Aes256Gcm, KeyInit}; // Or `Aes128Gcm` -use data_encoding::BASE64; -use hex::FromHex; +use data_encoding::{BASE64, DecodeError, HEXLOWER_PERMISSIVE}; use serde::Deserialize; use zeroize::Zeroize; @@ -58,14 +57,14 @@ impl AegisEncryptedDatabase { .map_err(|e| format!("Invalid master key length: {e:?}"))?; master_key.zeroize(); - let nonce_bytes = Vec::from_hex(&self.header.params.nonce) + let nonce_bytes = decode_hex(&self.header.params.nonce) .map_err(|e| format!("Failed to parse hex nonce: {e:?}"))?; let nonce = Nonce::::try_from(nonce_bytes.as_slice()) .map_err(|e| format!("Invalid nonce length: {e:?}"))?; let payload = [ content, - Vec::from_hex(&self.header.params.tag) + decode_hex(&self.header.params.tag) .map_err(|e| format!("Failed to parse hex tag: {e:?}"))?, ] .concat(); @@ -81,6 +80,12 @@ impl AegisEncryptedDatabase { } } +/// Decodes a hex string, accepting both lower- and uppercase digits like the +/// previously used `hex` crate did (Aegis itself writes lowercase). +fn decode_hex(input: &str) -> Result, DecodeError> { + HEXLOWER_PERMISSIVE.decode(input.as_bytes()) +} + fn get_master_key(aegis_encrypted: &AegisEncryptedDatabase, password: &str) -> Option> { let mut master_key: Option> = None; for slot in aegis_encrypted @@ -134,7 +139,7 @@ fn get_params(slot: &AegisEncryptedSlot) -> Result { fn calc_master_key(slot: &AegisEncryptedSlot, password: &str) -> Result, String> { let salt_hex = slot.salt.as_ref().ok_or("Missing salt in backup slot")?; - let salt = Vec::from_hex(salt_hex).map_err(|e| format!("Failed to parse hex salt: {e:?}"))?; + let salt = decode_hex(salt_hex).map_err(|e| format!("Failed to parse hex salt: {e:?}"))?; let mut output: [u8; 32] = [0; 32]; let params = get_params(slot)?; @@ -151,13 +156,12 @@ fn calc_master_key(slot: &AegisEncryptedSlot, password: &str) -> Result, output.zeroize(); let cipher_text = [ - Vec::from_hex(&slot.key).map_err(|e| format!("Failed to parse hex key: {e:?}"))?, - Vec::from_hex(&slot.key_params.tag) - .map_err(|e| format!("Failed to parse hex tag: {e:?}"))?, + decode_hex(&slot.key).map_err(|e| format!("Failed to parse hex key: {e:?}"))?, + decode_hex(&slot.key_params.tag).map_err(|e| format!("Failed to parse hex tag: {e:?}"))?, ] .concat(); - let nonce_bytes = Vec::from_hex(&slot.key_params.nonce) + let nonce_bytes = decode_hex(&slot.key_params.nonce) .map_err(|e| format!("Failed to parse hex nonce: {e:?}"))?; let nonce = Nonce::::try_from(nonce_bytes.as_slice()) .map_err(|e| format!("Invalid nonce length: {e:?}"))?; diff --git a/src/importers/google_authenticator.rs b/src/importers/google_authenticator.rs index 692fbb0b..10903c0b 100644 --- a/src/importers/google_authenticator.rs +++ b/src/importers/google_authenticator.rs @@ -14,8 +14,7 @@ use std::{fs::read_to_string, path::PathBuf}; -use base64::{Engine as _, engine::general_purpose}; -use data_encoding::BASE32_NOPAD; +use data_encoding::{BASE32_NOPAD, BASE64, BASE64_NOPAD}; use eyre::{Result, eyre}; use prost::Message; use url::Url; @@ -105,9 +104,12 @@ fn parse_migration_uri(uri: &str) -> Result> { .ok_or_else(|| eyre!("Missing 'data' parameter in otpauth-migration URI"))?; // The url crate already percent-decodes the value, so we can decode the - // raw base64 (standard alphabet, with padding) directly. - let decoded = general_purpose::STANDARD + // raw base64 (standard alphabet) directly. Some QR scanners strip the + // trailing `=` padding when extracting the URI, so fall back to unpadded + // decoding instead of rejecting those exports. + let decoded = BASE64 .decode(data.as_bytes()) + .or_else(|_| BASE64_NOPAD.decode(data.as_bytes())) .map_err(|e| eyre!("Invalid base64 in otpauth-migration data: {e}"))?; let payload = MigrationPayload::decode(decoded.as_slice()) @@ -172,7 +174,7 @@ mod tests { otp_parameters: entries, } .encode_to_vec(); - let data = general_purpose::STANDARD.encode(&payload); + let data = BASE64.encode(&payload); let encoded = urlencoding::encode(&data).into_owned(); format!("otpauth-migration://offline?data={encoded}") } @@ -285,6 +287,35 @@ mod tests { assert!(err.to_string().contains("No otpauth-migration")); } + #[test] + fn accepts_unpadded_base64_data() { + // Some QR scanners strip the trailing '=' padding from the migration + // URI; the importer must still decode it. + // Vary the label length so at least one payload is not a multiple of + // three bytes and therefore carries '=' padding in its base64 form. + let uri = (0..3) + .map(|i| { + build_uri(vec![otp_parameters( + b"Hello", + &"a".repeat(10 + i), + "Example", + 1, + 1, + 2, + 0, + )]) + }) + .find(|uri| uri.ends_with("%3D")) + .expect("at least one fixture must carry base64 padding"); + let unpadded = uri.trim_end_matches("%3D"); + assert_ne!(uri, unpadded); + + let elements = import_from_string(unpadded).unwrap(); + + assert_eq!(elements.len(), 1); + assert_eq!(elements[0].secret, "JBSWY3DP"); + } + #[test] fn errors_on_invalid_base64() { let err = import_from_string("otpauth-migration://offline?data=not*base64").unwrap_err(); @@ -294,7 +325,7 @@ mod tests { #[test] fn errors_on_invalid_protobuf() { // Valid base64 but not a valid protobuf message. - let data = general_purpose::STANDARD.encode([0xff, 0xff, 0xff, 0xff]); + let data = BASE64.encode(&[0xff, 0xff, 0xff, 0xff]); let encoded = urlencoding::encode(&data).into_owned(); let uri = format!("otpauth-migration://offline?data={encoded}"); let err = import_from_string(&uri).unwrap_err(); diff --git a/src/otp/algorithms/motp_maker.rs b/src/otp/algorithms/motp_maker.rs index 75b7b880..e7d7da71 100644 --- a/src/otp/algorithms/motp_maker.rs +++ b/src/otp/algorithms/motp_maker.rs @@ -1,3 +1,4 @@ +use data_encoding::HEXLOWER; use md5::{Digest, Md5}; use std::time::SystemTime; @@ -30,7 +31,7 @@ fn get_motp_code( let mut md5_hasher = Md5::new(); md5_hasher.update(data.as_bytes()); - let code = hex::encode(md5_hasher.finalize()); + let code = HEXLOWER.encode(&md5_hasher.finalize()); Ok(code.as_str()[0..digits].to_owned()) } diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index 3ae464d9..4e03f60f 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -5,7 +5,7 @@ use std::{fs::File, io::Write, vec}; use crate::crypto::cryptography::{argon_derive_key, encrypt_string_with_key, gen_salt}; use crate::otp::otp_error::OtpError; use crate::path::DATABASE_PATH; -use data_encoding::BASE32_NOPAD; +use data_encoding::{BASE32_NOPAD, HEXLOWER_PERMISSIVE}; use qrcode::QrCode; use qrcode::render::unicode; use serde::{Deserialize, Serialize}; @@ -334,7 +334,8 @@ impl OTPElementBuilder { // Validate secret encoding match self.type_.unwrap_or_default() { - OTPType::Motp => hex::decode(self.secret.as_ref().unwrap()) + OTPType::Motp => HEXLOWER_PERMISSIVE + .decode(self.secret.as_ref().unwrap().as_bytes()) .map(|_| {}) .map_err(|e| eyre!("Invalid hex secret: {e}")), _ => BASE32_NOPAD @@ -567,7 +568,7 @@ mod test { .build(); assert_eq!( - "Invalid hex secret: Odd number of digits", + "Invalid hex secret: invalid length at 2", result.unwrap_err().to_string() ); } From 12df2d3e9eca2f65e1429b44fb6b1f43703b3627 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:22:45 +0200 Subject: [PATCH 40/49] build(deps): trim ratatui/qrcode features and pin idna_adapter Three independent dependency-weight cuts, none of which change any user-visible behavior: - ratatui: default features minus "all-widgets" and "macros". The TUI only uses Block, Borders, Cell, Clear, Gauge, Paragraph, Row, Table and Wrap - all available without extra features. In ratatui 0.30 the default feature set itself enables all-widgets (= widget-calendar), which drags the `time` crate into every build; cotp also never uses the ratatui-macros crate. Kept: crossterm, layout-cache, underline-color. - qrcode: default-features = false. cotp only renders unicode QR strings for `cotp list --qrcode`; the default "image" feature compiles the whole `image` crate for PNG rendering that is never called. - idna_adapter = "~1.0" pinned as a direct dependency so `url` resolves its IDNA backend to the small unicode-rs adapter instead of the ICU4X normalizer/properties tables. cotp only parses otpauth:// URIs and never needs internationalized domain names. Measured -131 KB on the release binary. cargo tree -e normal | grep -iE 'icu|image|time': before: icu_collections, icu_locale_core, icu_normalizer(+data), icu_properties(+data), icu_provider, image v0.25.9, time v0.3.47, time-core v0.1.8 after: (no matches; remaining time/wait-timeout hits are dev-dependencies of assert_cmd only) --- Cargo.lock | 284 ++--------------------------------------------------- Cargo.toml | 15 ++- 2 files changed, 21 insertions(+), 278 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d33a607e..71de7df5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,12 +270,6 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - [[package]] name = "bytes" version = "1.12.1" @@ -478,6 +472,7 @@ dependencies = [ "eyre", "getrandom 0.4.3", "hmac", + "idna_adapter", "md-5", "predicates", "prost", @@ -816,18 +811,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "windows-sys 0.61.2", ] [[package]] @@ -885,7 +869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1131,87 +1115,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - [[package]] name = "ident_case" version = "1.0.1" @@ -1231,13 +1134,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] +checksum = "cfdf4f5d937a025381f5ab13624b1c5f51414bfe5c9885663226eae8d6d39560" [[package]] name = "ignore" @@ -1255,18 +1154,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "image" -version = "0.25.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" -dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", -] - [[package]] name = "indenter" version = "0.3.4" @@ -1416,12 +1303,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - [[package]] name = "litrs" version = "1.0.0" @@ -1538,16 +1419,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "moxcms" -version = "0.7.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" -dependencies = [ - "num-traits", - "pxfm", -] - [[package]] name = "nix" version = "0.24.3" @@ -1871,15 +1742,6 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - [[package]] name = "powerfmt" version = "0.2.0" @@ -1948,20 +1810,11 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "pxfm" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" - [[package]] name = "qrcode" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" -dependencies = [ - "image", -] [[package]] name = "quote" @@ -2014,7 +1867,6 @@ dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", - "ratatui-macros", "ratatui-termina", "ratatui-termwiz", "ratatui-widgets", @@ -2055,16 +1907,6 @@ dependencies = [ "ratatui-core", ] -[[package]] -name = "ratatui-macros" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" -dependencies = [ - "ratatui-core", - "ratatui-widgets", -] - [[package]] name = "ratatui-termina" version = "0.1.0" @@ -2220,7 +2062,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2431,12 +2273,6 @@ dependencies = [ "wayland-client", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "static_assertions" version = "1.1.0" @@ -2509,17 +2345,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -2529,7 +2354,7 @@ dependencies = [ "fastrand", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2708,16 +2533,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "typenum" version = "1.20.1" @@ -3084,7 +2899,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3205,12 +3020,6 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - [[package]] name = "x11-clipboard" version = "0.7.1" @@ -3254,50 +3063,6 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - [[package]] name = "zeroize" version = "1.9.0" @@ -3318,39 +3083,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 7a26579d..07926c7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,12 +43,23 @@ getrandom = "0.4.3" rust-argon2 = "3.0.0" scrypt = "0.12.0" aes-gcm = "0.11.0" -qrcode = "0.14.1" +qrcode = { version = "0.14.1", default-features = false } urlencoding = "2.1.3" md-5 = "0.11.0" -ratatui = { version = "0.30.2", features = ["all-widgets"] } +# Default features minus "all-widgets" (widget-calendar), which the TUI never +# uses and which drags the `time` crate into every build, and minus "macros", +# which cotp does not use either. +ratatui = { version = "0.30.2", default-features = false, features = [ + "crossterm", + "layout-cache", + "underline-color", +] } crossterm = "0.29.0" url = "2.5.8" +# Direct pin of url's IDNA backend to the unicode-rs adapter, keeping the much +# larger ICU4X tables out of the binary. cotp only parses otpauth:// URIs and +# never needs internationalized domain names. +idna_adapter = "~1.0" eyre = "0.6.12" enum_dispatch = "0.3.13" derive_builder = "0.20.2" From 004cdfa160a9f6d27a5779b19f66567d1156bf3c Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:25:12 +0200 Subject: [PATCH 41/49] fix(cli): clean up error handling, exit codes and dead event variants Four related cleanups on the binary entry path: - Print all startup errors to stderr. Init/database errors went to stdout while subcommand errors went to stderr; stdout must stay clean for piping (the TUI already runs on stderr for the same reason). - Use conventional exit codes. exit(-1)/exit(-2) wrap to 255/254 on POSIX; main now returns std::process::ExitCode with 1 (init or save failure) and 2 (subcommand failure). The derived key is still zeroized on every path before returning. - Drop dead Event variants. Event::Mouse(()), Resize((), ()), FocusGained(), FocusLost() and Paste(()) carried erased unit payloads through the channel only for main.rs to ignore them; the event thread now drops those crossterm events at the source and the enum shrinks to Tick and Key. The thread still exits when a send fails (receiver dropped). - Propagate password-prompt failures. utils::password / verified_password printed and exited the process on prompt errors (e.g. stdin not a TTY); their callers (main.rs, arguments/passwd.rs, arguments/import.rs, reading.rs) now use the Result-returning try_password / try_verified_password and bubble the error up, and the exit-on-failure wrappers are removed. utils::init_app also returns a proper eyre error (including the directory and the underlying io::Error) instead of Err(()) that main.rs replaced with a generic message. --- src/arguments/import.rs | 2 +- src/arguments/passwd.rs | 2 +- src/interface/event.rs | 34 +++++++---------------- src/main.rs | 61 ++++++++++++++++++----------------------- src/reading.rs | 2 +- src/utils.rs | 36 ++++++++---------------- 6 files changed, 50 insertions(+), 87 deletions(-) diff --git a/src/arguments/import.rs b/src/arguments/import.rs index 80717b59..4c8ef5cf 100644 --- a/src/arguments/import.rs +++ b/src/arguments/import.rs @@ -128,7 +128,7 @@ fn import_aegis_encrypted(path: PathBuf) -> eyre::Result> { ) })?; - let mut password = utils::password("Insert your Aegis password: ", 0); + let mut password = utils::try_password("Insert your Aegis password: ", 0)?; let result = encrypted.decrypt(password.as_str()); password.zeroize(); diff --git a/src/arguments/passwd.rs b/src/arguments/passwd.rs index 904406b1..1a8ad114 100644 --- a/src/arguments/passwd.rs +++ b/src/arguments/passwd.rs @@ -10,7 +10,7 @@ pub struct PasswdArgs; impl SubcommandExecutor for PasswdArgs { fn run_command(self, mut database: OTPDatabase) -> eyre::Result { - let mut new_password = utils::verified_password("New password: ", 8); + let mut new_password = utils::try_verified_password("New password: ", 8)?; database.save_with_pw(&new_password)?; new_password.zeroize(); Ok(database) diff --git a/src/interface/event.rs b/src/interface/event.rs index 703480e1..1fe0d86f 100644 --- a/src/interface/event.rs +++ b/src/interface/event.rs @@ -6,23 +6,14 @@ use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, KeyEventKind}; use crate::interface::app::AppResult; -/// Terminal events. +/// Terminal events the dashboard reacts to. Everything else read from the +/// terminal (mouse, resize, focus, paste) is ignored at the source. #[derive(Clone, Debug)] pub enum Event { /// Terminal tick. Tick, /// Key press. Key(KeyEvent), - /// Mouse click/scroll. - Mouse(()), - /// Terminal resize. - Resize((), ()), - /// Focus gained - FocusGained(), - /// Focus lost - FocusLost(), - /// Paste text - Paste(()), } /// Terminal event handler. @@ -50,20 +41,15 @@ impl EventHandler { if event::poll(timeout).expect("no events available") { let send_result = match event::read().expect("unable to read event") { - CrosstermEvent::Key(e) => { - // Workaround to fix double input on Windows - // Please check https://github.com/crossterm-rs/crossterm/issues/752 - if e.kind == KeyEventKind::Press { - sender.send(Event::Key(e)) - } else { - Ok(()) - } + // Workaround to fix double input on Windows + // Please check https://github.com/crossterm-rs/crossterm/issues/752 + CrosstermEvent::Key(e) if e.kind == KeyEventKind::Press => { + sender.send(Event::Key(e)) } - CrosstermEvent::Mouse(_e) => sender.send(Event::Mouse(())), - CrosstermEvent::Resize(_w, _h) => sender.send(Event::Resize((), ())), - CrosstermEvent::FocusGained => sender.send(Event::FocusGained()), - CrosstermEvent::FocusLost => sender.send(Event::FocusLost()), - CrosstermEvent::Paste(_e) => sender.send(Event::Paste(())), + // Mouse, resize, focus and paste events are irrelevant + // to the dashboard: drop them here instead of routing + // dead variants through the channel. + _ => Ok(()), }; if send_result.is_err() { // The receiver has been dropped: the dashboard has diff --git a/src/main.rs b/src/main.rs index 38d6f638..a4e4a709 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ #![forbid(unsafe_code)] use arguments::{CotpArgs, args_parser}; use clap::Parser; -use eyre::eyre; use interface::app::AppResult; use interface::event::{Event, EventHandler}; use interface::handlers::handle_key_events; @@ -11,6 +10,7 @@ use path::init_path; use ratatui::Terminal; use ratatui::prelude::CrosstermBackend; use reading::{ReadResult, get_elements_from_input, get_elements_from_stdin}; +use std::process::ExitCode; use std::{io, vec}; use zeroize::Zeroize; @@ -28,36 +28,32 @@ mod utils; fn init(args: &CotpArgs) -> eyre::Result { init_path(args); - match utils::init_app() { - Ok(first_run) => { - 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() - } - } - Err(()) => Err(eyre!("An error occurred during database creation")), + let first_run = utils::init_app()?; + if first_run { + // Let's initialize the database file + let mut pw = utils::try_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() } } -fn main() -> AppResult<()> { +fn main() -> ExitCode { let cotp_args: CotpArgs = CotpArgs::parse(); let (database, mut key, salt) = match init(&cotp_args) { Ok(v) => v, Err(e) => { - println!("{e}"); - std::process::exit(-1); + eprintln!("An error occurred: {e}"); + return ExitCode::from(1); } }; @@ -66,26 +62,26 @@ fn main() -> AppResult<()> { Err(e) => { eprintln!("An error occurred: {e}"); key.zeroize(); - std::process::exit(-2) + return ExitCode::from(2); } }; - let error_code = if reowned_database.is_modified() { + let exit_code = if reowned_database.is_modified() { match reowned_database.save(&key, &salt) { Ok(()) => { println!("Modifications have been persisted"); - 0 + ExitCode::SUCCESS } _ => { eprintln!("An error occurred during database overwriting"); - -1 + ExitCode::from(1) } } } else { - 0 + ExitCode::SUCCESS }; key.zeroize(); - std::process::exit(error_code) + exit_code } fn dashboard(mut database: OTPDatabase) -> AppResult { @@ -110,11 +106,6 @@ fn dashboard(mut database: OTPDatabase) -> AppResult { match tui.events.next()? { Event::Tick => app.tick(false), Event::Key(key_event) => handle_key_events(key_event, &mut app), - Event::Mouse(()) - | Event::Resize((), ()) - | Event::FocusGained() - | Event::FocusLost() - | Event::Paste(()) => {} } } diff --git a/src/reading.rs b/src/reading.rs index fa4d1829..518257c7 100644 --- a/src/reading.rs +++ b/src/reading.rs @@ -10,7 +10,7 @@ use zeroize::Zeroize; pub type ReadResult = (OTPDatabase, Vec, Vec); pub fn get_elements_from_input() -> eyre::Result { - let pw = utils::password("Password: ", 8); + let pw = utils::try_password("Password: ", 8)?; get_elements_with_password(pw) } diff --git a/src/utils.rs b/src/utils.rs index c5bd2c01..6d6b7fb5 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,11 +1,12 @@ use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; +use eyre::eyre; use zeroize::Zeroize; use crate::path::DATABASE_PATH; -pub fn init_app() -> Result { +pub fn init_app() -> eyre::Result { let db_path = DATABASE_PATH.get().unwrap(); // Safe to unwrap because we initialize // Decide whether this is a first run from the database file itself: relying on @@ -25,11 +26,10 @@ pub fn init_app() -> Result { if !db_dir.exists() && let Err(e) = std::fs::create_dir_all(db_dir) { - eprintln!( + return Err(eyre!( "Cannot create the database directory {}: {e}", db_dir.display() - ); - return Err(()); + )); } Ok(true) } @@ -45,27 +45,11 @@ pub fn percentage() -> u16 { (millis_before_next_step() * 100 / 30000) as u16 } -pub fn password(message: &str, minimum_length: usize) -> String { - try_password(message, minimum_length).unwrap_or_else(exit_on_prompt_error) -} - -pub fn verified_password(message: &str, minimum_length: usize) -> String { - try_verified_password(message, minimum_length).unwrap_or_else(exit_on_prompt_error) -} - -/// Reading the password can fail (e.g. stdin is not a TTY): print a clear -/// message and exit instead of panicking with a backtrace. +/// Prompts for a password of at least `minimum_length` characters. /// -/// The String-returning wrappers above are kept because their callers -/// (main.rs, passwd.rs, reading.rs, aegis_encrypted.rs) expect an infallible -/// signature; they should eventually be migrated to the Result-returning -/// variants. -fn exit_on_prompt_error(error: std::io::Error) -> String { - eprintln!("Cannot read the password: {error}"); - std::process::exit(-1); -} - -fn try_password(message: &str, minimum_length: usize) -> std::io::Result { +/// Reading the password can fail (e.g. stdin is not a TTY): the error is +/// propagated to the caller instead of exiting the process. +pub fn try_password(message: &str, minimum_length: usize) -> std::io::Result { loop { let mut password = rpassword::prompt_password(message)?; if password.chars().count() < minimum_length { @@ -77,7 +61,9 @@ fn try_password(message: &str, minimum_length: usize) -> std::io::Result } } -fn try_verified_password(message: &str, minimum_length: usize) -> std::io::Result { +/// Like [`try_password`], but asks the user to retype the password until both +/// entries match. +pub fn try_verified_password(message: &str, minimum_length: usize) -> std::io::Result { loop { let mut password = try_password(message, minimum_length)?; let mut verify_password = match rpassword::prompt_password("Retype the same password: ") { From 3fff320432c7392c746dcf3b8405e66902d74683 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:30:40 +0200 Subject: [PATCH 42/49] refactor(cli): dispatch import/export formats through exhaustive enums Both the import and export subcommands modeled their mutually exclusive format flags as a struct of booleans and dispatched through an if/else ladder. The import ladder ended in a reachable Err("Invalid arguments provided") fallthrough, and the export ladder ended in unreachable!() guarded only by an odd Default impl that set cotp: true. Convert the parsed flags into proper enums instead: - import: BackupType::format() maps the flags to a new ImportFormat enum via a flag table; run_command dispatches with a single exhaustive match, including the special-cased password-prompting import_aegis_encrypted() path. The Authy / Microsoft Authenticator / FreeOTP flags share one arm since all three import the intermediate ConvertedJsonList produced by the Python converters. - export: ExportFormat::kind() maps the flags to a new ExportKind enum; the strange Default for ExportFormat impl is deleted and the "no flag means cotp format" default is expressed explicitly at the call site with map_or. The CLI surface (flag names, short options, ArgGroup semantics, help output) is unchanged; --help output was verified byte-identical before and after. Also add an integration test asserting that 'cotp delete' without any of --index/--issuer/--label is rejected by clap (via required_unless_present_any) instead of falling through to an empty-string issuer/label match that would target the first element. --- src/arguments/export.rs | 70 +++++++++++++++++++--------- src/arguments/import.rs | 84 +++++++++++++++++++++++++--------- tests/cli_integration_tests.rs | 14 ++++++ 3 files changed, 124 insertions(+), 44 deletions(-) diff --git a/src/arguments/export.rs b/src/arguments/export.rs index 9f7f24c6..d9fc0249 100644 --- a/src/arguments/export.rs +++ b/src/arguments/export.rs @@ -42,39 +42,65 @@ pub struct ExportFormat { pub freeotp_plus: bool, } -impl Default for ExportFormat { - fn default() -> Self { - Self { - cotp: true, - andotp: false, - otp_uri: false, - freeotp_plus: false, - } +/// The export format selected on the command line, derived from the mutually +/// exclusive [`ExportFormat`] flags. +#[derive(Clone, Copy)] +enum ExportKind { + Cotp, + Andotp, + OtpUri, + FreeOtpPlus, +} + +impl ExportFormat { + /// Maps the mutually exclusive clap flags to the selected export format. + /// + /// The clap `ArgGroup` on [`ExportFormat`] (`multiple = false`) combined + /// with the `Option` flattening in [`ExportArgs`] guarantees exactly one + /// flag is set whenever this struct is present, so exactly one entry of + /// the table below is enabled. + fn kind(&self) -> ExportKind { + let flag_table = [ + (self.cotp, ExportKind::Cotp), + (self.andotp, ExportKind::Andotp), + (self.otp_uri, ExportKind::OtpUri), + (self.freeotp_plus, ExportKind::FreeOtpPlus), + ]; + flag_table + .into_iter() + .find_map(|(enabled, kind)| enabled.then_some(kind)) + .expect("clap ArgGroup guarantees exactly one export format flag") } } impl SubcommandExecutor for ExportArgs { fn run_command(self, database: OTPDatabase) -> eyre::Result { - let export_format = self.format.unwrap_or_default(); + // Exporting to the cotp format when no flag is given keeps the + // historical default behavior. + let export_kind = self + .format + .as_ref() + .map_or(ExportKind::Cotp, ExportFormat::kind); let exported_path = if self.path.is_dir() { self.path.join("exported.cotp") } else { self.path }; - if export_format.cotp { - do_export(&database, exported_path) - } else if export_format.andotp { - let andotp: &Vec = (&database).into(); - do_export(&andotp, exported_path) - } else if export_format.otp_uri { - let otp_uri_list: OtpUriList = (&database).into(); - do_export(&otp_uri_list, exported_path) - } else if export_format.freeotp_plus { - let freeotp_plus: FreeOTPPlusJson = (&database).try_into()?; - do_export(&freeotp_plus, exported_path) - } else { - unreachable!("Unreachable code"); + match export_kind { + ExportKind::Cotp => do_export(&database, exported_path), + ExportKind::Andotp => { + let andotp: &Vec = (&database).into(); + do_export(&andotp, exported_path) + } + ExportKind::OtpUri => { + let otp_uri_list: OtpUriList = (&database).into(); + do_export(&otp_uri_list, exported_path) + } + ExportKind::FreeOtpPlus => { + let freeotp_plus: FreeOTPPlusJson = (&database).try_into()?; + do_export(&freeotp_plus, exported_path) + } } .map(|path| { println!( diff --git a/src/arguments/import.rs b/src/arguments/import.rs index 4c8ef5cf..8da48566 100644 --- a/src/arguments/import.rs +++ b/src/arguments/import.rs @@ -78,32 +78,72 @@ pub struct BackupType { pub otp_uri: bool, } +/// The backup format selected on the command line, derived from the mutually +/// exclusive [`BackupType`] flags. +#[derive(Clone, Copy)] +enum ImportFormat { + Cotp, + Andotp, + Aegis, + AegisEncrypted, + FreeOtpPlus, + FreeOtp, + GoogleAuthenticator, + Authy, + AuthyExported, + MicrosoftAuthenticator, + OtpUri, +} + +impl BackupType { + /// Maps the mutually exclusive clap flags to the selected import format. + /// + /// The clap `ArgGroup` on [`BackupType`] (`required = true, multiple = + /// false`) guarantees exactly one flag is set, so exactly one entry of + /// the table below is enabled. + fn format(&self) -> ImportFormat { + let flag_table = [ + (self.cotp, ImportFormat::Cotp), + (self.andotp, ImportFormat::Andotp), + (self.aegis, ImportFormat::Aegis), + (self.aegis_encrypted, ImportFormat::AegisEncrypted), + (self.freeotp_plus, ImportFormat::FreeOtpPlus), + (self.freeotp, ImportFormat::FreeOtp), + (self.google_authenticator, ImportFormat::GoogleAuthenticator), + (self.authy, ImportFormat::Authy), + (self.authy_exported, ImportFormat::AuthyExported), + ( + self.microsoft_authenticator, + ImportFormat::MicrosoftAuthenticator, + ), + (self.otp_uri, ImportFormat::OtpUri), + ]; + flag_table + .into_iter() + .find_map(|(enabled, format)| enabled.then_some(format)) + .expect("clap ArgGroup guarantees exactly one import format flag") + } +} + impl SubcommandExecutor for ImportArgs { fn run_command(self, mut database: OTPDatabase) -> eyre::Result { let path = self.path; - let backup_type = self.backup_type; - - 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_aegis_encrypted(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) - } else { - return Err(eyre!("Invalid arguments provided")); + let result = match self.backup_type.format() { + ImportFormat::Cotp => import_from_path::(path), + ImportFormat::Andotp => import_from_path::>(path), + ImportFormat::Aegis => import_from_path::(path), + ImportFormat::AegisEncrypted => import_aegis_encrypted(path), + ImportFormat::FreeOtpPlus => import_from_path::(path), + ImportFormat::AuthyExported => import_from_path::(path), + ImportFormat::GoogleAuthenticator => import_from_google_authenticator(path), + // Authy, Microsoft Authenticator and FreeOTP backups are + // pre-converted by the Python scripts in converters/ into the + // same intermediate JSON shape. + ImportFormat::Authy | ImportFormat::MicrosoftAuthenticator | ImportFormat::FreeOtp => { + import_from_path::(path) + } + ImportFormat::OtpUri => import_from_path::(path), }; let elements = result.map_err(|e| eyre!("{e}"))?; diff --git a/tests/cli_integration_tests.rs b/tests/cli_integration_tests.rs index c1550c9b..080d2f97 100644 --- a/tests/cli_integration_tests.rs +++ b/tests/cli_integration_tests.rs @@ -17,6 +17,20 @@ mod cli_integration_test { .stderr(is_empty()); } + #[test] + fn test_delete_without_selector_is_rejected() { + // Arrange / Act: no --index, --issuer or --label is provided, so clap + // must reject the invocation instead of letting the matcher fall + // through to an empty-string match deleting the first element. + let mut command = cargo_bin_cmd!("cotp"); + let assertion = command.arg("delete").assert(); + + // Assert + assertion + .failure() + .stderr(is_match("required arguments were not provided").unwrap()); + } + #[test] fn test_help_subcommand() { // Arrange / Act From 976adca3b982c63b2de2f2a77c9fa7aa207f75cb Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:32:57 +0200 Subject: [PATCH 43/49] refactor(otp): reject unknown OTP type/algorithm strings instead of defaulting OTPType and OTPAlgorithm implemented From<&str> mapping any unrecognized string to Totp / Sha1. A backup entry with an unknown or misspelled type (e.g. "OCRA") or algorithm silently became a wrong-code TOTP/SHA1 entry on import instead of surfacing an error. Replace both impls with TryFrom<&str> that still parses known names case-insensitively but returns a descriptive error ("Unknown OTP type/algorithm: ... (expected one of ...)") for anything else. Propagate the fallible conversion through every caller: - importers/aegis.rs, converted.rs, freeotp_plus.rs: the per-element From impls become TryFrom, and the list-level TryFrom impls collect Results so a single bad entry fails the import with the specific reason (errors mapped to the modules' existing String error type). - importers/authy_remote_debug.rs: From and From become TryFrom for the same reason. - otp/from_otp_uri.rs: unknown type/algorithm in an otpauth:// URI now fails URI parsing via ? instead of being coerced to TOTP/SHA1. - freeotp_plus.rs additionally derives the HOTP counter from the parsed OTPType instead of re-comparing the uppercased raw string. Add unit tests covering case-insensitive parsing of all known values, the error for unknown values, and an importer-level test showing an import file with an unknown type now produces a clear error. --- src/importers/aegis.rs | 23 +++++++++---- src/importers/authy_remote_debug.rs | 20 +++++++----- src/importers/converted.rs | 50 +++++++++++++++++++++++------ src/importers/freeotp_plus.rs | 24 +++++++------- src/otp/from_otp_uri.rs | 4 +-- src/otp/otp_algorithm.rs | 47 +++++++++++++++++++++++---- src/otp/otp_type.rs | 44 +++++++++++++++++++++---- 7 files changed, 161 insertions(+), 51 deletions(-) diff --git a/src/importers/aegis.rs b/src/importers/aegis.rs index 94374ce0..b6297151 100644 --- a/src/importers/aegis.rs +++ b/src/importers/aegis.rs @@ -25,19 +25,24 @@ struct AegisElement { info: AegisInfo, } -impl From for OTPElement { - fn from(value: AegisElement) -> Self { - OTPElement { +impl TryFrom for OTPElement { + type Error = String; + + fn try_from(value: AegisElement) -> Result { + let type_ = OTPType::try_from(value.r#type.as_str()).map_err(|e| e.to_string())?; + let algorithm = + OTPAlgorithm::try_from(value.info.algo.as_str()).map_err(|e| e.to_string())?; + Ok(OTPElement { secret: value.info.secret, issuer: value.issuer, label: value.name, digits: value.info.digits, - type_: OTPType::from(value.r#type.as_str()), - algorithm: OTPAlgorithm::from(value.info.algo.as_str()), + type_, + algorithm, period: value.info.period.unwrap_or(30), counter: value.info.counter, pin: value.info.pin, - } + }) } } @@ -45,7 +50,11 @@ impl TryFrom for Vec { type Error = String; fn try_from(aegis_db: AegisDb) -> Result { - Ok(aegis_db.entries.into_iter().map(Into::into).collect()) + aegis_db + .entries + .into_iter() + .map(TryInto::try_into) + .collect() } } diff --git a/src/importers/authy_remote_debug.rs b/src/importers/authy_remote_debug.rs index 290e03f7..54a2a517 100644 --- a/src/importers/authy_remote_debug.rs +++ b/src/importers/authy_remote_debug.rs @@ -70,12 +70,14 @@ impl AuthyExportedJsonElement { } } -impl From for OTPElement { - fn from(input: AuthyExportedJsonElement) -> Self { - let type_ = OTPType::from(input.get_type().as_str()); +impl TryFrom for OTPElement { + type Error = String; + + fn try_from(input: AuthyExportedJsonElement) -> Result { + let type_ = OTPType::try_from(input.get_type().as_str()).map_err(|e| e.to_string())?; let counter: Option = (type_ == OTPType::Hotp).then_some(0); let digits = input.get_digits(); - OTPElement { + Ok(OTPElement { secret: input.secret.to_uppercase().replace('=', ""), issuer: input.get_issuer(), label: input.name, @@ -85,12 +87,14 @@ impl From for OTPElement { period: 30, counter, pin: None, - } + }) } } -impl From for Vec { - fn from(exported_list: AuthyExportedList) -> Self { - exported_list.0.into_iter().map(Into::into).collect() +impl TryFrom for Vec { + type Error = String; + + fn try_from(exported_list: AuthyExportedList) -> Result { + exported_list.0.into_iter().map(TryInto::try_into).collect() } } diff --git a/src/importers/converted.rs b/src/importers/converted.rs index 9ad3ede4..a9b1cb55 100644 --- a/src/importers/converted.rs +++ b/src/importers/converted.rs @@ -14,21 +14,25 @@ struct ConvertedJson { counter: u64, } -impl From for OTPElement { - fn from(converted_json: ConvertedJson) -> Self { - let counter: Option = (OTPType::from(converted_json.type_.as_str()) == OTPType::Hotp) - .then_some(converted_json.counter); - OTPElement { +impl TryFrom for OTPElement { + type Error = String; + + fn try_from(converted_json: ConvertedJson) -> Result { + let type_ = OTPType::try_from(converted_json.type_.as_str()).map_err(|e| e.to_string())?; + let algorithm = + OTPAlgorithm::try_from(converted_json.algorithm.as_str()).map_err(|e| e.to_string())?; + let counter: Option = (type_ == OTPType::Hotp).then_some(converted_json.counter); + Ok(OTPElement { secret: converted_json.secret, issuer: converted_json.issuer.unwrap_or_default(), label: converted_json.label.unwrap_or_default(), digits: converted_json.digits, - type_: OTPType::from(converted_json.type_.as_str()), - algorithm: OTPAlgorithm::from(converted_json.algorithm.as_str()), + type_, + algorithm, period: 30, counter, pin: None, - } + }) } } @@ -39,6 +43,34 @@ pub struct ConvertedJsonList(Vec); impl TryFrom for Vec { type Error = String; fn try_from(value: ConvertedJsonList) -> Result { - Ok(value.0.into_iter().map(Into::into).collect()) + value.0.into_iter().map(TryInto::try_into).collect() + } +} + +#[cfg(test)] +mod tests { + use super::ConvertedJsonList; + use crate::otp::otp_element::OTPElement; + + #[test] + fn unknown_type_in_import_file_is_a_clear_error() { + let json = r#"[ + { + "label": "Label", + "secret": "AAAAAAAAAAAAAAAA", + "issuer": "Issuer", + "type": "OCRA", + "algorithm": "SHA1", + "digits": 6, + "counter": 0 + } + ]"#; + + let deserialized: ConvertedJsonList = serde_json::from_str(json).unwrap(); + let result: Result, String> = deserialized.try_into(); + + let error = result.unwrap_err(); + assert!(error.contains("Unknown OTP type")); + assert!(error.contains("OCRA")); } } diff --git a/src/importers/freeotp_plus.rs b/src/importers/freeotp_plus.rs index 4642b074..eb5fe970 100644 --- a/src/importers/freeotp_plus.rs +++ b/src/importers/freeotp_plus.rs @@ -44,31 +44,31 @@ pub struct FreeOTPElement { pub r#type: String, } -impl From for OTPElement { - fn from(token: FreeOTPElement) -> Self { - let counter: Option = if token.r#type.to_uppercase().as_str() == "HOTP" { - Some(token.counter) - } else { - None - }; - OTPElement { +impl TryFrom for OTPElement { + type Error = String; + + fn try_from(token: FreeOTPElement) -> Result { + let type_ = OTPType::try_from(token.r#type.as_str()).map_err(|e| e.to_string())?; + let algorithm = OTPAlgorithm::try_from(token.algo.as_str()).map_err(|e| e.to_string())?; + let counter: Option = (type_ == OTPType::Hotp).then_some(token.counter); + Ok(OTPElement { counter, secret: encode_secret(&token.secret), issuer: token.issuer_ext, label: token.label, digits: token.digits, - type_: OTPType::from(token.r#type.as_str()), - algorithm: OTPAlgorithm::from(token.algo.as_str()), + type_, + algorithm, period: token.period, pin: None, - } + }) } } impl TryFrom for Vec { type Error = String; fn try_from(freeotp: FreeOTPPlusJson) -> Result { - Ok(freeotp.tokens.into_iter().map(Into::into).collect()) + freeotp.tokens.into_iter().map(TryInto::try_into).collect() } } diff --git a/src/otp/from_otp_uri.rs b/src/otp/from_otp_uri.rs index 4cd867f8..594fb6af 100644 --- a/src/otp/from_otp_uri.rs +++ b/src/otp/from_otp_uri.rs @@ -68,11 +68,11 @@ impl FromOtpUri for OTPElement { // after the secret, so the builder can normalize the secret case. OTPElementBuilder::default() .secret(secret) - .type_(OTPType::from(otp_type.as_str())) + .type_(OTPType::try_from(otp_type.as_str())?) .issuer(issuer) .label(label) .digits(digits) - .algorithm(OTPAlgorithm::from(algorithm.as_str())) + .algorithm(OTPAlgorithm::try_from(algorithm.as_str())?) .period(period) .counter(counter) .pin(pin) diff --git a/src/otp/otp_algorithm.rs b/src/otp/otp_algorithm.rs index 79fc96c6..5b6d937c 100644 --- a/src/otp/otp_algorithm.rs +++ b/src/otp/otp_algorithm.rs @@ -26,13 +26,21 @@ impl fmt::Display for OTPAlgorithm { } } -impl From<&str> for OTPAlgorithm { - fn from(s: &str) -> Self { +impl TryFrom<&str> for OTPAlgorithm { + type Error = eyre::Report; + + /// Parses an OTP algorithm name case-insensitively, rejecting unknown + /// values instead of silently defaulting to SHA1 (which would generate + /// wrong codes for entries using a different, unsupported algorithm). + fn try_from(s: &str) -> Result { match s.to_uppercase().as_str() { - "SHA256" => Self::Sha256, - "SHA512" => Self::Sha512, - "MD5" => Self::Md5, - _ => Self::Sha1, + "SHA1" => Ok(Self::Sha1), + "SHA256" => Ok(Self::Sha256), + "SHA512" => Ok(Self::Sha512), + "MD5" => Ok(Self::Md5), + _ => Err(eyre::eyre!( + "Unknown OTP algorithm: {s:?} (expected one of SHA1, SHA256, SHA512, MD5)" + )), } } } @@ -42,3 +50,30 @@ impl Zeroize for OTPAlgorithm { *self = OTPAlgorithm::Sha1; } } + +#[cfg(test)] +mod tests { + use super::OTPAlgorithm; + + #[test] + fn known_algorithms_parse_case_insensitively() { + assert_eq!(OTPAlgorithm::Sha1, OTPAlgorithm::try_from("sha1").unwrap()); + assert_eq!(OTPAlgorithm::Sha1, OTPAlgorithm::try_from("SHA1").unwrap()); + assert_eq!( + OTPAlgorithm::Sha256, + OTPAlgorithm::try_from("Sha256").unwrap() + ); + assert_eq!( + OTPAlgorithm::Sha512, + OTPAlgorithm::try_from("sha512").unwrap() + ); + assert_eq!(OTPAlgorithm::Md5, OTPAlgorithm::try_from("md5").unwrap()); + } + + #[test] + fn unknown_algorithm_is_an_error_instead_of_defaulting_to_sha1() { + let error = OTPAlgorithm::try_from("crc32").unwrap_err(); + assert!(error.to_string().contains("Unknown OTP algorithm")); + assert!(error.to_string().contains("crc32")); + } +} diff --git a/src/otp/otp_type.rs b/src/otp/otp_type.rs index 4a845446..57af8bd0 100644 --- a/src/otp/otp_type.rs +++ b/src/otp/otp_type.rs @@ -38,14 +38,22 @@ impl fmt::Display for OTPType { } } -impl From<&str> for OTPType { - fn from(s: &str) -> Self { +impl TryFrom<&str> for OTPType { + type Error = eyre::Report; + + /// Parses an OTP type name case-insensitively, rejecting unknown values + /// instead of silently defaulting to TOTP (which would generate wrong + /// codes for entries of a different, unsupported type). + fn try_from(s: &str) -> Result { match s.to_uppercase().as_str() { - "HOTP" => Self::Hotp, - "STEAM" => Self::Steam, - "YANDEX" => Self::Yandex, - "MOTP" => Self::Motp, - _ => Self::Totp, + "TOTP" => Ok(Self::Totp), + "HOTP" => Ok(Self::Hotp), + "STEAM" => Ok(Self::Steam), + "YANDEX" => Ok(Self::Yandex), + "MOTP" => Ok(Self::Motp), + _ => Err(eyre::eyre!( + "Unknown OTP type: {s:?} (expected one of TOTP, HOTP, STEAM, YANDEX, MOTP)" + )), } } } @@ -55,3 +63,25 @@ impl Zeroize for OTPType { *self = OTPType::Totp; } } + +#[cfg(test)] +mod tests { + use super::OTPType; + + #[test] + fn known_types_parse_case_insensitively() { + assert_eq!(OTPType::Totp, OTPType::try_from("totp").unwrap()); + assert_eq!(OTPType::Totp, OTPType::try_from("TOTP").unwrap()); + assert_eq!(OTPType::Hotp, OTPType::try_from("HoTp").unwrap()); + assert_eq!(OTPType::Steam, OTPType::try_from("steam").unwrap()); + assert_eq!(OTPType::Yandex, OTPType::try_from("YANDEX").unwrap()); + assert_eq!(OTPType::Motp, OTPType::try_from("Motp").unwrap()); + } + + #[test] + fn unknown_type_is_an_error_instead_of_defaulting_to_totp() { + let error = OTPType::try_from("otp-2000").unwrap_err(); + assert!(error.to_string().contains("Unknown OTP type")); + assert!(error.to_string().contains("otp-2000")); + } +} From 6caec8ef5a121b3eae80f1073d87e2d8b3395103 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:36:04 +0200 Subject: [PATCH 44/49] refactor(errors): unify importer/exporter error types on eyre Importer and exporter boundaries used ad-hoc String errors that were then re-wrapped (and sometimes Debug-formatted, printing quoted strings) before reaching the user: - The TryInto> impls in aegis.rs, converted.rs, freeotp_plus.rs and authy_remote_debug.rs used Error = String; they now use eyre::Report, so the OTPType/OTPAlgorithm parse errors from the previous commit propagate without .to_string() round-trips. - AegisEncryptedDatabase::decrypt and its helpers (calc_master_key, get_params, map_results) returned Result<_, String> built from format! with {e:?}; they now return eyre::Result with Display- formatted ({e}) causes. - do_export in exporters/mod.rs returned Result; it now returns eyre::Result, and the serde failure message no longer Debug-formats the error. - import_from_path now bounds the conversion error with Into instead of Debug, so import errors are no longer stringified via {:?} (which wrapped String errors in quotes in user-facing output). The serde deserialization error is also Display-formatted now. - arguments/import.rs drops the error-laundering .map_err(|e| eyre!("{e}")) conversions, both on the dispatch result and in import_aegis_encrypted. - arguments/export.rs wraps the export error with WrapErr context instead of flattening it into a new eyre! message, and main.rs prints errors with the alternate {e:#} format so the whole context chain ("outer context: root cause") stays visible to the user. OtpError (src/otp/otp_error.rs) is intentionally left as-is: it is a proper domain error type. User-visible behavior is unchanged except for cleaner error messages (no more Debug-quoted strings). --- src/arguments/export.rs | 4 +- src/arguments/import.rs | 7 +- src/exporters/mod.rs | 20 ++- src/importers/aegis.rs | 11 +- src/importers/aegis_encrypted.rs | 101 +++++++++----- src/importers/authy_remote_debug.rs | 200 ++++++++++++++-------------- src/importers/converted.rs | 13 +- src/importers/freeotp_plus.rs | 8 +- src/importers/importer.rs | 11 +- src/main.rs | 6 +- 10 files changed, 209 insertions(+), 172 deletions(-) diff --git a/src/arguments/export.rs b/src/arguments/export.rs index d9fc0249..7bcec55b 100644 --- a/src/arguments/export.rs +++ b/src/arguments/export.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use clap::Args; -use eyre::eyre; +use eyre::WrapErr; use crate::{ exporters::{do_export, otp_uri::OtpUriList}, @@ -109,6 +109,6 @@ impl SubcommandExecutor for ExportArgs { ); database }) - .map_err(|e| eyre!("An error occurred while exporting database: {e}")) + .wrap_err("An error occurred while exporting database") } } diff --git a/src/arguments/import.rs b/src/arguments/import.rs index 8da48566..aa8c0f98 100644 --- a/src/arguments/import.rs +++ b/src/arguments/import.rs @@ -146,7 +146,7 @@ impl SubcommandExecutor for ImportArgs { ImportFormat::OtpUri => import_from_path::(path), }; - let elements = result.map_err(|e| eyre!("{e}"))?; + let elements = result?; database.add_all(elements); Ok(database) @@ -163,8 +163,7 @@ fn import_aegis_encrypted(path: PathBuf) -> eyre::Result> { Please check the file you are trying to import. For further information please check these guidelines: https://github.com/replydev/cotp?tab=readme-ov-file#migration-from-other-apps - Specific error: {:?}", - e + Specific error: {e}" ) })?; @@ -172,5 +171,5 @@ fn import_aegis_encrypted(path: PathBuf) -> eyre::Result> { let result = encrypted.decrypt(password.as_str()); password.zeroize(); - result.map_err(|e| eyre!("{e}")) + result } diff --git a/src/exporters/mod.rs b/src/exporters/mod.rs index fc598700..1098aeb6 100644 --- a/src/exporters/mod.rs +++ b/src/exporters/mod.rs @@ -4,6 +4,7 @@ use std::{ path::{Path, PathBuf}, }; +use eyre::eyre; use serde::Serialize; use zeroize::Zeroize; @@ -11,17 +12,17 @@ pub mod andotp; pub mod freeotp_plus; pub mod otp_uri; -pub fn do_export(to_be_saved: &T, exported_path: PathBuf) -> Result +pub fn do_export(to_be_saved: &T, exported_path: PathBuf) -> eyre::Result where T: ?Sized + Serialize, { let mut contents = match serde_json::to_string(to_be_saved) { Ok(contents) => contents, - Err(e) => return Err(format!("{e:?}")), + Err(e) => return Err(eyre!("Failed to serialize the export: {e}")), }; if contents == "[]" { contents.zeroize(); - return Err("No contents to export, skipping...".to_owned()); + return Err(eyre!("No contents to export, skipping...")); } let write_result = write_secret_file(&exported_path, contents.as_bytes()); contents.zeroize(); @@ -32,7 +33,7 @@ where ); Ok(exported_path) } - Err(e) => Err(format!( + Err(e) => Err(eyre!( "Cannot export to file {}: {e}", exported_path.display() )), @@ -64,7 +65,12 @@ mod tests { std::path::PathBuf::from("/nonexistent-dir/never/created/export.json"), ); assert!(result.is_err()); - assert!(result.unwrap_err().starts_with("Cannot export to file")); + assert!( + result + .unwrap_err() + .to_string() + .starts_with("Cannot export to file") + ); } #[test] @@ -72,8 +78,8 @@ mod tests { let empty: Vec = vec![]; let result = do_export(&empty, std::path::PathBuf::from("unused.json")); assert_eq!( - result.unwrap_err(), - "No contents to export, skipping...".to_owned() + result.unwrap_err().to_string(), + "No contents to export, skipping..." ); } diff --git a/src/importers/aegis.rs b/src/importers/aegis.rs index b6297151..a6daa5c5 100644 --- a/src/importers/aegis.rs +++ b/src/importers/aegis.rs @@ -26,12 +26,11 @@ struct AegisElement { } impl TryFrom for OTPElement { - type Error = String; + type Error = eyre::Report; fn try_from(value: AegisElement) -> Result { - let type_ = OTPType::try_from(value.r#type.as_str()).map_err(|e| e.to_string())?; - let algorithm = - OTPAlgorithm::try_from(value.info.algo.as_str()).map_err(|e| e.to_string())?; + let type_ = OTPType::try_from(value.r#type.as_str())?; + let algorithm = OTPAlgorithm::try_from(value.info.algo.as_str())?; Ok(OTPElement { secret: value.info.secret, issuer: value.issuer, @@ -47,7 +46,7 @@ impl TryFrom for OTPElement { } impl TryFrom for Vec { - type Error = String; + type Error = eyre::Report; fn try_from(aegis_db: AegisDb) -> Result { aegis_db @@ -59,7 +58,7 @@ impl TryFrom for Vec { } impl TryFrom for Vec { - type Error = String; + type Error = eyre::Report; fn try_from(aegis_json: AegisJson) -> Result { aegis_json.db.try_into() diff --git a/src/importers/aegis_encrypted.rs b/src/importers/aegis_encrypted.rs index 8ae107e3..2b52640c 100644 --- a/src/importers/aegis_encrypted.rs +++ b/src/importers/aegis_encrypted.rs @@ -1,6 +1,7 @@ use aes_gcm::aead::{Aead, Nonce}; use aes_gcm::{Aes256Gcm, KeyInit}; // Or `Aes128Gcm` use data_encoding::{BASE64, DecodeError, HEXLOWER_PERMISSIVE}; +use eyre::eyre; use serde::Deserialize; use zeroize::Zeroize; @@ -44,38 +45,38 @@ struct AegisEncryptedSlot { impl AegisEncryptedDatabase { /// Decrypts the backup contents using the given password and maps the /// entries into `OTPElement` values. - pub fn decrypt(self, password: &str) -> Result, String> { + pub fn decrypt(self, password: &str) -> eyre::Result> { let master_key: Option> = get_master_key(&self, password); match master_key { Some(mut master_key) => { let content = BASE64 .decode(self.db.as_bytes()) - .map_err(|e| format!("Error during base64 decoding: {e:?}"))?; + .map_err(|e| eyre!("Error during base64 decoding: {e}"))?; let cipher = Aes256Gcm::new_from_slice(master_key.as_slice()) - .map_err(|e| format!("Invalid master key length: {e:?}"))?; + .map_err(|e| eyre!("Invalid master key length: {e}"))?; master_key.zeroize(); let nonce_bytes = decode_hex(&self.header.params.nonce) - .map_err(|e| format!("Failed to parse hex nonce: {e:?}"))?; + .map_err(|e| eyre!("Failed to parse hex nonce: {e}"))?; let nonce = Nonce::::try_from(nonce_bytes.as_slice()) - .map_err(|e| format!("Invalid nonce length: {e:?}"))?; + .map_err(|e| eyre!("Invalid nonce length: {e}"))?; let payload = [ content, decode_hex(&self.header.params.tag) - .map_err(|e| format!("Failed to parse hex tag: {e:?}"))?, + .map_err(|e| eyre!("Failed to parse hex tag: {e}"))?, ] .concat(); let decrypted_db = cipher .decrypt(&nonce, payload.as_slice()) - .map_err(|e| format!("Failed to derive master key: {e:?}"))?; + .map_err(|e| eyre!("Failed to derive master key: {e}"))?; map_results(decrypted_db) } - None => Err("Failed to derive master key".to_string()), + None => Err(eyre!("Failed to derive master key")), } } } @@ -105,41 +106,50 @@ fn get_master_key(aegis_encrypted: &AegisEncryptedDatabase, password: &str) -> O master_key } -fn map_results(decrypted_db: Vec) -> Result, String> { +fn map_results(decrypted_db: Vec) -> eyre::Result> { let mut json = match String::from_utf8(decrypted_db) { Ok(json) => json, Err(e) => { - let error = format!("Failed to decode from utf-8 bytes: {:?}", e.utf8_error()); + let error = eyre!("Failed to decode from utf-8 bytes: {}", e.utf8_error()); e.into_bytes().zeroize(); return Err(error); } }; let result = serde_json::from_str::(json.as_str()) - .map_err(|e| e.to_string()) + .map_err(eyre::Report::from) .and_then(TryInto::try_into); json.zeroize(); result } -fn get_params(slot: &AegisEncryptedSlot) -> Result { - let n = slot.n.ok_or("Missing scrypt parameter n in backup slot")?; - let p = slot.p.ok_or("Missing scrypt parameter p in backup slot")?; - let r = slot.r.ok_or("Missing scrypt parameter r in backup slot")?; +fn get_params(slot: &AegisEncryptedSlot) -> eyre::Result { + let n = slot + .n + .ok_or(eyre!("Missing scrypt parameter n in backup slot"))?; + let p = slot + .p + .ok_or(eyre!("Missing scrypt parameter p in backup slot"))?; + let r = slot + .r + .ok_or(eyre!("Missing scrypt parameter r in backup slot"))?; if !n.is_power_of_two() { - return Err(format!( + return Err(eyre!( "Invalid scrypt parameter n: {n} is not a power of two" )); } Params::new(n.trailing_zeros() as u8, r, p) - .map_err(|e| format!("Error during scrypt params creation: {e:?}")) + .map_err(|e| eyre!("Error during scrypt params creation: {e}")) } -fn calc_master_key(slot: &AegisEncryptedSlot, password: &str) -> Result, String> { - let salt_hex = slot.salt.as_ref().ok_or("Missing salt in backup slot")?; - let salt = decode_hex(salt_hex).map_err(|e| format!("Failed to parse hex salt: {e:?}"))?; +fn calc_master_key(slot: &AegisEncryptedSlot, password: &str) -> eyre::Result> { + let salt_hex = slot + .salt + .as_ref() + .ok_or(eyre!("Missing salt in backup slot"))?; + let salt = decode_hex(salt_hex).map_err(|e| eyre!("Failed to parse hex salt: {e}"))?; let mut output: [u8; 32] = [0; 32]; let params = get_params(slot)?; @@ -149,26 +159,26 @@ fn calc_master_key(slot: &AegisEncryptedSlot, password: &str) -> Result, ¶ms, output.as_mut_slice(), ) { - return Err(format!("Error during scrypt key derivation: {e:?}")); + return Err(eyre!("Error during scrypt key derivation: {e}")); } let cipher = Aes256Gcm::new_from_slice(output.as_slice()) - .map_err(|e| format!("Invalid derived key length: {e:?}"))?; + .map_err(|e| eyre!("Invalid derived key length: {e}"))?; output.zeroize(); let cipher_text = [ - decode_hex(&slot.key).map_err(|e| format!("Failed to parse hex key: {e:?}"))?, - decode_hex(&slot.key_params.tag).map_err(|e| format!("Failed to parse hex tag: {e:?}"))?, + decode_hex(&slot.key).map_err(|e| eyre!("Failed to parse hex key: {e}"))?, + decode_hex(&slot.key_params.tag).map_err(|e| eyre!("Failed to parse hex tag: {e}"))?, ] .concat(); - let nonce_bytes = decode_hex(&slot.key_params.nonce) - .map_err(|e| format!("Failed to parse hex nonce: {e:?}"))?; + let nonce_bytes = + decode_hex(&slot.key_params.nonce).map_err(|e| eyre!("Failed to parse hex nonce: {e}"))?; let nonce = Nonce::::try_from(nonce_bytes.as_slice()) - .map_err(|e| format!("Invalid nonce length: {e:?}"))?; + .map_err(|e| eyre!("Invalid nonce length: {e}"))?; cipher .decrypt(&nonce, cipher_text.as_slice()) - .map_err(|e| format!("Failed to derive master key: {e:?}")) + .map_err(|e| eyre!("Failed to derive master key: {e}")) } #[cfg(test)] @@ -203,7 +213,10 @@ mod tests { .expect("Invalid test database JSON"); let result = database.decrypt("password"); - assert_eq!(Err("Failed to derive master key".to_string()), result); + assert_eq!( + "Failed to derive master key", + result.unwrap_err().to_string() + ); } #[test] @@ -219,7 +232,12 @@ mod tests { let result = calc_master_key(&slot, "password"); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Missing scrypt parameter")); + assert!( + result + .unwrap_err() + .to_string() + .contains("Missing scrypt parameter") + ); } #[test] @@ -237,7 +255,7 @@ mod tests { let result = calc_master_key(&slot, "password"); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Missing salt")); + assert!(result.unwrap_err().to_string().contains("Missing salt")); } #[test] @@ -256,7 +274,12 @@ mod tests { let result = get_params(&slot); assert!(result.is_err()); - assert!(result.unwrap_err().contains("not a power of two")); + assert!( + result + .unwrap_err() + .to_string() + .contains("not a power of two") + ); } #[test] @@ -275,7 +298,12 @@ mod tests { let result = calc_master_key(&slot, "password"); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Failed to parse hex salt")); + assert!( + result + .unwrap_err() + .to_string() + .contains("Failed to parse hex salt") + ); } #[test] @@ -294,6 +322,11 @@ mod tests { let result = calc_master_key(&slot, "password"); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Failed to parse hex key")); + assert!( + result + .unwrap_err() + .to_string() + .contains("Failed to parse hex key") + ); } } diff --git a/src/importers/authy_remote_debug.rs b/src/importers/authy_remote_debug.rs index 54a2a517..4c5607a4 100644 --- a/src/importers/authy_remote_debug.rs +++ b/src/importers/authy_remote_debug.rs @@ -1,100 +1,100 @@ -/* -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 TryFrom for OTPElement { - type Error = String; - - fn try_from(input: AuthyExportedJsonElement) -> Result { - let type_ = OTPType::try_from(input.get_type().as_str()).map_err(|e| e.to_string())?; - let counter: Option = (type_ == OTPType::Hotp).then_some(0); - let digits = input.get_digits(); - Ok(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 TryFrom for Vec { - type Error = String; - - fn try_from(exported_list: AuthyExportedList) -> Result { - exported_list.0.into_iter().map(TryInto::try_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 TryFrom for OTPElement { + type Error = eyre::Report; + + fn try_from(input: AuthyExportedJsonElement) -> Result { + let type_ = OTPType::try_from(input.get_type().as_str())?; + let counter: Option = (type_ == OTPType::Hotp).then_some(0); + let digits = input.get_digits(); + Ok(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 TryFrom for Vec { + type Error = eyre::Report; + + fn try_from(exported_list: AuthyExportedList) -> Result { + exported_list.0.into_iter().map(TryInto::try_into).collect() + } +} diff --git a/src/importers/converted.rs b/src/importers/converted.rs index a9b1cb55..2926de7f 100644 --- a/src/importers/converted.rs +++ b/src/importers/converted.rs @@ -15,12 +15,11 @@ struct ConvertedJson { } impl TryFrom for OTPElement { - type Error = String; + type Error = eyre::Report; fn try_from(converted_json: ConvertedJson) -> Result { - let type_ = OTPType::try_from(converted_json.type_.as_str()).map_err(|e| e.to_string())?; - let algorithm = - OTPAlgorithm::try_from(converted_json.algorithm.as_str()).map_err(|e| e.to_string())?; + let type_ = OTPType::try_from(converted_json.type_.as_str())?; + let algorithm = OTPAlgorithm::try_from(converted_json.algorithm.as_str())?; let counter: Option = (type_ == OTPType::Hotp).then_some(converted_json.counter); Ok(OTPElement { secret: converted_json.secret, @@ -41,7 +40,7 @@ impl TryFrom for OTPElement { pub struct ConvertedJsonList(Vec); impl TryFrom for Vec { - type Error = String; + type Error = eyre::Report; fn try_from(value: ConvertedJsonList) -> Result { value.0.into_iter().map(TryInto::try_into).collect() } @@ -67,9 +66,9 @@ mod tests { ]"#; let deserialized: ConvertedJsonList = serde_json::from_str(json).unwrap(); - let result: Result, String> = deserialized.try_into(); + let result: eyre::Result> = deserialized.try_into(); - let error = result.unwrap_err(); + let error = result.unwrap_err().to_string(); assert!(error.contains("Unknown OTP type")); assert!(error.contains("OCRA")); } diff --git a/src/importers/freeotp_plus.rs b/src/importers/freeotp_plus.rs index eb5fe970..c9d5c972 100644 --- a/src/importers/freeotp_plus.rs +++ b/src/importers/freeotp_plus.rs @@ -45,11 +45,11 @@ pub struct FreeOTPElement { } impl TryFrom for OTPElement { - type Error = String; + type Error = eyre::Report; fn try_from(token: FreeOTPElement) -> Result { - let type_ = OTPType::try_from(token.r#type.as_str()).map_err(|e| e.to_string())?; - let algorithm = OTPAlgorithm::try_from(token.algo.as_str()).map_err(|e| e.to_string())?; + let type_ = OTPType::try_from(token.r#type.as_str())?; + let algorithm = OTPAlgorithm::try_from(token.algo.as_str())?; let counter: Option = (type_ == OTPType::Hotp).then_some(token.counter); Ok(OTPElement { counter, @@ -66,7 +66,7 @@ impl TryFrom for OTPElement { } impl TryFrom for Vec { - type Error = String; + type Error = eyre::Report; fn try_from(freeotp: FreeOTPPlusJson) -> Result { freeotp.tokens.into_iter().map(TryInto::try_into).collect() } diff --git a/src/importers/importer.rs b/src/importers/importer.rs index 42f9c882..60caa594 100644 --- a/src/importers/importer.rs +++ b/src/importers/importer.rs @@ -1,4 +1,4 @@ -use std::{fmt::Debug, fs::read_to_string, path::PathBuf}; +use std::{fs::read_to_string, path::PathBuf}; use eyre::{Result, eyre}; use serde::Deserialize; @@ -9,7 +9,7 @@ use crate::otp::otp_element::OTPElement; pub fn import_from_path(path: PathBuf) -> Result> where T: for<'a> Deserialize<'a> + TryInto>, - >>::Error: Debug, + >>::Error: Into, { let json = read_to_string(path)?; let deserialized: T = serde_json::from_str(json.as_str()).map_err(|e| { @@ -17,11 +17,10 @@ where "Invalid JSON import format. Please check the file you are trying to import. For further information please check these guidelines: https://github.com/replydev/cotp?tab=readme-ov-file#migration-from-other-apps - - Specific error: {:?}", - e + + Specific error: {e}" ) })?; - let mapped: Vec = deserialized.try_into().map_err(|e| eyre!("{:?}", e))?; + let mapped: Vec = deserialized.try_into().map_err(Into::into)?; Ok(mapped) } diff --git a/src/main.rs b/src/main.rs index a4e4a709..9374a0d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,7 +52,9 @@ fn main() -> ExitCode { let (database, mut key, salt) = match init(&cotp_args) { Ok(v) => v, Err(e) => { - eprintln!("An error occurred: {e}"); + // "{e:#}" prints the whole eyre error chain, e.g. + // "outer context: root cause", keeping the root cause visible. + eprintln!("An error occurred: {e:#}"); return ExitCode::from(1); } }; @@ -60,7 +62,7 @@ fn main() -> ExitCode { let mut reowned_database = match args_parser(cotp_args, database) { Ok(d) => d, Err(e) => { - eprintln!("An error occurred: {e}"); + eprintln!("An error occurred: {e:#}"); key.zeroize(); return ExitCode::from(2); } From 192c93026a9b0b72d23e2941a8417b96acda20ef Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:41:40 +0200 Subject: [PATCH 45/49] refactor(otp): harden the OTPDatabase dirty-flag API Make the needs_modification field private so the dirty flag can only be toggled through mark_modified()/the new clear_modified(), and route every direct field access from outside otp_element.rs through accessors: - interface/handlers/popup.rs: the "don't save before quit" path now calls clear_modified() instead of writing the field directly. - arguments/list.rs, arguments/extract.rs, exporters/freeotp_plus.rs, exporters/otp_uri.rs: use elements_ref() instead of touching the (now private) elements field. - exporters/andotp.rs: use the new into_elements() accessor for the by-value export and elements_ref() for the borrowed one (which now converts to &[OTPElement], adjusting arguments/export.rs). - main.rs: build the first-run database with OTPDatabase::default() instead of a struct literal over private fields. save() now clears the dirty flag only AFTER a successful encrypt+write, so a failed save leaves the database marked dirty, and documents the passwd-path invariant: passwd persists the database itself via save_with_pw (new salt + key), and the cleared flag is what makes the final is_modified() check in main() skip a second save that would re-encrypt with the old key and undo the password change. mut_element() keeps handing out &mut OTPElement without marking the database dirty, now with a doc comment making the contract explicit: callers mark modified themselves, which lets no-op edits (cotp edit with unchanged values) skip rewriting the database file. --- src/arguments/export.rs | 2 +- src/arguments/extract.rs | 2 +- src/arguments/list.rs | 8 +++--- src/exporters/andotp.rs | 7 ++--- src/exporters/freeotp_plus.rs | 2 +- src/exporters/otp_uri.rs | 2 +- src/interface/handlers/popup.rs | 2 +- src/main.rs | 10 +++----- src/otp/otp_element.rs | 45 ++++++++++++++++++++++++++++----- 9 files changed, 54 insertions(+), 26 deletions(-) diff --git a/src/arguments/export.rs b/src/arguments/export.rs index 7bcec55b..d8ef3a94 100644 --- a/src/arguments/export.rs +++ b/src/arguments/export.rs @@ -90,7 +90,7 @@ impl SubcommandExecutor for ExportArgs { match export_kind { ExportKind::Cotp => do_export(&database, exported_path), ExportKind::Andotp => { - let andotp: &Vec = (&database).into(); + let andotp: &[OTPElement] = (&database).into(); do_export(&andotp, exported_path) } ExportKind::OtpUri => { diff --git a/src/arguments/extract.rs b/src/arguments/extract.rs index b2f4843a..5a704e3e 100644 --- a/src/arguments/extract.rs +++ b/src/arguments/extract.rs @@ -110,7 +110,7 @@ impl SubcommandExecutor for ExtractArgs { fn find_match(otp_database: &OTPDatabase, filter: ExtractFilter) -> Option<&OTPElement> { otp_database - .elements + .elements_ref() .iter() .enumerate() .find(|(index, code)| filter_extract(&filter, *index, code)) diff --git a/src/arguments/list.rs b/src/arguments/list.rs index 278c8533..d2144090 100644 --- a/src/arguments/list.rs +++ b/src/arguments/list.rs @@ -64,7 +64,7 @@ impl SubcommandExecutor for ListArgs { fn run_command(self, otp_database: OTPDatabase) -> eyre::Result { if self.format.unwrap_or_default().json { let json_elements = otp_database - .elements + .elements_ref() .iter() .map(Into::into) .collect::>(); @@ -73,7 +73,7 @@ impl SubcommandExecutor for ListArgs { .map_err(|e| eyre!("Error during JSON serialization: {:?}", e))?; println!("{stringified}"); } else { - if otp_database.elements.is_empty() { + if otp_database.elements_ref().is_empty() { println!("No elements to list"); return Ok(otp_database); } @@ -102,7 +102,7 @@ impl SubcommandExecutor for ListArgs { "Index", ISSUER_HEADER, LABEL_HEADER, "OTP", ); otp_database - .elements + .elements_ref() .iter() .enumerate() .for_each(|(index, e)| { @@ -130,7 +130,7 @@ where F: Fn(&OTPElement) -> usize, { otp_database - .elements + .elements_ref() .iter() .map(get_number_of_chars) .max() diff --git a/src/exporters/andotp.rs b/src/exporters/andotp.rs index a5a48469..b23fa1b7 100644 --- a/src/exporters/andotp.rs +++ b/src/exporters/andotp.rs @@ -1,14 +1,15 @@ use crate::otp::otp_element::{OTPDatabase, OTPElement}; +/// andOTP backups are plain JSON arrays of OTP elements type AndOtpDatabase = Vec; impl From for AndOtpDatabase { fn from(value: OTPDatabase) -> Self { - value.elements + value.into_elements() } } -impl<'a> From<&'a OTPDatabase> for &'a AndOtpDatabase { +impl<'a> From<&'a OTPDatabase> for &'a [OTPElement] { fn from(value: &'a OTPDatabase) -> Self { - &value.elements + value.elements_ref() } } diff --git a/src/exporters/freeotp_plus.rs b/src/exporters/freeotp_plus.rs index 1e27a173..7ce831cd 100644 --- a/src/exporters/freeotp_plus.rs +++ b/src/exporters/freeotp_plus.rs @@ -10,7 +10,7 @@ impl TryFrom<&OTPDatabase> for FreeOTPPlusJson { type Error = ErrReport; fn try_from(otp_database: &OTPDatabase) -> Result { otp_database - .elements + .elements_ref() .iter() .map(TryInto::try_into) .collect::, ErrReport>>() diff --git a/src/exporters/otp_uri.rs b/src/exporters/otp_uri.rs index eacb0ed9..7d63d2c2 100644 --- a/src/exporters/otp_uri.rs +++ b/src/exporters/otp_uri.rs @@ -9,7 +9,7 @@ pub struct OtpUriList { impl<'a> From<&'a OTPDatabase> for OtpUriList { fn from(value: &'a OTPDatabase) -> Self { let items: Vec = value - .elements + .elements_ref() .iter() .map(super::super::otp::otp_element::OTPElement::get_otpauth_uri) .collect(); diff --git a/src/interface/handlers/popup.rs b/src/interface/handlers/popup.rs index 46f781a0..7c545556 100644 --- a/src/interface/handlers/popup.rs +++ b/src/interface/handlers/popup.rs @@ -33,7 +33,7 @@ pub(super) fn popup_handler(key_event: KeyEvent, app: &mut App) { app.running = false; } KeyCode::Char('n' | 'N') => { - app.database.needs_modification = false; + app.database.clear_modified(); app.running = false; } KeyCode::Esc => { diff --git a/src/main.rs b/src/main.rs index 9374a0d5..941a2ebf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,13 +5,13 @@ use interface::app::AppResult; use interface::event::{Event, EventHandler}; use interface::handlers::handle_key_events; 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; use std::process::ExitCode; -use std::{io, vec}; use zeroize::Zeroize; mod arguments; @@ -32,11 +32,7 @@ fn init(args: &CotpArgs) -> eyre::Result { if first_run { // Let's initialize the database file let mut pw = utils::try_verified_password("Choose a password: ", 8)?; - let mut database = OTPDatabase { - version: CURRENT_DATABASE_VERSION, - elements: vec![], - ..Default::default() - }; + let mut database = OTPDatabase::default(); let save_result = database.save_with_pw(&pw); pw.zeroize(); save_result.map(|(key, salt)| (database, key, salt.to_vec())) diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index 4e03f60f..0e0d921c 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -47,9 +47,12 @@ fn create_database_file() -> std::io::Result { #[derive(Serialize, Deserialize, PartialEq, Hash)] pub struct OTPDatabase { pub(crate) version: u16, - pub(crate) elements: Vec, + elements: Vec, + /// Dirty flag gating the final save in `main()`. Private on purpose: it is + /// only toggled through [`OTPDatabase::mark_modified`] and + /// [`OTPDatabase::clear_modified`]. #[serde(skip)] - pub(crate) needs_modification: bool, + needs_modification: bool, } impl From> for OTPDatabase { @@ -78,13 +81,23 @@ impl OTPDatabase { self.needs_modification } + /// Encrypts the database with the given key and writes it to disk. + /// + /// The modified flag is cleared only AFTER a successful write, so a failed + /// save leaves the database marked dirty. + /// + /// Clearing the flag on success is also what makes the `passwd` flow safe: + /// `passwd` persists the database itself via [`Self::save_with_pw`] (new + /// salt + key derived from the new password), and the cleared flag makes + /// the final `is_modified()` check in `main()` skip its own save — which + /// would otherwise re-encrypt the database with the OLD key and silently + /// undo the password change. pub fn save(&mut self, key: &Vec, salt: &[u8]) -> eyre::Result<()> { - self.needs_modification = false; migrate(self)?; - match self.overwrite_database_key(key, salt) { - Ok(()) => Ok(()), - Err(e) => Err(ErrReport::from(e)), - } + self.overwrite_database_key(key, salt) + .map_err(ErrReport::from)?; + self.clear_modified(); + Ok(()) } fn overwrite_database_key(&self, key: &Vec, salt: &[u8]) -> Result<(), std::io::Error> { @@ -122,10 +135,17 @@ impl OTPDatabase { self.elements.push(element); } + /// Marks the database as dirty so `main()` persists it on exit. pub fn mark_modified(&mut self) { self.needs_modification = true; } + /// Discards the dirty flag so the database will NOT be persisted on exit + /// (e.g. the user answered "don't save" when quitting the dashboard). + pub fn clear_modified(&mut self) { + self.needs_modification = false; + } + pub fn delete_element(&mut self, index: usize) { self.mark_modified(); self.elements.remove(index); @@ -135,10 +155,21 @@ impl OTPDatabase { &self.elements } + /// Consumes the database and returns its elements, for exporters that + /// need owned values. + pub fn into_elements(self) -> Vec { + self.elements + } + pub fn get_element(&self, i: usize) -> Option<&OTPElement> { self.elements.get(i) } + /// Mutable access to an element. Deliberately does NOT mark the database + /// as modified: callers must call [`Self::mark_modified`] themselves once + /// they know an actual change happened, which lets no-op edits (e.g. + /// `cotp edit` with values identical to the current ones) skip the + /// re-encryption and rewrite of the database file. pub fn mut_element(&mut self, i: usize) -> Option<&mut OTPElement> { self.elements.get_mut(i) } From 59dab57fe375122999c1ac03bacb47e43bb605f8 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:43:50 +0200 Subject: [PATCH 46/49] refactor(storage): extract database persistence out of OTPDatabase OTPDatabase mixed domain data with encryption and filesystem writes: save()/save_with_pw() reached into crypto:: and wrote to the global DATABASE_PATH from inside the domain type. Introduce src/storage/mod.rs as the persistence layer and fold the small reading.rs module into it, so load and save live side by side: - get_elements_from_input/get_elements_from_stdin/read_from_file moved from reading.rs (deleted), keeping the ReadResult flow identical; read_decrypted_text and read_from_file now take the database path as a parameter instead of reading the global. - save(db, key, salt, path) and save_with_pw(db, password, path) moved from OTPDatabase; both take the path explicitly, and callers (main.rs, arguments/passwd.rs) pass DATABASE_PATH. migrate() still runs on every save, the modified flag is still cleared only after a successful write, and the passwd-path invariant doc moved along. - create_database_file() (0600 on unix) and the encrypt+write logic moved to storage; plaintext-JSON zeroization after encryption is preserved. The only non-mechanical change: the encryption result is now propagated with `?` instead of the previous unwrap(), so an encryption failure reports an error instead of panicking. OTPDatabase keeps only pure domain operations (elements accessors, add/delete/mut, dirty flag, sort) and loses its crypto/path/fs imports. --- src/arguments/passwd.rs | 6 +- src/main.rs | 15 +++-- src/otp/otp_element.rs | 69 ------------------- src/reading.rs | 59 ----------------- src/storage/mod.rs | 143 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 157 insertions(+), 135 deletions(-) delete mode 100644 src/reading.rs create mode 100644 src/storage/mod.rs diff --git a/src/arguments/passwd.rs b/src/arguments/passwd.rs index 1a8ad114..680f2151 100644 --- a/src/arguments/passwd.rs +++ b/src/arguments/passwd.rs @@ -1,7 +1,7 @@ use clap::Args; use zeroize::Zeroize; -use crate::{otp::otp_element::OTPDatabase, utils}; +use crate::{otp::otp_element::OTPDatabase, path::DATABASE_PATH, storage, utils}; use super::SubcommandExecutor; @@ -11,7 +11,9 @@ pub struct PasswdArgs; impl SubcommandExecutor for PasswdArgs { fn run_command(self, mut database: OTPDatabase) -> eyre::Result { let mut new_password = utils::try_verified_password("New password: ", 8)?; - database.save_with_pw(&new_password)?; + // Saves with a key derived from the new password and clears the + // modified flag, so main() will not save again with the old key + storage::save_with_pw(&mut database, &new_password, DATABASE_PATH.get().unwrap())?; new_password.zeroize(); Ok(database) } diff --git a/src/main.rs b/src/main.rs index 941a2ebf..f73391d4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,12 +6,12 @@ use interface::event::{Event, EventHandler}; use interface::handlers::handle_key_events; use interface::ui::Tui; use otp::otp_element::OTPDatabase; -use path::init_path; +use path::{DATABASE_PATH, init_path}; use ratatui::Terminal; use ratatui::prelude::CrosstermBackend; -use reading::{ReadResult, get_elements_from_input, get_elements_from_stdin}; use std::io; use std::process::ExitCode; +use storage::{ReadResult, get_elements_from_input, get_elements_from_stdin}; use zeroize::Zeroize; mod arguments; @@ -22,7 +22,7 @@ mod importers; mod interface; mod otp; mod path; -mod reading; +mod storage; mod utils; fn init(args: &CotpArgs) -> eyre::Result { @@ -33,7 +33,7 @@ fn init(args: &CotpArgs) -> eyre::Result { // Let's initialize the database file let mut pw = utils::try_verified_password("Choose a password: ", 8)?; let mut database = OTPDatabase::default(); - let save_result = database.save_with_pw(&pw); + let save_result = storage::save_with_pw(&mut database, &pw, DATABASE_PATH.get().unwrap()); pw.zeroize(); save_result.map(|(key, salt)| (database, key, salt.to_vec())) } else if args.password_from_stdin { @@ -65,7 +65,12 @@ fn main() -> ExitCode { }; let exit_code = if reowned_database.is_modified() { - match reowned_database.save(&key, &salt) { + match storage::save( + &mut reowned_database, + &key, + &salt, + DATABASE_PATH.get().unwrap(), + ) { Ok(()) => { println!("Modifications have been persisted"); ExitCode::SUCCESS diff --git a/src/otp/otp_element.rs b/src/otp/otp_element.rs index 0e0d921c..a50ec9a4 100644 --- a/src/otp/otp_element.rs +++ b/src/otp/otp_element.rs @@ -1,10 +1,7 @@ use derive_builder::Builder; use eyre::{ErrReport, eyre}; -use std::{fs::File, io::Write, vec}; -use crate::crypto::cryptography::{argon_derive_key, encrypt_string_with_key, gen_salt}; use crate::otp::otp_error::OtpError; -use crate::path::DATABASE_PATH; use data_encoding::{BASE32_NOPAD, HEXLOWER_PERMISSIVE}; use qrcode::QrCode; use qrcode::render::unicode; @@ -16,34 +13,12 @@ use super::{ hotp_maker::hotp, motp_maker::motp, steam_otp_maker::steam, totp_maker::totp, yandex_otp_maker::yandex, }, - migrations::migrate, otp_algorithm::OTPAlgorithm, otp_type::OTPType, }; pub const CURRENT_DATABASE_VERSION: u16 = 2; -/// Creates (or truncates) the database file. -/// -/// On unix the file is created with mode 0600 so other users cannot read it. -/// The database is encrypted, so this is defense in depth rather than a -/// confidentiality requirement. -#[cfg(unix)] -fn create_database_file() -> std::io::Result { - use std::os::unix::fs::OpenOptionsExt; - std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(DATABASE_PATH.get().unwrap()) -} - -#[cfg(not(unix))] -fn create_database_file() -> std::io::Result { - File::create(DATABASE_PATH.get().unwrap()) -} - #[derive(Serialize, Deserialize, PartialEq, Hash)] pub struct OTPDatabase { pub(crate) version: u16, @@ -81,50 +56,6 @@ impl OTPDatabase { self.needs_modification } - /// Encrypts the database with the given key and writes it to disk. - /// - /// The modified flag is cleared only AFTER a successful write, so a failed - /// save leaves the database marked dirty. - /// - /// Clearing the flag on success is also what makes the `passwd` flow safe: - /// `passwd` persists the database itself via [`Self::save_with_pw`] (new - /// salt + key derived from the new password), and the cleared flag makes - /// the final `is_modified()` check in `main()` skip its own save — which - /// would otherwise re-encrypt the database with the OLD key and silently - /// undo the password change. - pub fn save(&mut self, key: &Vec, salt: &[u8]) -> eyre::Result<()> { - migrate(self)?; - self.overwrite_database_key(key, salt) - .map_err(ErrReport::from)?; - self.clear_modified(); - Ok(()) - } - - fn overwrite_database_key(&self, key: &Vec, salt: &[u8]) -> Result<(), std::io::Error> { - // The plaintext JSON contains every secret in the database: wipe it - // from memory as soon as it has been encrypted - let mut json = serde_json::to_string(&self)?; - let encrypted = encrypt_string_with_key(&json, key, salt); - json.zeroize(); - let encrypted = encrypted.unwrap(); - let mut file = create_database_file()?; - match serde_json::to_string(&encrypted) { - Ok(content) => { - file.write_all(content.as_bytes())?; - file.sync_all()?; - Ok(()) - } - Err(e) => Err(std::io::Error::from(e)), - } - } - - pub fn save_with_pw(&mut self, password: &str) -> eyre::Result<(Vec, [u8; 16])> { - let salt = gen_salt()?; - let key = argon_derive_key(password.as_bytes(), &salt)?; - self.save(&key, &salt)?; - Ok((key, salt)) - } - pub fn add_all(&mut self, mut elements: Vec) { self.mark_modified(); self.elements.append(&mut elements); diff --git a/src/reading.rs b/src/reading.rs deleted file mode 100644 index 518257c7..00000000 --- a/src/reading.rs +++ /dev/null @@ -1,59 +0,0 @@ -use crate::crypto; -use crate::otp::otp_element::{OTPDatabase, OTPElement}; -use crate::path::DATABASE_PATH; -use crate::utils; -use 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() -> eyre::Result { - let pw = utils::try_password("Password: ", 8)?; - get_elements_with_password(pw) -} - -pub fn get_elements_from_stdin() -> 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) -> eyre::Result { - let (elements, key, salt) = read_from_file(&password)?; - password.zeroize(); - Ok((elements, key, salt)) -} - -pub fn read_decrypted_text(password: &str) -> eyre::Result<(String, Vec, Vec)> { - let encrypted_contents = - read_to_string(DATABASE_PATH.get().unwrap()).map_err(ErrReport::from)?; - if encrypted_contents.is_empty() { - // Do not delete the file here: silently destroying a user file from a - // read path is surprising and irreversible. An empty file can also be - // the leftover of an interrupted write, in which case the user may - // want to restore a backup instead of starting over. - return Err(eyre!( - "Your database file at {:?} is empty or corrupted. If you have a backup, restore it over that path; otherwise remove the file manually and restart cotp to initialize a new database.", - DATABASE_PATH.get().unwrap() - )); - } - //rust close files at the end of the function - crypto::cryptography::decrypt_string(&encrypted_contents, password) -} - -pub fn read_from_file(password: &str) -> eyre::Result { - match read_decrypted_text(password) { - Ok((mut contents, key, salt)) => { - 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(); - database.sort(); - Ok((database, key, salt)) - } - Err(e) => Err(e), - } -} diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 00000000..354369b2 --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,143 @@ +//! Persistence layer for the OTP database. +//! +//! [`OTPDatabase`] holds only domain data; this module owns everything about +//! moving it to and from disk: password prompting, decryption and +//! deserialization on load (including the legacy v1 format fallback), and +//! migration, encryption and the actual filesystem write on save. + +use std::fs::{File, read_to_string}; +use std::io::{self, BufRead, Write}; +use std::path::Path; + +use eyre::{ErrReport, eyre}; +use zeroize::Zeroize; + +use crate::crypto::cryptography::{ + argon_derive_key, decrypt_string, encrypt_string_with_key, gen_salt, +}; +use crate::otp::migrations::migrate; +use crate::otp::otp_element::{OTPDatabase, OTPElement}; +use crate::path::DATABASE_PATH; +use crate::utils; + +pub type ReadResult = (OTPDatabase, Vec, Vec); + +pub fn get_elements_from_input() -> eyre::Result { + let pw = utils::try_password("Password: ", 8)?; + get_elements_with_password(pw) +} + +pub fn get_elements_from_stdin() -> 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) -> eyre::Result { + let read_result = read_from_file(DATABASE_PATH.get().unwrap(), &password); + password.zeroize(); + read_result +} + +fn read_decrypted_text(path: &Path, password: &str) -> eyre::Result<(String, Vec, Vec)> { + let encrypted_contents = read_to_string(path).map_err(ErrReport::from)?; + if encrypted_contents.is_empty() { + // Do not delete the file here: silently destroying a user file from a + // read path is surprising and irreversible. An empty file can also be + // the leftover of an interrupted write, in which case the user may + // want to restore a backup instead of starting over. + return Err(eyre!( + "Your database file at {path:?} is empty or corrupted. If you have a backup, restore it over that path; otherwise remove the file manually and restart cotp to initialize a new database.", + )); + } + //rust close files at the end of the function + decrypt_string(&encrypted_contents, password) +} + +fn read_from_file(path: &Path, password: &str) -> eyre::Result { + let (mut contents, key, salt) = read_decrypted_text(path, password)?; + 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(); + database.sort(); + Ok((database, key, salt)) +} + +/// Encrypts the database with the given key and writes it to `path`. +/// +/// `migrate()` runs on every save, so the written database is always at the +/// current schema version. The modified flag is cleared only AFTER a +/// successful write, so a failed save leaves the database marked dirty. +/// +/// Clearing the flag on success is also what makes the `passwd` flow safe: +/// `passwd` persists the database itself via [`save_with_pw`] (new salt + key +/// derived from the new password), and the cleared flag makes the final +/// `is_modified()` check in `main()` skip its own save — which would +/// otherwise re-encrypt the database with the OLD key and silently undo the +/// password change. +pub fn save( + database: &mut OTPDatabase, + key: &Vec, + salt: &[u8], + path: &Path, +) -> eyre::Result<()> { + migrate(database)?; + encrypt_and_write(database, key, salt, path)?; + database.clear_modified(); + Ok(()) +} + +/// Derives a fresh salt + key from `password` and saves the database with +/// them, returning both so the caller can keep using the new key. +pub fn save_with_pw( + database: &mut OTPDatabase, + password: &str, + path: &Path, +) -> eyre::Result<(Vec, [u8; 16])> { + let salt = gen_salt()?; + let key = argon_derive_key(password.as_bytes(), &salt)?; + save(database, &key, &salt, path)?; + Ok((key, salt)) +} + +fn encrypt_and_write( + database: &OTPDatabase, + key: &Vec, + salt: &[u8], + path: &Path, +) -> eyre::Result<()> { + // The plaintext JSON contains every secret in the database: wipe it + // from memory as soon as it has been encrypted + let mut json = serde_json::to_string(database)?; + let encrypted = encrypt_string_with_key(&json, key, salt); + json.zeroize(); + let encrypted = encrypted?; + let mut file = create_database_file(path)?; + let content = serde_json::to_string(&encrypted)?; + file.write_all(content.as_bytes())?; + file.sync_all()?; + Ok(()) +} + +/// Creates (or truncates) the database file. +/// +/// On unix the file is created with mode 0600 so other users cannot read it. +/// The database is encrypted, so this is defense in depth rather than a +/// confidentiality requirement. +#[cfg(unix)] +fn create_database_file(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) +} + +#[cfg(not(unix))] +fn create_database_file(path: &Path) -> std::io::Result { + File::create(path) +} From 558f74766db1239c882a658a88eb3f3d3817fa6d Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:45:58 +0200 Subject: [PATCH 47/49] refactor(tui): move rendering out of app.rs into ui.rs CLAUDE.md documents the split as "app.rs holds mutable App state; ui.rs renders", but the render functions (render_main_page, render_qrcode_page, render_table_box, render_alert and the top-level render dispatcher) lived on App while ui.rs only handled the terminal lifecycle. Move them to ui.rs as free functions taking &mut App, purely mechanically (self -> app). The QR-code render cache keeps its exact behavior: rendering still populates app.qrcode_cache keyed by the selected index, and tick()/reset() in app.rs keep invalidating it. App keeps only state and state-derived logic: title and qrcode_cache become pub(crate) so ui.rs can read/fill them, and progress() becomes pub(crate) but stays in app.rs since it is a state computation, not rendering. --- src/interface/app.rs | 243 +------------------------------------------ src/interface/ui.rs | 241 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 241 insertions(+), 243 deletions(-) diff --git a/src/interface/app.rs b/src/interface/app.rs index 38010a39..9c5fb8b7 100644 --- a/src/interface/app.rs +++ b/src/interface/app.rs @@ -3,21 +3,12 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::interface::enums::Focus; use crate::interface::enums::Page; -use crate::interface::enums::Page::{Main, Qrcode}; use crate::otp::otp_element::{OTPDatabase, OTPElement}; -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::layout::{Alignment, Constraint, Direction, Layout}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::widgets::{Block, Borders, Cell, Clear, Gauge, Paragraph, Row, Table, Wrap}; use crate::interface::stateful_table::{StatefulTable, fill_table}; use crate::utils::percentage; use super::enums::PopupAction; -use super::popup::centered_rect; - -const LARGE_APPLICATION_WIDTH: u16 = 75; /// Application result type. pub type AppResult = Result>; @@ -28,7 +19,7 @@ const DEFAULT_QRCODE_LABEL: &str = "Press enter to copy the OTP URI code"; pub struct App<'a> { /// Is the application running? pub running: bool, - title: String, + pub(crate) title: String, pub(crate) table: StatefulTable, pub(crate) database: &'a mut OTPDatabase, /// Time step of each element at the last tick, used to detect when an @@ -47,7 +38,7 @@ pub struct App<'a> { /// Cached rendered QR code for the `QRCode` page, keyed by the index of /// the element it was generated from - qrcode_cache: Option<(usize, String)>, + pub(crate) qrcode_cache: Option<(usize, String)>, } pub struct Popup { @@ -113,241 +104,13 @@ impl<'a> App<'a> { /// Percentage of the current period cycle elapsed for the selected /// element, falling back to the global 30 seconds cycle if no element is /// selected - fn progress(&self) -> u16 { + pub(crate) fn progress(&self) -> u16 { self.table .state .selected() .and_then(|index| self.database.elements_ref().get(index)) .map_or_else(percentage, |element| period_percentage(element.period)) } - - /// Renders the user interface widgets. - pub fn render(&mut self, frame: &mut Frame<'_>) { - match &self.current_page { - Main => self.render_main_page(frame), - Qrcode => self.render_qrcode_page(frame), - } - } - - fn render_qrcode_page(&mut self, frame: &mut Frame<'_>) { - let selected_index = self - .table - .state - .selected() - .filter(|index| *index < self.database.elements_ref().len()); - - let paragraph = if let Some(index) = selected_index { - // Building the QR code (URI + matrix + unicode rendering) is - // expensive, so cache the rendered string and rebuild it only - // when the selection changes - let cache_is_valid = - matches!(&self.qrcode_cache, Some((cached, _)) if *cached == index); - if !cache_is_valid { - let qrcode = self.database.elements_ref()[index].get_qrcode(); - self.qrcode_cache = Some((index, qrcode)); - } - let element = &self.database.elements_ref()[index]; - let qrcode = self - .qrcode_cache - .as_ref() - .map(|(_, qrcode)| qrcode.as_str()) - .unwrap_or_default(); - let title = if element.label.is_empty() { - element.issuer.clone() - } else { - format!("{} - {}", element.issuer, element.label) - }; - Paragraph::new(format!("{}\n{}", qrcode, self.qr_code_page_label)) - .block(Block::default().title(title).borders(Borders::ALL)) - .style(Style::default().fg(Color::White).bg(Color::Reset)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }) - } else { - Paragraph::new("No element is selected") - .block(Block::default().title("Nope").borders(Borders::ALL)) - .style(Style::default().fg(Color::White).bg(Color::Reset)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }) - }; - Self::render_paragraph(frame, paragraph); - } - - fn render_paragraph(frame: &mut Frame<'_>, paragraph: Paragraph) { - let rects = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(100)].as_ref()) - .split(frame.area()); - - frame.render_widget(paragraph, rects[0]); - } - - fn render_main_page(&mut self, frame: &mut Frame<'_>) { - let height = frame.area().height; - let rects = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - Constraint::Length(3), // Search bar - Constraint::Length(height.saturating_sub(8)), // Table + Info Box - Constraint::Length(1), // Progress bar - ] - .as_ref(), - ) - .margin(2) - .split(frame.area()); - - let search_bar_title = "Press CTRL + F to search a code..."; - let search_bar = Paragraph::new(&*self.search_query) - .block( - Block::default() - .title(search_bar_title) - .borders(Borders::ALL) - .border_style(Style::default().fg(if self.focus == Focus::SearchBar { - Color::LightRed - } else { - Color::White - })), - ) - .style(Style::default().fg(Color::White).bg(Color::Reset)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }); - - // The gauge tracks the period of the selected element, so a 60s TOTP - // or a 10s MOTP shows its actual remaining time - let progress = self.progress(); - let progress_label = if self.print_percentage { - format!("{progress}%") - } else { - self.label_text.clone() - }; - let progress_bar = Gauge::default() - .block(Block::default()) - .gauge_style( - Style::default() - .bg(Color::White) - .fg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - ) - .percent(progress) - .label(progress_label); - - frame.render_widget(search_bar, rects[0]); - self.render_table_box(frame, rects[1]); - frame.render_widget(progress_bar, rects[2]); - if self.focus == Focus::Popup { - self.render_alert(frame); - } - } - - fn render_alert(&mut self, frame: &mut Frame<'_>) { - let block = Block::default().title("Alert").borders(Borders::ALL); - let paragraph = Paragraph::new(&*self.popup.text) - .block(block) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }); - let area = centered_rect(self.popup.percent_x, self.popup.percent_y, frame.area()); - frame.render_widget(Clear, area); - //this clears out the background - frame.render_widget(paragraph, area); - } - - fn render_table_box(&mut self, frame: &mut Frame<'_>, area: Rect) { - let constraints = if Self::is_large_application(frame) { - vec![Constraint::Percentage(80), Constraint::Percentage(20)] - } else { - vec![Constraint::Percentage(100)] - }; - let chunks = Layout::default() - .constraints(constraints) - .direction(Direction::Horizontal) - .split(area); - - let header_cells = ["Id", "Issuer", "Label", "OTP"] - .iter() - .map(|h| Cell::from(*h).style(Style::default().fg(Color::Black))); - let header = Row::new(header_cells) - .style( - Style::default() - .bg(Color::White) - .add_modifier(Modifier::BOLD), - ) - .height(1) - .bottom_margin(1); - let rows = self.table.items.iter().map(|item| { - Row::new(item.cells()) - .height(item.height()) - .bottom_margin(1) - }); - - const TABLE_WIDTHS: &[Constraint] = &[ - Constraint::Percentage(5), - Constraint::Percentage(35), - Constraint::Percentage(35), - Constraint::Percentage(25), - ]; - - let t = Table::new(rows, TABLE_WIDTHS) - .header(header) - .block( - Block::default() - .borders(Borders::TOP | Borders::BOTTOM) - .title(self.title.as_str()), - ) - .row_highlight_style( - Style::default() - .bg(Color::White) - .fg(Color::Black) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol("-> "); - - let selected_element = self - .table - .state - .selected() - .and_then(|i| self.database.get_element(i)); - - let mut text = if let Some(element) = selected_element { - format!( - " - Type: {} - Algorithm: {} - Period: {} {} - Counter: {} - Pin: {} - ", - element.type_, - element.algorithm, - element.period, - if element.period == 1u64 { - "second" - } else { - "seconds" - }, - element - .counter - .map_or_else(|| String::from("N/A"), |e| e.to_string()), - element.pin.clone().unwrap_or_else(|| String::from("N/A")) - ) - } else { - String::new() - }; - - text.push_str("\n\n Press '?' to get help\n"); - let paragraph = Paragraph::new(text) - .block(Block::default().title("Code info").borders(Borders::ALL)) - .style(Style::default().fg(Color::White).bg(Color::Reset)) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }); - frame.render_stateful_widget(t, chunks[0], &mut self.table.state); - if Self::is_large_application(frame) { - frame.render_widget(paragraph, chunks[1]); - } - } - - fn is_large_application(frame: &mut Frame<'_>) -> bool { - frame.area().width >= LARGE_APPLICATION_WIDTH - } } /// Milliseconds elapsed since the Unix epoch diff --git a/src/interface/ui.rs b/src/interface/ui.rs index e822bad3..b1ee992d 100644 --- a/src/interface/ui.rs +++ b/src/interface/ui.rs @@ -2,11 +2,19 @@ use std::io; use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; -use ratatui::Terminal; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::prelude::Backend; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::{Block, Borders, Cell, Clear, Gauge, Paragraph, Row, Table, Wrap}; +use ratatui::{Frame, Terminal}; use crate::interface::app::{App, AppResult}; +use crate::interface::enums::Focus; +use crate::interface::enums::Page::{Main, Qrcode}; use crate::interface::event::EventHandler; +use crate::interface::popup::centered_rect; + +const LARGE_APPLICATION_WIDTH: u16 = 75; /// Representation of a terminal user interface. /// @@ -43,12 +51,12 @@ impl Tui { /// [`Draw`] the terminal interface by [`rendering`] the widgets. /// /// [`Draw`]: tui::Terminal::draw - /// [`rendering`]: crate::app::App::render + /// [`rendering`]: render pub fn draw(&mut self, app: &mut App) -> AppResult<()> where ::Error: 'static, { - self.terminal.draw(|frame| app.render(frame))?; + self.terminal.draw(|frame| render(app, frame))?; Ok(()) } @@ -65,3 +73,230 @@ impl Tui { Ok(()) } } + +/// Renders the user interface widgets. +pub fn render(app: &mut App, frame: &mut Frame<'_>) { + match &app.current_page { + Main => render_main_page(app, frame), + Qrcode => render_qrcode_page(app, frame), + } +} + +fn render_qrcode_page(app: &mut App, frame: &mut Frame<'_>) { + let selected_index = app + .table + .state + .selected() + .filter(|index| *index < app.database.elements_ref().len()); + + let paragraph = if let Some(index) = selected_index { + // Building the QR code (URI + matrix + unicode rendering) is + // expensive, so cache the rendered string and rebuild it only + // when the selection changes + let cache_is_valid = matches!(&app.qrcode_cache, Some((cached, _)) if *cached == index); + if !cache_is_valid { + let qrcode = app.database.elements_ref()[index].get_qrcode(); + app.qrcode_cache = Some((index, qrcode)); + } + let element = &app.database.elements_ref()[index]; + let qrcode = app + .qrcode_cache + .as_ref() + .map(|(_, qrcode)| qrcode.as_str()) + .unwrap_or_default(); + let title = if element.label.is_empty() { + element.issuer.clone() + } else { + format!("{} - {}", element.issuer, element.label) + }; + Paragraph::new(format!("{}\n{}", qrcode, app.qr_code_page_label)) + .block(Block::default().title(title).borders(Borders::ALL)) + .style(Style::default().fg(Color::White).bg(Color::Reset)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) + } else { + Paragraph::new("No element is selected") + .block(Block::default().title("Nope").borders(Borders::ALL)) + .style(Style::default().fg(Color::White).bg(Color::Reset)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) + }; + render_paragraph(frame, paragraph); +} + +fn render_paragraph(frame: &mut Frame<'_>, paragraph: Paragraph) { + let rects = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(100)].as_ref()) + .split(frame.area()); + + frame.render_widget(paragraph, rects[0]); +} + +fn render_main_page(app: &mut App, frame: &mut Frame<'_>) { + let height = frame.area().height; + let rects = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(3), // Search bar + Constraint::Length(height.saturating_sub(8)), // Table + Info Box + Constraint::Length(1), // Progress bar + ] + .as_ref(), + ) + .margin(2) + .split(frame.area()); + + let search_bar_title = "Press CTRL + F to search a code..."; + let search_bar = Paragraph::new(&*app.search_query) + .block( + Block::default() + .title(search_bar_title) + .borders(Borders::ALL) + .border_style(Style::default().fg(if app.focus == Focus::SearchBar { + Color::LightRed + } else { + Color::White + })), + ) + .style(Style::default().fg(Color::White).bg(Color::Reset)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + + // The gauge tracks the period of the selected element, so a 60s TOTP + // or a 10s MOTP shows its actual remaining time + let progress = app.progress(); + let progress_label = if app.print_percentage { + format!("{progress}%") + } else { + app.label_text.clone() + }; + let progress_bar = Gauge::default() + .block(Block::default()) + .gauge_style( + Style::default() + .bg(Color::White) + .fg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .percent(progress) + .label(progress_label); + + frame.render_widget(search_bar, rects[0]); + render_table_box(app, frame, rects[1]); + frame.render_widget(progress_bar, rects[2]); + if app.focus == Focus::Popup { + render_alert(app, frame); + } +} + +fn render_alert(app: &mut App, frame: &mut Frame<'_>) { + let block = Block::default().title("Alert").borders(Borders::ALL); + let paragraph = Paragraph::new(&*app.popup.text) + .block(block) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + let area = centered_rect(app.popup.percent_x, app.popup.percent_y, frame.area()); + frame.render_widget(Clear, area); + //this clears out the background + frame.render_widget(paragraph, area); +} + +fn render_table_box(app: &mut App, frame: &mut Frame<'_>, area: Rect) { + let constraints = if is_large_application(frame) { + vec![Constraint::Percentage(80), Constraint::Percentage(20)] + } else { + vec![Constraint::Percentage(100)] + }; + let chunks = Layout::default() + .constraints(constraints) + .direction(Direction::Horizontal) + .split(area); + + let header_cells = ["Id", "Issuer", "Label", "OTP"] + .iter() + .map(|h| Cell::from(*h).style(Style::default().fg(Color::Black))); + let header = Row::new(header_cells) + .style( + Style::default() + .bg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .height(1) + .bottom_margin(1); + let rows = app.table.items.iter().map(|item| { + Row::new(item.cells()) + .height(item.height()) + .bottom_margin(1) + }); + + const TABLE_WIDTHS: &[Constraint] = &[ + Constraint::Percentage(5), + Constraint::Percentage(35), + Constraint::Percentage(35), + Constraint::Percentage(25), + ]; + + let t = Table::new(rows, TABLE_WIDTHS) + .header(header) + .block( + Block::default() + .borders(Borders::TOP | Borders::BOTTOM) + .title(app.title.as_str()), + ) + .row_highlight_style( + Style::default() + .bg(Color::White) + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("-> "); + + let selected_element = app + .table + .state + .selected() + .and_then(|i| app.database.get_element(i)); + + let mut text = if let Some(element) = selected_element { + format!( + " + Type: {} + Algorithm: {} + Period: {} {} + Counter: {} + Pin: {} + ", + element.type_, + element.algorithm, + element.period, + if element.period == 1u64 { + "second" + } else { + "seconds" + }, + element + .counter + .map_or_else(|| String::from("N/A"), |e| e.to_string()), + element.pin.clone().unwrap_or_else(|| String::from("N/A")) + ) + } else { + String::new() + }; + + text.push_str("\n\n Press '?' to get help\n"); + let paragraph = Paragraph::new(text) + .block(Block::default().title("Code info").borders(Borders::ALL)) + .style(Style::default().fg(Color::White).bg(Color::Reset)) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }); + frame.render_stateful_widget(t, chunks[0], &mut app.table.state); + if is_large_application(frame) { + frame.render_widget(paragraph, chunks[1]); + } +} + +fn is_large_application(frame: &mut Frame<'_>) -> bool { + frame.area().width >= LARGE_APPLICATION_WIDTH +} From c0598e6c1ccbd3409a4fd7880d18cc03d63c6b00 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:46:28 +0200 Subject: [PATCH 48/49] test(crypto): fix err().expect() clippy lints in test targets The CI clippy gate (cargo clippy -- -D warnings) only lints the lib/bin target, so clippy::err_expect violations in #[cfg(test)] code went unnoticed. Replace the three result.err().expect(...) patterns in src/crypto/cryptography.rs tests with result.expect_err(...), making cargo clippy --all-targets -- -D warnings pass cleanly too. No other --all-targets lints surfaced. --- src/crypto/cryptography.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/crypto/cryptography.rs b/src/crypto/cryptography.rs index 39e251f5..1e141715 100644 --- a/src/crypto/cryptography.rs +++ b/src/crypto/cryptography.rs @@ -125,7 +125,7 @@ mod tests { let corrupted = r#"{"version":1,"nonce":"!!!not-base64!!!","salt":"c2FsdA==","cipher":"Y2lwaGVy"}"#; let result = decrypt_string(corrupted, "pa$$w0rd"); - let error = result.err().expect("corrupted nonce must not panic"); + let error = result.expect_err("corrupted nonce must not panic"); assert!(error.to_string().contains("database file is corrupted")); assert!(error.to_string().contains("nonce")); } @@ -135,7 +135,7 @@ mod tests { let corrupted = r#"{"version":1,"nonce":"bm9uY2U=","salt":"c2FsdA==","cipher":"!!!not-base64!!!"}"#; let result = decrypt_string(corrupted, "pa$$w0rd"); - let error = result.err().expect("corrupted cipher must not panic"); + let error = result.expect_err("corrupted cipher must not panic"); assert!(error.to_string().contains("database file is corrupted")); assert!(error.to_string().contains("cipher")); } @@ -145,7 +145,7 @@ mod tests { let corrupted = r#"{"version":1,"nonce":"bm9uY2U=","salt":"!!!not-base64!!!","cipher":"Y2lwaGVy"}"#; let result = decrypt_string(corrupted, "pa$$w0rd"); - let error = result.err().expect("corrupted salt must not panic"); + let error = result.expect_err("corrupted salt must not panic"); assert!(error.to_string().contains("database file is corrupted")); assert!(error.to_string().contains("salt")); } From 80fc12e5ec06eb3515189bfd47c0c375a81eeb32 Mon Sep 17 00:00:00 2001 From: replydev Date: Thu, 23 Jul 2026 00:48:10 +0200 Subject: [PATCH 49/49] docs: bring CLAUDE.md in line with the review-fix branch Update the guidance to match what this branch changed: - Runtime data flow: ReadResult and load/save now live in the new storage/ module; document the dirty-flag contract (private flag, mark_modified/clear_modified, mut_element not marking, flag cleared only after a successful write and the passwd invariant) and the exit codes (1 init/save errors, 2 subcommand errors). - Key modules: add the storage/ persistence-layer bullet; OTPDatabase described as pure domain data; ui.rs now really owns all rendering (free functions over &mut App) plus the terminal lifecycle; import/ export format dispatch through the exhaustive ImportFormat/ ExportKind enums; extract's hand-rolled wildcard matcher; Yandex always HMAC-SHA256; OTPType/OTPAlgorithm TryFrom<&str> rejecting unknown strings. - Common commands: note cargo clippy --all-targets is kept clean too. - New short "Dependency notes" section: plain eyre (no color-eyre), data-encoding for all codecs (no hex/base64 crates), trimmed ratatui/qrcode features and the idna_adapter pin. - Database format: read_from_file reference now points at storage::. --- CLAUDE.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 43b0254c..2422b21c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ cargo test --locked --release # tests in release mode (CI runs both) cargo test # run a single test by substring match cargo fmt --all -- --check # formatting check (CI gate) cargo clippy -- -D warnings # lint; warnings are errors (CI gate) +cargo clippy --all-targets -- -D warnings # also lints tests; kept clean too ``` CI (`.github/workflows/build.yml`) requires `cargo fmt`, `cargo clippy -D warnings`, and `cargo test` (debug + release) to pass across Linux/macOS/Windows. On Linux, building requires xcb dev libraries for clipboard support (see README "Other linux distributions"). @@ -41,25 +42,30 @@ commit-analyzer computes the next version, `ci/write_cargo_version.sh` writes it `main.rs` drives a fixed lifecycle for every invocation: -1. `init()` — resolves the DB path, and either initializes a new encrypted database on first run (prompting for a password) or loads the existing one. Returns `ReadResult = (OTPDatabase, key, salt)`. -2. `args_parser()` (`arguments/mod.rs`) — if a subcommand was given, dispatches to it; otherwise launches the interactive `dashboard()` (TUI). Each subcommand consumes the `OTPDatabase` and returns a (possibly modified) one. -3. Back in `main()`, if `database.is_modified()` the database is re-encrypted and written to disk. The derived `key` is zeroized before exit. +1. `init()` — resolves the DB path, and either initializes a new encrypted database on first run (prompting for a password) or loads the existing one via `storage::`. Returns `ReadResult = (OTPDatabase, key, salt)` (defined in `storage/mod.rs`). +2. `args_parser()` (`arguments/mod.rs`) — if a subcommand was given, dispatches to it; otherwise launches the interactive `dashboard()` (TUI). Each subcommand consumes the `OTPDatabase` and returns a (possibly modified) one. Exit codes: 1 for init/save errors, 2 for subcommand errors. +3. Back in `main()`, if `database.is_modified()` the database is re-encrypted and written to disk via `storage::save()`. The derived `key` is zeroized before exit. -The `OTPDatabase` is passed by value through the command layer; mutations set a `needs_modification` flag (via `mark_modified()`) that gates the final save. Secrets and keys use `zeroize` throughout — preserve zeroization when touching password/key handling. +The `OTPDatabase` is passed by value through the command layer; mutations set a private dirty flag (via `mark_modified()`/`clear_modified()`) that gates the final save — `mut_element()` deliberately does NOT mark, so callers mark only on real changes (no-op edits skip the rewrite). `storage::save()` clears the flag only after a successful write; this is also what makes `passwd` safe (it saves itself with a key from the new password, and the cleared flag stops `main()` from saving again with the old key). Secrets and keys use `zeroize` throughout — preserve zeroization when touching password/key handling. ## Key modules (`src/`) -- **`arguments/`** — Clap subcommands (`add`, `edit`, `list`, `delete`, `import`, `export`, `extract`, `passwd`). Each implements the `SubcommandExecutor` trait (`fn run_command(self, db: OTPDatabase) -> Result`), wired together with `enum_dispatch` on the `CotpSubcommands` enum. To add a subcommand: create the module, define an `Args` struct, implement `SubcommandExecutor`, and add a variant to `CotpSubcommands`. -- **`otp/`** — core domain. `otp_element.rs` holds `OTPElement` and `OTPDatabase` (serialization, save/encrypt, migrations). `algorithms/` has one generator per scheme (`totp`, `hotp`, `motp`, `steam`, `yandex`). `otp_type.rs` / `otp_algorithm.rs` are the enums; `from_otp_uri.rs` parses `otpauth://` URIs. +- **`arguments/`** — Clap subcommands (`add`, `edit`, `list`, `delete`, `import`, `export`, `extract`, `passwd`). Each implements the `SubcommandExecutor` trait (`fn run_command(self, db: OTPDatabase) -> Result`), wired together with `enum_dispatch` on the `CotpSubcommands` enum. To add a subcommand: create the module, define an `Args` struct, implement `SubcommandExecutor`, and add a variant to `CotpSubcommands`. The mutually exclusive import/export format flags map to exhaustive internal enums (`ImportFormat` in `import.rs`, `ExportKind` in `export.rs`) — add new formats there. `extract` matches issuer/label with a hand-rolled `*`/`?` wildcard matcher (no regex/glob crate). +- **`otp/`** — core domain. `otp_element.rs` holds `OTPElement` and `OTPDatabase`; the database is pure domain data (element accessors, dirty flag, sort) — persistence lives in `storage/`. `algorithms/` has one generator per scheme (`totp`, `hotp`, `motp`, `steam`, `yandex`); Yandex always uses HMAC-SHA256 regardless of the element's stored algorithm. `otp_type.rs` / `otp_algorithm.rs` are the enums, with `TryFrom<&str>` impls that reject unknown strings instead of defaulting; `from_otp_uri.rs` parses `otpauth://` URIs. +- **`storage/`** — persistence layer. Load: `get_elements_from_input`/`get_elements_from_stdin` → `read_from_file` (password prompt, decrypt, legacy-v1 fallback). Save: `save(db, key, salt, path)` / `save_with_pw(db, password, path)` — runs `migrate()` on every save, encrypts, writes with 0600 permissions on unix, zeroizes the plaintext JSON, and clears the dirty flag only after a successful write. - **`crypto/`** — `cryptography.rs` does Argon2id key derivation (config constants at top of file) + XChaCha20Poly1305 authenticated encryption; also AES-GCM for decrypting Aegis encrypted backups. `encrypted_database.rs` is the on-disk envelope. - **`importers/`** — one module per source app. `importer.rs::import_from_path::()` is the generic entry point: `T` must be `Deserialize + TryInto>`. Import selection happens in `arguments/import.rs`. Some sources (Authy, Microsoft Authenticator, FreeOTP) are pre-converted by Python scripts to `ConvertedJsonList` first (see below); others deserialize natively. Google Authenticator is handled natively by `google_authenticator.rs`, which parses `otpauth-migration://` export URIs (base64 protobuf `MigrationPayload`, decoded with `prost` using hand-declared message structs — no `.proto`/`protoc` build step). -- **`exporters/`** — `andotp`, `freeotp_plus`, `otp_uri`. `do_export::()` is the shared writer. -- **`interface/`** — the ratatui/crossterm TUI. `app.rs` holds mutable `App` state; `ui.rs` renders; `event.rs` is the input event loop (250ms tick); `handlers/` route key events by focus (`main_window`, `popup`, `search_bar`). The dashboard runs on `io::stderr()` so stdout stays clean for piping. +- **`exporters/`** — `andotp`, `freeotp_plus`, `otp_uri`. `do_export::()` is the shared writer (0600 permissions on unix). +- **`interface/`** — the ratatui/crossterm TUI. `app.rs` holds mutable `App` state (including the cached rendered QR code); `ui.rs` owns the terminal lifecycle (`Tui`) and all rendering, as free functions taking `&mut App`; `event.rs` is the input event loop (250ms tick); `handlers/` route key events by focus (`main_window`, `popup`, `search_bar`). The dashboard runs on `io::stderr()` so stdout stays clean for piping. + +## Dependency notes + +Errors use plain `eyre` (not color-eyre). All base32/hex/base64 codecs go through `data-encoding` (no `hex`/`base64` crates). `ratatui` and `qrcode` are built with trimmed feature sets, and `url`'s IDNA backend is pinned to the small unicode-rs `idna_adapter` — keep this binary-size budget in mind when adding dependencies. ## Database format & migrations - Default path resolution (`path.rs`): `--database-path` arg > `COTP_DB_PATH` env > `./db.cotp` (portable / debug builds always) > `$XDG_DATA_HOME/cotp/db.cotp` (auto-migrated from legacy `$HOME/.cotp/db.cotp` if present). The path is a `OnceLock` set once at startup. -- `CURRENT_DATABASE_VERSION` (in `otp_element.rs`) is the schema version. Legacy v1 was a bare `Vec`; `read_from_file` falls back to parsing that and converts via `From>`. Schema upgrades go in `otp/migrations/mod.rs` — add a `Migration { to_version, migration_function }` entry to `MIGRATIONS_LIST`; `migrate()` runs on every save. +- `CURRENT_DATABASE_VERSION` (in `otp_element.rs`) is the schema version. Legacy v1 was a bare `Vec`; `storage::read_from_file` falls back to parsing that and converts via `From>`. Schema upgrades go in `otp/migrations/mod.rs` — add a `Migration { to_version, migration_function }` entry to `MIGRATIONS_LIST`; `migrate()` runs on every save (from `storage::save`). ## Python converters (`converters/`)