diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d9241ce4..06e885c462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # v148.0 (In progress) +### Logins +- Add breach alert support, including a database migration to version 3, + new `Login` fields (`time_of_last_breach`, `time_last_breach_alert_dismissed`), + and new `LoginStore` APIs (`record_breach`, `reset_all_breaches`, `is_potentially_breached`, `record_breach_alert_dismissal_time`, `record_breach_alert_dismissal`, `is_breach_alert_dismissed`). ([#7127](https://github.com/mozilla/application-services/pull/7127)) + [Full Changelog](In progress) ### Ads Client diff --git a/components/logins/src/db.rs b/components/logins/src/db.rs index 8336bcdc45..e739d486cd 100644 --- a/components/logins/src/db.rs +++ b/components/logins/src/db.rs @@ -306,6 +306,84 @@ impl LoginDb { Ok(()) } + pub fn record_breach(&self, id: &str, timestamp: i64) -> Result<()> { + let tx = self.unchecked_transaction()?; + self.ensure_local_overlay_exists(id)?; + self.mark_mirror_overridden(id)?; + self.execute_cached( + "UPDATE loginsL + SET timeOfLastBreach = :now_millis + WHERE guid = :guid", + named_params! { + ":now_millis": timestamp, + ":guid": id, + }, + )?; + tx.commit()?; + Ok(()) + } + + pub fn is_potentially_breached(&self, id: &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 }, + |row| row.get(0), + )?; + Ok(is_potentially_breached) + } + + pub fn reset_all_breaches(&self) -> Result<()> { + let tx = self.unchecked_transaction()?; + self.execute_cached( + "UPDATE loginsL + SET timeOfLastBreach = NULL + WHERE timeOfLastBreach IS NOT NULL", + [], + )?; + tx.commit()?; + Ok(()) + } + + pub fn is_breach_alert_dismissed(&self, id: &str) -> Result { + let is_breach_alert_dismissed: bool = self.db.query_row( + "SELECT EXISTS(SELECT 1 FROM loginsL WHERE guid = :guid AND timeOfLastBreach < timeLastBreachAlertDismissed)", + named_params! { ":guid": id }, + |row| row.get(0), + )?; + Ok(is_breach_alert_dismissed) + } + + /// Records that the user dismissed the breach alert for a login using the current time. + /// + /// For testing or when you need to specify a particular timestamp, use + /// [`record_breach_alert_dismissal_time`](Self::record_breach_alert_dismissal_time) instead. + pub fn record_breach_alert_dismissal(&self, id: &str) -> Result<()> { + let timestamp = util::system_time_ms_i64(SystemTime::now()); + self.record_breach_alert_dismissal_time(id, timestamp) + } + + /// Records that the user dismissed the breach alert for a login at a specific time. + /// + /// This is primarily useful for testing or when syncing dismissal times from other devices. + /// For normal usage, prefer [`record_breach_alert_dismissal`](Self::record_breach_alert_dismissal) + /// which automatically uses the current time. + pub fn record_breach_alert_dismissal_time(&self, id: &str, timestamp: i64) -> Result<()> { + let tx = self.unchecked_transaction()?; + self.ensure_local_overlay_exists(id)?; + self.mark_mirror_overridden(id)?; + self.execute_cached( + "UPDATE loginsL + SET timeLastBreachAlertDismissed = :now_millis + WHERE guid = :guid", + named_params! { + ":now_millis": timestamp, + ":guid": id, + }, + )?; + tx.commit()?; + Ok(()) + } + // The single place we insert new rows or update existing local rows. // just the SQL - no validation or anything. fn insert_new_login(&self, login: &EncryptedLogin) -> Result<()> { @@ -322,6 +400,8 @@ impl LoginDb { timeCreated, timeLastUsed, timePasswordChanged, + timeOfLastBreach, + timeLastBreachAlertDismissed, local_modified, is_deleted, sync_status @@ -337,6 +417,8 @@ impl LoginDb { :time_created, :time_last_used, :time_password_changed, + :time_of_last_breach, + :time_last_breach_alert_dismissed, :local_modified, 0, -- is_deleted {new} -- sync_status @@ -356,6 +438,8 @@ 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, ":sec_fields": login.sec_fields, ":guid": login.guid(), @@ -368,18 +452,20 @@ impl LoginDb { // assumes the "local overlay" exists, so the guid must too. let sql = format!( "UPDATE loginsL - SET local_modified = :now_millis, - timeLastUsed = :time_last_used, - timePasswordChanged = :time_password_changed, - httpRealm = :http_realm, - formActionOrigin = :form_action_origin, - usernameField = :username_field, - passwordField = :password_field, - timesUsed = :times_used, - secFields = :sec_fields, - origin = :origin, + SET local_modified = :now_millis, + timeLastUsed = :time_last_used, + timePasswordChanged = :time_password_changed, + timeOfLastBreach = :time_of_last_breach, + timeLastBreachAlertDismissed = :time_last_breach_alert_dismissed, + httpRealm = :http_realm, + formActionOrigin = :form_action_origin, + usernameField = :username_field, + passwordField = :password_field, + timesUsed = :times_used, + secFields = :sec_fields, + origin = :origin, -- leave New records as they are, otherwise update them to `changed` - sync_status = max(sync_status, {changed}) + sync_status = max(sync_status, {changed}) WHERE guid = :guid", changed = SyncStatus::Changed as u8 ); @@ -397,6 +483,8 @@ impl LoginDb { ":time_password_changed": login.meta.time_password_changed, ":sec_fields": login.sec_fields, ":guid": &login.meta.id, + ":time_of_last_breach": login.fields.time_of_last_breach, + ":time_last_breach_alert_dismissed": login.fields.time_last_breach_alert_dismissed, // time_last_used has been set to now. ":now_millis": login.meta.time_last_used, }, @@ -459,6 +547,8 @@ 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, }; @@ -573,6 +663,8 @@ 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, }; @@ -1018,6 +1110,9 @@ pub mod test_utils { timePasswordChanged, timeCreated, + timeOfLastBreach, + timeLastBreachAlertDismissed, + guid ) VALUES ( :is_overridden, @@ -1035,6 +1130,9 @@ pub mod test_utils { :time_password_changed, :time_created, + :time_of_last_breach, + :time_last_breach_alert_dismissed, + :guid )"; let mut stmt = db.prepare_cached(sql)?; @@ -1052,6 +1150,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, ":guid": login.guid_str(), })?; Ok(()) @@ -1678,6 +1778,114 @@ mod tests { assert_eq!(login2.meta.times_used, login.meta.times_used + 1); } + #[test] + fn test_breach_alerts() { + ensure_initialized(); + let db = LoginDb::open_in_memory(); + let login = db + .add( + LoginEntry { + origin: "https://www.example.com".into(), + http_realm: Some("https://www.example.com".into()), + username: "user1".into(), + password: "password1".into(), + ..Default::default() + }, + &*TEST_ENCDEC, + ) + .unwrap(); + // initial state + assert!(login.fields.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()); + + // set - use a time that's definitely after password was changed + let breach_time = login.meta.time_password_changed + 1000; + db.record_breach(&login.meta.id, breach_time).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()); + + // 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()); + + // 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()); + + // set again - use a time that's definitely after password was changed + let breach_time2 = login.meta.time_password_changed + 2000; + db.record_breach(&login.meta.id, breach_time2).unwrap(); + assert!(db.is_potentially_breached(&login.meta.id).unwrap()); + + // now change password + db.update( + &login.meta.id.clone(), + LoginEntry { + password: "changed-password".into(), + ..login.clone().decrypt(&*TEST_ENCDEC).unwrap().entry() + }, + &*TEST_ENCDEC, + ) + .unwrap(); + // not breached anymore + assert!(!db.is_potentially_breached(&login.meta.id).unwrap()); + } + + #[test] + fn test_breach_alert_dismissal_with_specific_timestamp() { + ensure_initialized(); + let db = LoginDb::open_in_memory(); + let login = db + .add( + LoginEntry { + origin: "https://www.example.com".into(), + http_realm: Some("https://www.example.com".into()), + username: "user1".into(), + password: "password1".into(), + ..Default::default() + }, + &*TEST_ENCDEC, + ) + .unwrap(); + + // 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(); + assert!(db.is_potentially_breached(&login.meta.id).unwrap()); + + // Dismiss with a specific timestamp after the breach + let dismiss_time = breach_time + 500; + db.record_breach_alert_dismissal_time(&login.meta.id, dismiss_time) + .unwrap(); + + // Verify the exact timestamp was stored + let retrieved = db + .get_by_id(&login.meta.id) + .unwrap() + .unwrap() + .decrypt(&*TEST_ENCDEC) + .unwrap(); + assert_eq!( + retrieved.time_last_breach_alert_dismissed, + Some(dismiss_time) + ); + + // Verify the breach alert is considered dismissed + assert!(db.is_breach_alert_dismissed(&login.meta.id).unwrap()); + + // Test that dismissing before the breach time means it's not dismissed + let earlier_dismiss_time = breach_time - 100; + db.record_breach_alert_dismissal_time(&login.meta.id, earlier_dismiss_time) + .unwrap(); + assert!(!db.is_breach_alert_dismissed(&login.meta.id).unwrap()); + } + #[test] fn test_delete() { ensure_initialized(); diff --git a/components/logins/src/error.rs b/components/logins/src/error.rs index 0837a4c76d..a08c20225a 100644 --- a/components/logins/src/error.rs +++ b/components/logins/src/error.rs @@ -113,6 +113,9 @@ pub enum Error { #[error("Migration Error: {0}")] MigrationError(String), + + #[error("IncompatibleVersion: {0}")] + IncompatibleVersion(i64), } /// Error::InvalidLogin subtypes diff --git a/components/logins/src/login.rs b/components/logins/src/login.rs index e3e150a722..e486c0f0bf 100644 --- a/components/logins/src/login.rs +++ b/components/logins/src/login.rs @@ -292,6 +292,8 @@ 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 @@ -355,6 +357,9 @@ pub struct LoginEntryWithMeta { } /// A bulk insert result entry, returned by `add_many` and `add_many_with_records` +/// Please note that although the success case is much larger than the error case, this is +/// negligible in real life, as we expect a very small success/error ratio. +#[allow(clippy::large_enum_variant)] pub enum BulkResultEntry { Success { login: Login }, Error { message: String }, @@ -465,6 +470,10 @@ 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 +493,9 @@ 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, } } @@ -525,6 +537,8 @@ 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, }) @@ -581,6 +595,10 @@ 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 080489fb76..a6ad8fbc90 100644 --- a/components/logins/src/logins.udl +++ b/components/logins/src/logins.udl @@ -22,7 +22,7 @@ namespace logins { /// Utility function to create a StaticKeyManager to be used for the time /// being until support lands for [trait implementation of an UniFFI /// interface](https://mozilla.github.io/uniffi-rs/next/proc_macro/index.html#structs-implementing-traits) - /// in UniFFI. + /// in UniFFI. KeyManager create_static_key_manager(string key); /// Similar to create_static_key_manager above, create a @@ -92,6 +92,10 @@ dictionary Login { // secure login fields string password; string username; + + // breach alert fields + 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` @@ -228,6 +232,31 @@ interface LoginStore { [Throws=LoginsApiError] void touch([ByRef] string id); + /// Determines whether a login’s password is potentially breached, based on the breach date and the time of the last password change. + [Throws=LoginsApiError] + boolean is_potentially_breached([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] + void record_breach([ByRef] string id, i64 timestamp); + + /// Removes all recorded breaches for all logins (i.e. sets time_of_last_breach to null). + [Throws=LoginsApiError] + void reset_all_breaches(); + + /// Determines whether a breach alert has been dismissed, based on the breach date and the alert dismissal timestamp. + [Throws=LoginsApiError] + boolean is_breach_alert_dismissed([ByRef] string id); + + /// Stores that the user dismissed the breach alert for a login. + [Throws=LoginsApiError] + void record_breach_alert_dismissal([ByRef] string id); + + /// Stores the time at which the user dismissed the breach alert for a login. + [Throws=LoginsApiError] + void record_breach_alert_dismissal_time([ByRef] string id, i64 timestamp); + [Throws=LoginsApiError] boolean is_empty(); diff --git a/components/logins/src/schema.rs b/components/logins/src/schema.rs index 79376b01e0..77b028a02c 100644 --- a/components/logins/src/schema.rs +++ b/components/logins/src/schema.rs @@ -95,7 +95,8 @@ use sql_support::ConnExt; /// Version 1: SQLCipher -> plaintext migration. /// Version 2: addition of `loginsM.enc_unknown_fields`. -pub(super) const VERSION: i64 = 2; +/// Version 3: addition of `timeOfLastBreach` and `timeLastBreachAlertDismissed`. +pub(super) const VERSION: i64 = 3; /// Every column shared by both tables except for `id` /// @@ -125,29 +126,34 @@ pub const COMMON_COLS: &str = " timeCreated, timeLastUsed, timePasswordChanged, - timesUsed + timesUsed, + timeOfLastBreach, + timeLastBreachAlertDismissed "; const COMMON_SQL: &str = " - id INTEGER PRIMARY KEY AUTOINCREMENT, - origin TEXT NOT NULL, + id INTEGER PRIMARY KEY AUTOINCREMENT, + origin TEXT NOT NULL, -- Exactly one of httpRealm or formActionOrigin should be set - httpRealm TEXT, - formActionOrigin TEXT, - usernameField TEXT, - passwordField TEXT, - timesUsed INTEGER NOT NULL DEFAULT 0, - timeCreated INTEGER NOT NULL, - timeLastUsed INTEGER, - timePasswordChanged INTEGER NOT NULL, - secFields TEXT, - guid TEXT NOT NULL UNIQUE + httpRealm TEXT, + formActionOrigin TEXT, + usernameField TEXT, + passwordField TEXT, + timesUsed INTEGER NOT NULL DEFAULT 0, + timeCreated INTEGER NOT NULL, + timeLastUsed INTEGER, + timePasswordChanged INTEGER NOT NULL, + timeOfLastBreach INTEGER, + timeLastBreachAlertDismissed INTEGER, + secFields TEXT, + guid TEXT NOT NULL UNIQUE "; lazy_static! { static ref CREATE_LOCAL_TABLE_SQL: String = format!( "CREATE TABLE IF NOT EXISTS loginsL ( {common_sql}, + -- Milliseconds, or NULL if never modified locally. local_modified INTEGER, @@ -220,26 +226,41 @@ pub(crate) fn init(db: &Connection) -> Result<()> { #[allow(clippy::unnecessary_wraps)] fn upgrade(db: &Connection, from: i64) -> Result<()> { debug!("Upgrading schema from {} to {}", from, VERSION); + if from == VERSION { return Ok(()); } - assert_ne!( - from, 0, - "Upgrading from user_version = 0 should already be handled (in `init`)" - ); - // Schema upgrades. - if from == 1 { - // Just one new nullable column makes this fairly easy - db.execute_batch("ALTER TABLE loginsM ADD enc_unknown_fields TEXT;")?; + for version in from..VERSION { + upgrade_from(db, version)?; } - // XXX - next migration, be sure to: - // from = 2; - // if from == 2 ... + db.execute_batch(&SET_VERSION_SQL)?; Ok(()) } +fn upgrade_from(db: &Connection, from: i64) -> Result<()> { + debug!("- running schema upgrade {}", from); + // Schema upgrades. + match from { + 0 => Err(Error::IncompatibleVersion(from)), + + // Just one new nullable column makes this fairly easy + 1 => Ok(db.execute_batch("ALTER TABLE loginsM ADD enc_unknown_fields TEXT;")?), + + // again, easy migratable nullable columns + 2 => Ok(db.execute_batch( + "ALTER TABLE loginsL ADD timeOfLastBreach INTEGER; + ALTER TABLE loginsM ADD timeOfLastBreach INTEGER; + ALTER TABLE loginsL ADD timeLastBreachAlertDismissed INTEGER; + ALTER TABLE loginsM ADD timeLastBreachAlertDismissed INTEGER;", + )?), + + // next migration, add here + _ => Err(Error::IncompatibleVersion(from)), + } +} + pub(crate) fn create(db: &Connection) -> Result<()> { debug!("Creating schema"); db.execute_all(&[ @@ -262,6 +283,46 @@ mod tests { use nss::ensure_initialized; use rusqlite::Connection; + // Snapshot of the schema in version 1. We use this to test that we can migrate from there to the + // current schema. + const SCHEMA_V1: &str = r#" +CREATE TABLE IF NOT EXISTS loginsL ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + origin TEXT NOT NULL, + httpRealm TEXT, + formActionOrigin TEXT, + usernameField TEXT, + passwordField TEXT, + timesUsed INTEGER NOT NULL DEFAULT 0, + timeCreated INTEGER NOT NULL, + timeLastUsed INTEGER, + timePasswordChanged INTEGER NOT NULL, + secFields TEXT, + + local_modified INTEGER, + + is_deleted TINYINT NOT NULL DEFAULT 0, + sync_status TINYINT NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS loginsM ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + origin TEXT NOT NULL, + httpRealm TEXT, + formActionOrigin TEXT, + usernameField TEXT, + passwordField TEXT, + timesUsed INTEGER NOT NULL DEFAULT 0, + timeCreated INTEGER NOT NULL, + timeLastUsed INTEGER, + timePasswordChanged INTEGER NOT NULL, + secFields TEXT, + guid TEXT NOT NULL UNIQUE, + server_modified INTEGER NOT NULL, + is_overridden TINYINT NOT NULL DEFAULT 0 +); +PRAGMA user_version=1; + "#; + #[test] fn test_create_schema() { ensure_initialized(); @@ -271,51 +332,24 @@ mod tests { assert_eq!(version, VERSION); } + /// Test running all schema upgrades. + /// + /// If an upgrade fails, then this test will fail with a panic. #[test] - fn test_upgrade_v1() { + fn test_all_upgrades() { ensure_initialized(); // manually setup a V1 schema. let connection = Connection::open_in_memory().unwrap(); - connection - .execute_batch( - " - CREATE TABLE IF NOT EXISTS loginsM ( - -- this was common_sql as at v1 - id INTEGER PRIMARY KEY AUTOINCREMENT, - origin TEXT NOT NULL, - httpRealm TEXT, - formActionOrigin TEXT, - usernameField TEXT, - passwordField TEXT, - timesUsed INTEGER NOT NULL DEFAULT 0, - timeCreated INTEGER NOT NULL, - timeLastUsed INTEGER, - timePasswordChanged INTEGER NOT NULL, - secFields TEXT, - guid TEXT NOT NULL UNIQUE, - server_modified INTEGER NOT NULL, - is_overridden TINYINT NOT NULL DEFAULT 0 - -- note enc_unknown_fields missing - ); - ", - ) - .unwrap(); - // Call `create` to create the rest of the schema - the "if not exists" means loginsM - // will remain as v1. - create(&connection).unwrap(); - // but that set the version to VERSION - set it back to 1 so our upgrade code runs. - connection - .execute_batch("PRAGMA user_version = 1;") + connection.execute_batch(SCHEMA_V1).unwrap(); + let version = connection + .conn_ext_query_one::("PRAGMA user_version") .unwrap(); + assert_eq!(version, 1); // Now open the DB - it will create loginsL for us and migrate loginsM. let db = LoginDb::with_connection(connection, TEST_ENCDEC.clone()).unwrap(); // all migrations should have succeeded. let version = db.conn_ext_query_one::("PRAGMA user_version").unwrap(); assert_eq!(version, VERSION); - - // and ensure sql selecting the new column works. - db.execute_batch("SELECT enc_unknown_fields FROM loginsM") - .unwrap(); } } diff --git a/components/logins/src/store.rs b/components/logins/src/store.rs index 405c9b0efa..4328463f96 100644 --- a/components/logins/src/store.rs +++ b/components/logins/src/store.rs @@ -178,6 +178,37 @@ impl LoginStore { self.lock_db()?.touch(id) } + #[handle_error(Error)] + pub fn is_potentially_breached(&self, id: &str) -> ApiResult { + self.lock_db()?.is_potentially_breached(id) + } + + #[handle_error(Error)] + pub fn record_breach(&self, id: &str, timestamp: i64) -> ApiResult<()> { + self.lock_db()?.record_breach(id, timestamp) + } + + #[handle_error(Error)] + pub fn reset_all_breaches(&self) -> ApiResult<()> { + self.lock_db()?.reset_all_breaches() + } + + #[handle_error(Error)] + pub fn is_breach_alert_dismissed(&self, id: &str) -> ApiResult { + self.lock_db()?.is_breach_alert_dismissed(id) + } + + #[handle_error(Error)] + pub fn record_breach_alert_dismissal(&self, id: &str) -> ApiResult<()> { + self.lock_db()?.record_breach_alert_dismissal(id) + } + + #[handle_error(Error)] + pub fn record_breach_alert_dismissal_time(&self, id: &str, timestamp: i64) -> ApiResult<()> { + self.lock_db()? + .record_breach_alert_dismissal_time(id, timestamp) + } + #[handle_error(Error)] pub fn delete(&self, id: &str) -> ApiResult { self.lock_db()?.delete(id) diff --git a/components/logins/src/sync/engine.rs b/components/logins/src/sync/engine.rs index 1cb484e5c4..0cde79e7b2 100644 --- a/components/logins/src/sync/engine.rs +++ b/components/logins/src/sync/engine.rs @@ -99,7 +99,7 @@ impl LoginsSyncEngine { let local_modified = UNIX_EPOCH + Duration::from_millis(dupe.meta.time_password_changed as u64); let local = LocalLogin::Alive { - login: dupe, + login: Box::new(dupe), local_modified, }; plan.plan_two_way_merge(local, (upstream, upstream_time)); diff --git a/components/logins/src/sync/merge.rs b/components/logins/src/sync/merge.rs index 2786d64e7f..a64edf67a9 100644 --- a/components/logins/src/sync/merge.rs +++ b/components/logins/src/sync/merge.rs @@ -40,7 +40,7 @@ pub(crate) enum LocalLogin { local_modified: SystemTime, }, Alive { - login: EncryptedLogin, + login: Box, local_modified: SystemTime, }, } @@ -72,7 +72,7 @@ impl LocalLogin { error_support::report_error!("logins-crypto", "empty ciphertext in the db",); } LocalLogin::Alive { - login, + login: Box::new(login), local_modified, } }) diff --git a/components/logins/src/sync/payload.rs b/components/logins/src/sync/payload.rs index a10a634b94..4901c16cb5 100644 --- a/components/logins/src/sync/payload.rs +++ b/components/logins/src/sync/payload.rs @@ -70,6 +70,8 @@ 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, @@ -86,6 +88,8 @@ 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 { @@ -169,6 +173,14 @@ pub struct LoginPayload { // Additional "unknown" round-tripped fields. #[serde(flatten)] unknown_fields: UnknownFields, + + #[serde(default)] + #[serde(deserialize_with = "deserialize_optional_timestamp")] + pub time_of_last_breach: Option, + + #[serde(default)] + #[serde(deserialize_with = "deserialize_optional_timestamp")] + pub time_last_breach_alert_dismissed: Option, } // These probably should be on the payload itself, but one refactor at a time! @@ -197,6 +209,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, unknown_fields, }, )?) @@ -217,6 +231,18 @@ where Ok(i64::deserialize(deserializer).unwrap_or_default().max(0)) } +// Quiet clippy, since this function is passed to deserialiaze_with... +#[allow(clippy::unnecessary_wraps)] +fn deserialize_optional_timestamp<'de, D>( + deserializer: D, +) -> std::result::Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + use serde::de::Deserialize; + Ok(i64::deserialize(deserializer).ok()) +} + #[cfg(not(feature = "keydb"))] #[cfg(test)] mod tests { diff --git a/components/logins/src/sync/update_plan.rs b/components/logins/src/sync/update_plan.rs index 122a2f7d40..50a77583cc 100644 --- a/components/logins/src/sync/update_plan.rs +++ b/components/logins/src/sync/update_plan.rs @@ -449,7 +449,7 @@ mod tests { // And since the local age is 100, then the server should win. let server_record_timestamp = now.checked_sub(Duration::from_secs(1)).unwrap(); let local_login = LocalLogin::Alive { - login: login.clone(), + login: Box::new(login.clone()), local_modified, }; @@ -516,7 +516,7 @@ mod tests { // And since the local age is 1, the local record should win! let server_record_timestamp = now.checked_sub(Duration::from_secs(500)).unwrap(); let local_login = LocalLogin::Alive { - login: login.clone(), + login: Box::new(login.clone()), local_modified, }; let mirror_login = MirrorLogin { diff --git a/components/support/rc_crypto/nss/fixtures/profile/logins.db b/components/support/rc_crypto/nss/fixtures/profile/logins.db index 93d7ce7517..ebe1a36f4b 100644 Binary files a/components/support/rc_crypto/nss/fixtures/profile/logins.db and b/components/support/rc_crypto/nss/fixtures/profile/logins.db differ