diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc45dc6eb..031f841b4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ * Added Android support for eval-jexl functionality through the `NimbusTargetingHelper.evalJexl()` method, enabling JEXL expression evaluation on Android with full targeting context support. ([#7163](https://github.com/mozilla/application-services/pull/7163)) +### Logins +- Add password reuse detection for breach alerts: Database schema upgraded to version 4 with new `breachesL` table storing encrypted breached passwords. New APIs `are_potentially_vulnerable_passwords()` (batch check) and `is_potentially_vulnerable_password()` (single check) enable cross-domain password reuse detection. +- Move breach alert fields (`time_of_last_breach`, `time_last_breach_alert_dismissed`) from `LoginFields` to `LoginMeta` to group internally managed fields that are not directly updateable via the `update()` API. + ### Ads-Client * Adds new Kotlin `AdsClientTelemetry.kt` wrapper for Glean callbacks. diff --git a/components/logins/src/db.rs b/components/logins/src/db.rs index ba95dd7744..dabc05c9ec 100644 --- a/components/logins/src/db.rs +++ b/components/logins/src/db.rs @@ -306,27 +306,118 @@ impl LoginDb { Ok(()) } - pub fn record_breach(&self, id: &str, timestamp: i64) -> Result<()> { + pub fn record_breach( + &self, + guid: &str, + timestamp: i64, + encdec: &dyn EncryptorDecryptor, + ) -> Result<()> { + let existing = match self.get_by_id(guid)? { + Some(e) => e.decrypt(encdec)?, + None => return Err(Error::NoSuchRecord(guid.to_owned())), + }; + let encrypted_password_bytes = encdec + .encrypt(existing.password.as_bytes().into()) + .map_err(|e| Error::EncryptionFailed(format!("{e} (encrypting password)")))?; + let encrypted_password = std::str::from_utf8(&encrypted_password_bytes).map_err(|e| { + Error::EncryptionFailed(format!("{e} (encrypting password: data not utf8)")) + })?; + let is_potentially_vulnerable_password = + self.is_potentially_vulnerable_password(guid, encdec)?; + let tx = self.unchecked_transaction()?; - self.ensure_local_overlay_exists(id)?; - self.mark_mirror_overridden(id)?; + self.ensure_local_overlay_exists(guid)?; + self.mark_mirror_overridden(guid)?; self.execute_cached( "UPDATE loginsL SET timeOfLastBreach = :now_millis WHERE guid = :guid", named_params! { ":now_millis": timestamp, - ":guid": id, + ":guid": guid, }, )?; + if !is_potentially_vulnerable_password { + self.execute_cached( + "INSERT INTO breachesL (encryptedPassword) VALUES (:encrypted_password)", + named_params! { + ":encrypted_password": encrypted_password, + }, + )?; + } tx.commit()?; Ok(()) } - pub fn is_potentially_breached(&self, id: &str) -> Result { + /// Checks multiple logins for password reuse in a single batch operation. + /// + /// Returns the GUIDs of logins whose passwords match any password in the breach database. + /// This is more efficient than calling `is_potentially_vulnerable_password()` repeatedly, + /// as it decrypts the breach database only once. + /// + /// Performance: O(M + N) where M = breached passwords, N = logins to check + /// - Single check: Use `is_potentially_vulnerable_password()` (simpler) + /// - Multiple checks: Use this method (faster) + pub fn are_potentially_vulnerable_passwords( + &self, + guids: &[&str], + encdec: &dyn EncryptorDecryptor, + ) -> Result> { + if guids.is_empty() { + return Ok(Vec::new()); + } + + // Load and decrypt all breached passwords once + let all_encrypted_passwords: Vec = self.db.query_rows_and_then_cached( + "SELECT encryptedPassword FROM breachesL", + [], + |row| row.get(0), + )?; + + let mut breached_passwords = std::collections::HashSet::new(); + for ciphertext in &all_encrypted_passwords { + let decrypted_bytes = encdec.decrypt(ciphertext.as_bytes().into()).map_err(|e| { + Error::DecryptionFailed(format!("Failed to decrypt password from breachesL: {}", e)) + })?; + + let decrypted_password = std::str::from_utf8(&decrypted_bytes).map_err(|e| { + Error::DecryptionFailed(format!( + "Decrypted password from breachesL is not valid UTF-8: {}", + e + )) + })?; + + breached_passwords.insert(decrypted_password.to_string()); + } + + // Check each login against the breached passwords set + let mut vulnerable_guids = Vec::new(); + for guid in guids { + if let Some(login) = self.get_by_id(guid)? { + let decrypted_login = login.decrypt(encdec)?; + if breached_passwords.contains(&decrypted_login.password) { + vulnerable_guids.push(guid.to_string()); + } + } + } + + Ok(vulnerable_guids) + } + + pub fn is_potentially_vulnerable_password( + &self, + guid: &str, + encdec: &dyn EncryptorDecryptor, + ) -> Result { + // Delegate to batch method for code reuse + let vulnerable = self.are_potentially_vulnerable_passwords(&[guid], encdec)?; + Ok(!vulnerable.is_empty()) + } + + pub fn is_potentially_breached(&self, guid: &str) -> Result { let is_potentially_breached: bool = self.db.query_row( "SELECT EXISTS(SELECT 1 FROM loginsL WHERE guid = :guid AND timeOfLastBreach IS NOT NULL AND timeOfLastBreach > timePasswordChanged)", - named_params! { ":guid": id }, + named_params! { ":guid": guid }, |row| row.get(0), )?; Ok(is_potentially_breached) @@ -340,6 +431,7 @@ impl LoginDb { WHERE timeOfLastBreach IS NOT NULL", [], )?; + self.execute_cached("DELETE FROM breachesL", [])?; tx.commit()?; Ok(()) } @@ -438,9 +530,9 @@ impl LoginDb { ":times_used": login.meta.times_used, ":time_last_used": login.meta.time_last_used, ":time_password_changed": login.meta.time_password_changed, - ":time_of_last_breach": login.fields.time_of_last_breach, - ":time_last_breach_alert_dismissed": login.fields.time_last_breach_alert_dismissed, ":local_modified": login.meta.time_created, + ":time_of_last_breach": login.meta.time_of_last_breach, + ":time_last_breach_alert_dismissed": login.meta.time_last_breach_alert_dismissed, ":sec_fields": login.sec_fields, ":guid": login.guid(), }, @@ -508,6 +600,8 @@ impl LoginDb { time_password_changed: now_ms, time_last_used: now_ms, times_used: 1, + time_of_last_breach: None, + time_last_breach_alert_dismissed: None, }, } }) @@ -543,8 +637,6 @@ impl LoginDb { http_realm: new_entry.http_realm, username_field: new_entry.username_field, password_field: new_entry.password_field, - time_of_last_breach: None, - time_last_breach_alert_dismissed: None, }, sec_fields, }; @@ -577,6 +669,8 @@ impl LoginDb { time_password_changed: now_ms, time_last_used: now_ms, times_used: 1, + time_of_last_breach: None, + time_last_breach_alert_dismissed: None, }, }; @@ -652,6 +746,8 @@ impl LoginDb { time_password_changed, time_last_used: now_ms, times_used: existing.times_used + 1, + time_of_last_breach: None, + time_last_breach_alert_dismissed: None, }, fields: LoginFields { origin: entry.origin, @@ -659,8 +755,6 @@ impl LoginDb { http_realm: entry.http_realm, username_field: entry.username_field, password_field: entry.password_field, - time_of_last_breach: None, - time_last_breach_alert_dismissed: None, }, sec_fields, }; @@ -977,6 +1071,7 @@ impl LoginDb { row_count += self.execute("DELETE FROM loginsL", [])?; row_count += self.execute("DELETE FROM loginsM", [])?; row_count += self.execute("DELETE FROM loginsSyncMeta", [])?; + row_count += self.execute("DELETE FROM breachesL", [])?; tx.commit()?; Ok(row_count) } @@ -1146,8 +1241,8 @@ pub mod test_utils { ":time_last_used": login.meta.time_last_used, ":time_password_changed": login.meta.time_password_changed, ":time_created": login.meta.time_created, - ":time_of_last_breach": login.fields.time_of_last_breach, - ":time_last_breach_alert_dismissed": login.fields.time_last_breach_alert_dismissed, + ":time_of_last_breach": login.meta.time_of_last_breach, + ":time_last_breach_alert_dismissed": login.meta.time_last_breach_alert_dismissed, ":guid": login.guid_str(), })?; Ok(()) @@ -1455,6 +1550,8 @@ mod tests { time_password_changed: now_ms + 100, time_last_used: now_ms + 10, times_used: 42, + time_of_last_breach: None, + time_last_breach_alert_dismissed: None, }; let db = LoginDb::open_in_memory(); @@ -1493,6 +1590,8 @@ mod tests { time_password_changed: now_ms + 100, time_last_used: now_ms + 10, times_used: 42, + time_of_last_breach: None, + time_last_breach_alert_dismissed: None, }; let db = LoginDb::open_in_memory(); @@ -1791,33 +1890,35 @@ mod tests { ) .unwrap(); // initial state - assert!(login.fields.time_of_last_breach.is_none()); + assert!(login.meta.time_of_last_breach.is_none()); assert!(!db.is_potentially_breached(&login.meta.id).unwrap()); - assert!(login.fields.time_last_breach_alert_dismissed.is_none()); + assert!(login.meta.time_last_breach_alert_dismissed.is_none()); // Wait and use a time that's definitely after password was changed thread::sleep(time::Duration::from_millis(50)); let breach_time = util::system_time_ms_i64(SystemTime::now()); - db.record_breach(&login.meta.id, breach_time).unwrap(); + db.record_breach(&login.meta.id, breach_time, &*TEST_ENCDEC) + .unwrap(); assert!(db.is_potentially_breached(&login.meta.id).unwrap()); let login1 = db.get_by_id(&login.meta.id).unwrap().unwrap(); - assert!(login1.fields.time_of_last_breach.is_some()); + assert!(login1.meta.time_of_last_breach.is_some()); // dismiss db.record_breach_alert_dismissal(&login.meta.id).unwrap(); let login2 = db.get_by_id(&login.meta.id).unwrap().unwrap(); - assert!(login2.fields.time_last_breach_alert_dismissed.is_some()); + assert!(login2.meta.time_last_breach_alert_dismissed.is_some()); // reset db.reset_all_breaches().unwrap(); assert!(!db.is_potentially_breached(&login.meta.id).unwrap()); let login3 = db.get_by_id(&login.meta.id).unwrap().unwrap(); - assert!(login3.fields.time_of_last_breach.is_none()); + assert!(login3.meta.time_of_last_breach.is_none()); // Wait and use a time that's definitely after password was changed thread::sleep(time::Duration::from_millis(50)); let breach_time = util::system_time_ms_i64(SystemTime::now()); - db.record_breach(&login.meta.id, breach_time).unwrap(); + db.record_breach(&login.meta.id, breach_time, &*TEST_ENCDEC) + .unwrap(); assert!(db.is_potentially_breached(&login.meta.id).unwrap()); // now change password @@ -1855,7 +1956,8 @@ mod tests { // Wait and use a time that's definitely after password was changed thread::sleep(time::Duration::from_millis(50)); let breach_time = util::system_time_ms_i64(SystemTime::now()); - db.record_breach(&login.meta.id, breach_time).unwrap(); + db.record_breach(&login.meta.id, breach_time, &*TEST_ENCDEC) + .unwrap(); assert!(db.is_potentially_breached(&login.meta.id).unwrap()); // change some fields @@ -1893,7 +1995,8 @@ mod tests { // Record a breach that happened after password was created // Use a timestamp that's definitely after the login's timePasswordChanged let breach_time = login.meta.time_password_changed + 1000; - db.record_breach(&login.meta.id, breach_time).unwrap(); + db.record_breach(&login.meta.id, breach_time, &*TEST_ENCDEC) + .unwrap(); assert!(db.is_potentially_breached(&login.meta.id).unwrap()); // Dismiss with a specific timestamp after the breach @@ -2189,5 +2292,200 @@ mod tests { entry.password = "pass3".to_string(); db.add_or_update(entry, &*TEST_ENCDEC).unwrap(); } + + #[test] + fn test_password_reuse_detection() { + ensure_initialized(); + let db = LoginDb::open_in_memory(); + + // Create two logins with the same password + let login1 = db + .add( + LoginEntry { + origin: "https://site1.com".into(), + http_realm: Some("realm".into()), + username: "user1".into(), + password: "shared_password".into(), + ..Default::default() + }, + &*TEST_ENCDEC, + ) + .unwrap(); + + let login2 = db + .add( + LoginEntry { + origin: "https://site2.com".into(), + http_realm: Some("realm".into()), + username: "user2".into(), + password: "shared_password".into(), + ..Default::default() + }, + &*TEST_ENCDEC, + ) + .unwrap(); + + // Initially, neither login is vulnerable + assert!(!db + .is_potentially_vulnerable_password(&login1.meta.id, &*TEST_ENCDEC) + .unwrap()); + assert!(!db + .is_potentially_vulnerable_password(&login2.meta.id, &*TEST_ENCDEC) + .unwrap()); + // And checking both logins should return empty (none are vulnerable yet) + let vulnerable = db + .are_potentially_vulnerable_passwords( + &[&login1.meta.id, &login2.meta.id], + &*TEST_ENCDEC, + ) + .unwrap(); + assert_eq!(vulnerable.len(), 0); + + // Mark login1 as breached + let breach_time = util::system_time_ms_i64(SystemTime::now()); + db.record_breach(&login1.meta.id, breach_time, &*TEST_ENCDEC) + .unwrap(); + + // login1 should be recognized as breached + assert!(db.is_potentially_breached(&login1.meta.id).unwrap()); + + // login2 should be recognized as vulnerable (same password as breached login1) + assert!(db + .is_potentially_vulnerable_password(&login2.meta.id, &*TEST_ENCDEC) + .unwrap()); + // Batch check: both logins should be vulnerable (they share the same password) + let vulnerable = db + .are_potentially_vulnerable_passwords( + &[&login1.meta.id, &login2.meta.id], + &*TEST_ENCDEC, + ) + .unwrap(); + assert_eq!(vulnerable.len(), 2); + assert!(vulnerable.contains(&login1.meta.id)); + assert!(vulnerable.contains(&login2.meta.id)); + + // Change password of login2 → should no longer be vulnerable + db.update( + &login2.meta.id, + LoginEntry { + origin: "https://site2.com".into(), + http_realm: Some("realm".into()), + username: "user2".into(), + password: "different_password".into(), + ..Default::default() + }, + &*TEST_ENCDEC, + ) + .unwrap(); + + assert!(!db + .is_potentially_vulnerable_password(&login2.meta.id, &*TEST_ENCDEC) + .unwrap()); + } + + #[test] + fn test_reset_all_breaches_clears_breach_table() { + ensure_initialized(); + let db = LoginDb::open_in_memory(); + + let login = db + .add( + LoginEntry { + origin: "https://example.com".into(), + http_realm: Some("realm".into()), + username: "user".into(), + password: "password123".into(), + ..Default::default() + }, + &*TEST_ENCDEC, + ) + .unwrap(); + + let breach_time = util::system_time_ms_i64(SystemTime::now()); + db.record_breach(&login.meta.id, breach_time, &*TEST_ENCDEC) + .unwrap(); + + // Verify that breachesL has an entry + let count: i64 = db + .db + .query_row("SELECT COUNT(*) FROM breachesL", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 1); + // And verify via the API that this login is vulnerable + let vulnerable = db + .are_potentially_vulnerable_passwords(&[&login.meta.id], &*TEST_ENCDEC) + .unwrap(); + assert_eq!(vulnerable.len(), 1); + assert_eq!(vulnerable[0], login.meta.id); + + // Reset all breaches + db.reset_all_breaches().unwrap(); + + // After reset, breachesL should be empty + let count: i64 = db + .db + .query_row("SELECT COUNT(*) FROM breachesL", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 0); + // And verify via the API that no logins are vulnerable anymore + let vulnerable = db + .are_potentially_vulnerable_passwords(&[&login.meta.id], &*TEST_ENCDEC) + .unwrap(); + assert_eq!(vulnerable.len(), 0); + + // And the login should no longer be breached + assert!(!db.is_potentially_breached(&login.meta.id).unwrap()); + } + + #[test] + fn test_different_passwords_not_vulnerable() { + ensure_initialized(); + let db = LoginDb::open_in_memory(); + + let login1 = db + .add( + LoginEntry { + origin: "https://site1.com".into(), + http_realm: Some("realm".into()), + username: "user".into(), + password: "password_A".into(), + ..Default::default() + }, + &*TEST_ENCDEC, + ) + .unwrap(); + + let login2 = db + .add( + LoginEntry { + origin: "https://site2.com".into(), + http_realm: Some("realm".into()), + username: "user".into(), + password: "password_B".into(), + ..Default::default() + }, + &*TEST_ENCDEC, + ) + .unwrap(); + + let breach_time = util::system_time_ms_i64(SystemTime::now()); + db.record_breach(&login1.meta.id, breach_time, &*TEST_ENCDEC) + .unwrap(); + + // login2 has a different password → not vulnerable + assert!(!db + .is_potentially_vulnerable_password(&login2.meta.id, &*TEST_ENCDEC) + .unwrap()); + // Batch check: login1 should be vulnerable (its password is in breachesL) + // login2 has a different password, so it's not vulnerable + let vulnerable = db + .are_potentially_vulnerable_passwords( + &[&login1.meta.id, &login2.meta.id], + &*TEST_ENCDEC, + ) + .unwrap(); + assert_eq!(vulnerable.len(), 1); + assert!(vulnerable.contains(&login1.meta.id)); + } } } diff --git a/components/logins/src/login.rs b/components/logins/src/login.rs index e486c0f0bf..fd0a4440d7 100644 --- a/components/logins/src/login.rs +++ b/components/logins/src/login.rs @@ -292,8 +292,6 @@ pub struct LoginFields { pub http_realm: Option, pub username_field: String, pub password_field: String, - pub time_of_last_breach: Option, - pub time_last_breach_alert_dismissed: Option, } /// LoginEntry fields that are stored encrypted @@ -347,6 +345,8 @@ pub struct LoginMeta { pub time_password_changed: i64, pub time_last_used: i64, pub times_used: i64, + pub time_of_last_breach: Option, + pub time_last_breach_alert_dismissed: Option, } /// A login together with meta fields, handed over to the store API; ie a login persisted @@ -459,6 +459,9 @@ pub struct Login { pub time_password_changed: i64, pub time_last_used: i64, pub times_used: i64, + // breach alerts + pub time_of_last_breach: Option, + pub time_last_breach_alert_dismissed: Option, // login fields pub origin: String, @@ -470,10 +473,6 @@ pub struct Login { // secure fields pub username: String, pub password: String, - - // breach alerts - pub time_of_last_breach: Option, - pub time_last_breach_alert_dismissed: Option, } impl Login { @@ -484,6 +483,8 @@ impl Login { time_password_changed: meta.time_password_changed, time_last_used: meta.time_last_used, times_used: meta.times_used, + time_of_last_breach: meta.time_of_last_breach, + time_last_breach_alert_dismissed: meta.time_last_breach_alert_dismissed, origin: fields.origin, form_action_origin: fields.form_action_origin, @@ -493,9 +494,6 @@ impl Login { username: sec_fields.username, password: sec_fields.password, - - time_of_last_breach: fields.time_last_breach_alert_dismissed, - time_last_breach_alert_dismissed: fields.time_last_breach_alert_dismissed, } } @@ -530,6 +528,8 @@ impl Login { time_password_changed: self.time_password_changed, time_last_used: self.time_last_used, times_used: self.times_used, + time_of_last_breach: self.time_of_last_breach, + time_last_breach_alert_dismissed: self.time_last_breach_alert_dismissed, }, fields: LoginFields { origin: self.origin, @@ -537,8 +537,6 @@ impl Login { http_realm: self.http_realm, username_field: self.username_field, password_field: self.password_field, - time_of_last_breach: self.time_last_breach_alert_dismissed, - time_last_breach_alert_dismissed: self.time_last_breach_alert_dismissed, }, sec_fields, }) @@ -586,6 +584,10 @@ impl EncryptedLogin { time_password_changed: row.get("timePasswordChanged")?, times_used: row.get("timesUsed")?, + + time_of_last_breach: row.get::<_, Option>("timeOfLastBreach")?, + time_last_breach_alert_dismissed: row + .get::<_, Option>("timeLastBreachAlertDismissed")?, }, fields: LoginFields { origin: row.get("origin")?, @@ -595,10 +597,6 @@ impl EncryptedLogin { username_field: string_or_default(row, "usernameField")?, password_field: string_or_default(row, "passwordField")?, - - time_of_last_breach: row.get::<_, Option>("timeOfLastBreach")?, - time_last_breach_alert_dismissed: row - .get::<_, Option>("timeLastBreachAlertDismissed")?, }, sec_fields: row.get("secFields")?, }; diff --git a/components/logins/src/logins.udl b/components/logins/src/logins.udl index 5bae6d40d5..a17c25bd58 100644 --- a/components/logins/src/logins.udl +++ b/components/logins/src/logins.udl @@ -57,6 +57,12 @@ dictionary LoginMeta { i64 time_created; i64 time_last_used; i64 time_password_changed; + + /// These fields can be synced from Desktop and are NOT included in LoginEntry, + /// so update() will not modify them. Use the dedicated API methods to manipulate + /// these fields, or use add_with_meta/update with LoginEntryWithMeta. + i64? time_of_last_breach; + i64? time_last_breach_alert_dismissed; }; /// A login together with record fields, handed over to the store API; ie a login persisted @@ -81,6 +87,9 @@ dictionary Login { i64 time_created; i64 time_last_used; i64 time_password_changed; + // breach fields + i64? time_of_last_breach; + i64? time_last_breach_alert_dismissed; // login fields string origin; @@ -92,15 +101,6 @@ dictionary Login { // secure login fields string password; string username; - - // breach alert fields - /// These fields can be synced from Desktop and are NOT included in LoginEntry, - /// so update() will not modify them. Use the dedicated API methods to manipulate: - /// record_breach(), reset_all_breaches(), is_potentially_breached(), - /// record_breach_alert_dismissal(), record_breach_alert_dismissal_time(), - /// and is_breach_alert_dismissed(). - i64? time_of_last_breach; - i64? time_last_breach_alert_dismissed; }; /// Metrics tracking deletion of logins that cannot be decrypted, see `delete_undecryptable_records_for_remote_replacement` @@ -241,6 +241,22 @@ interface LoginStore { [Throws=LoginsApiError] boolean is_potentially_breached([ByRef] string id); + /// Checks multiple logins for password reuse in a single batch operation. + /// + /// Returns the GUIDs of logins whose passwords match any password in the breach database. + /// This is more efficient than calling `is_potentially_vulnerable_password()` repeatedly, + /// as it decrypts the breach database only once. + [Throws=LoginsApiError] + sequence are_potentially_vulnerable_passwords(sequence ids); + + /// Checks if a login's password matches any password in the local breach database. + /// + /// Returns true if this login's password appears in the breachesL table, indicating + /// that the same password has been breached on a different domain (password reuse). + /// This is independent of whether this specific login has been marked as breached. + [Throws=LoginsApiError] + boolean is_potentially_vulnerable_password([ByRef] string id); + /// Stores a known breach date for a login. /// In Firefox Desktop this is updated once per session from Remote Settings. [Throws=LoginsApiError] diff --git a/components/logins/src/schema.rs b/components/logins/src/schema.rs index 77b028a02c..917433ac31 100644 --- a/components/logins/src/schema.rs +++ b/components/logins/src/schema.rs @@ -96,7 +96,8 @@ use sql_support::ConnExt; /// Version 1: SQLCipher -> plaintext migration. /// Version 2: addition of `loginsM.enc_unknown_fields`. /// Version 3: addition of `timeOfLastBreach` and `timeLastBreachAlertDismissed`. -pub(super) const VERSION: i64 = 3; +/// Version 4: addition of `breachesL` table +pub(super) const VERSION: i64 = 4; /// Every column shared by both tables except for `id` /// @@ -143,7 +144,7 @@ const COMMON_SQL: &str = " timeCreated INTEGER NOT NULL, timeLastUsed INTEGER, timePasswordChanged INTEGER NOT NULL, - timeOfLastBreach INTEGER, + timeOfLastBreach INTEGER, timeLastBreachAlertDismissed INTEGER, secFields TEXT, guid TEXT NOT NULL UNIQUE @@ -196,6 +197,21 @@ const CREATE_DELETED_ORIGIN_INDEX_SQL: &str = " ON loginsL (is_deleted, origin) "; +// breachesL stores encrypted passwords from logins that have been marked as breached. +// This allows cross-domain password reuse detection: if a password was breached on site A, +// we can warn users who reuse that same password on site B. +// +// Performance considerations: +// - No index is needed: queries scan all rows, which is acceptable given the small expected size +// (typically < 1000 entries per user) +// - Future consideration: implement retention policy to prevent unbounded growth +// (e.g., remove entries older than N months, or cap at N entries) +const CREATE_LOCAL_BREACHES_TABLE_SQL: &str = " + CREATE TABLE IF NOT EXISTS breachesL ( + encryptedPassword TEXT + ) +"; + pub(crate) static LAST_SYNC_META_KEY: &str = "last_sync_time"; pub(crate) static GLOBAL_STATE_META_KEY: &str = "global_state_v2"; pub(crate) static GLOBAL_SYNCID_META_KEY: &str = "global_sync_id"; @@ -256,6 +272,8 @@ fn upgrade_from(db: &Connection, from: i64) -> Result<()> { ALTER TABLE loginsM ADD timeLastBreachAlertDismissed INTEGER;", )?), + 3 => Ok(db.execute_batch(CREATE_LOCAL_BREACHES_TABLE_SQL)?), + // next migration, add here _ => Err(Error::IncompatibleVersion(from)), } @@ -269,6 +287,7 @@ pub(crate) fn create(db: &Connection) -> Result<()> { CREATE_OVERRIDE_ORIGIN_INDEX_SQL, CREATE_DELETED_ORIGIN_INDEX_SQL, CREATE_META_TABLE_SQL, + CREATE_LOCAL_BREACHES_TABLE_SQL, &*SET_VERSION_SQL, ])?; Ok(()) diff --git a/components/logins/src/store.rs b/components/logins/src/store.rs index 4328463f96..1a051416ba 100644 --- a/components/logins/src/store.rs +++ b/components/logins/src/store.rs @@ -183,9 +183,24 @@ impl LoginStore { self.lock_db()?.is_potentially_breached(id) } + #[handle_error(Error)] + pub fn are_potentially_vulnerable_passwords(&self, ids: Vec) -> ApiResult> { + // Note: Vec<&str> is not supported with UDL, so we receive Vec and convert + let db = self.lock_db()?; + let ids: Vec<&str> = ids.iter().map(|id| &**id).collect(); + db.are_potentially_vulnerable_passwords(&ids, db.encdec.as_ref()) + } + + #[handle_error(Error)] + pub fn is_potentially_vulnerable_password(&self, id: &str) -> ApiResult { + let db = self.lock_db()?; + db.is_potentially_vulnerable_password(id, db.encdec.as_ref()) + } + #[handle_error(Error)] pub fn record_breach(&self, id: &str, timestamp: i64) -> ApiResult<()> { - self.lock_db()?.record_breach(id, timestamp) + let db = self.lock_db()?; + db.record_breach(id, timestamp, db.encdec.as_ref()) } #[handle_error(Error)] diff --git a/components/logins/src/sync/payload.rs b/components/logins/src/sync/payload.rs index 4901c16cb5..00a03988a3 100644 --- a/components/logins/src/sync/payload.rs +++ b/components/logins/src/sync/payload.rs @@ -70,8 +70,6 @@ impl IncomingLogin { http_realm: p.http_realm, username_field: p.username_field, password_field: p.password_field, - time_of_last_breach: p.time_of_last_breach, - time_last_breach_alert_dismissed: p.time_last_breach_alert_dismissed, }; let original_sec_fields = SecureLoginFields { username: p.username, @@ -88,8 +86,6 @@ impl IncomingLogin { http_realm: login_entry.http_realm, username_field: login_entry.username_field, password_field: login_entry.password_field, - time_of_last_breach: None, - time_last_breach_alert_dismissed: None, }; let id = String::from(p.guid); let sec_fields = SecureLoginFields { @@ -115,6 +111,8 @@ impl IncomingLogin { time_password_changed: p.time_password_changed, time_last_used: p.time_last_used, times_used: p.times_used, + time_of_last_breach: p.time_of_last_breach, + time_last_breach_alert_dismissed: p.time_last_breach_alert_dismissed, }, fields, sec_fields, @@ -170,10 +168,6 @@ pub struct LoginPayload { #[serde(default)] pub times_used: i64, - // Additional "unknown" round-tripped fields. - #[serde(flatten)] - unknown_fields: UnknownFields, - #[serde(default)] #[serde(deserialize_with = "deserialize_optional_timestamp")] pub time_of_last_breach: Option, @@ -181,6 +175,10 @@ pub struct LoginPayload { #[serde(default)] #[serde(deserialize_with = "deserialize_optional_timestamp")] pub time_last_breach_alert_dismissed: Option, + + // Additional "unknown" round-tripped fields. + #[serde(flatten)] + unknown_fields: UnknownFields, } // These probably should be on the payload itself, but one refactor at a time! @@ -209,8 +207,8 @@ impl EncryptedLogin { time_password_changed: self.meta.time_password_changed, time_last_used: self.meta.time_last_used, times_used: self.meta.times_used, - time_of_last_breach: self.fields.time_of_last_breach, - time_last_breach_alert_dismissed: self.fields.time_last_breach_alert_dismissed, + time_of_last_breach: self.meta.time_of_last_breach, + time_last_breach_alert_dismissed: self.meta.time_last_breach_alert_dismissed, unknown_fields, }, )?)