diff --git a/src/password_store/decrypted_entry.rs b/src/password_store/decrypted_entry.rs index 455eb67..d152810 100644 --- a/src/password_store/decrypted_entry.rs +++ b/src/password_store/decrypted_entry.rs @@ -13,16 +13,19 @@ impl DecryptedEntry { let mut lines = content.lines(); let password = lines.next().unwrap_or_default().to_owned(); let mut fields = Vec::new(); - let mut otp_uri = None; + let mut otp_uri = otp_uri_from_line(&password).map(str::to_owned); let mut extra_lines = Vec::new(); for line in lines { - if is_otp_uri(line) { - otp_uri = Some(line.to_owned()); + if let Some(uri) = otp_uri_from_line(line) { + otp_uri = Some(uri.to_owned()); continue; } if let Some(field) = EntryField::parse(line) { + if let Some(uri) = otp_uri_from_line(&field.value) { + otp_uri = Some(uri.to_owned()); + } fields.push(field); continue; } @@ -61,8 +64,9 @@ impl EntryField { } } -fn is_otp_uri(line: &str) -> bool { - line.starts_with("otpauth://") +fn otp_uri_from_line(line: &str) -> Option<&str> { + let line = line.trim(); + line.starts_with("otpauth://").then_some(line) } #[cfg(test)] @@ -106,6 +110,23 @@ notes without separator ); } + #[test] + fn recognizes_otp_on_first_line_with_whitespace_or_in_a_field() { + let uri = "otpauth://totp/Stripe?secret=JBSWY3DPEHPK3PXP&issuer=Stripe"; + for content in [ + uri.to_owned(), + format!("secret\n {uri} \r\n"), + format!("secret\notp: {uri}\n"), + ] { + let entry = DecryptedEntry::parse(&content); + assert_eq!(entry.otp_uri.as_deref(), Some(uri)); + } + let entry = DecryptedEntry::parse(uri); + assert_eq!(entry.password, uri); + let entry = DecryptedEntry::parse(&format!("secret\notp: {uri}\n")); + assert_eq!(entry.fields[0].value, uri); + } + #[test] fn parses_empty_content_as_empty_password() { let entry = DecryptedEntry::parse(""); diff --git a/src/password_store/otp.rs b/src/password_store/otp.rs index 3426d3d..2d05f55 100644 --- a/src/password_store/otp.rs +++ b/src/password_store/otp.rs @@ -96,6 +96,22 @@ mod tests { }; use totp_rs::TotpUrlError; + #[test] + fn generates_stripe_codes_from_supported_entry_layouts() { + // Public RFC 6238 test secret; timestamp 59 produces 94287082 (8 digits). + let uri = "otpauth://totp/Stripe?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Stripe"; + for content in [ + uri.to_owned(), + format!("password\n{uri}\n"), + format!("password\n {uri} \r\n"), + format!("password\notp: {uri}\n"), + ] { + let entry = DecryptedEntry::parse(&content); + let otp = OtpCode::generate_at(&entry, 59).expect("Stripe OTP"); + assert_eq!(otp.code, "287082"); + } + } + #[test] fn generates_deterministic_totp_code() { let entry = DecryptedEntry::parse(