Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

[Full Changelog](In progress)

## ✨ What's Changed ✨

### Logins

- Add `LoginStore::list_candidates()` and `LoginStore::get_many()`, a pair of read APIs for consumers which filter logins on their unencrypted fields. `list_candidates()` returns a `LoginCandidate` per stored login - everything `Login` has except the secure fields (`username`/`password`), so searching by `origin`, `httpRealm` or `formActionOrigin` no longer forces a primary password prompt. `get_many()` then decrypts just the logins which matched. `list()` is unchanged, for callers who really do want every login in cleartext.

# v155.0 (_2026-08-13_)

[Full Changelog](https://github.com/mozilla/application-services/compare/v154.0...v155.0)
Expand Down
66 changes: 66 additions & 0 deletions components/logins/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,27 @@ impl LoginDb {
rows.collect::<Result<_>>()
}

/// Like `get_all()`, but only the logins with the given guids. Guids we don't have a login
/// for are simply absent from the result, so this can return fewer rows than it was given
/// ids. As with `get_all()` the order of the rows is whatever the query gives us - in
/// particular it is not the order of `ids`.
pub fn get_many(&self, ids: &[String]) -> Result<Vec<EncryptedLogin>> {
let mut logins = Vec::with_capacity(ids.len());
sql_support::each_chunk(ids, |chunk, _| -> Result<()> {
logins.extend(self.db.query_rows_and_then(
&format!(
"SELECT * FROM ({}) WHERE guid IN ({})",
&*GET_ALL_SQL,
sql_support::repeat_sql_values(chunk.len())
),
rusqlite::params_from_iter(chunk),
EncryptedLogin::from_row,
)?);
Ok(())
})?;
Ok(logins)
}

pub fn get_by_base_domain(&self, base_domain: &str) -> Result<Vec<EncryptedLogin>> {
// We first parse the input string as a host so it is normalized.
let base_host = match Host::parse(base_domain) {
Expand Down Expand Up @@ -1387,6 +1408,51 @@ mod tests {
assert_eq!(db.get_all().unwrap().len(), 2);
}

#[test]
fn test_get_many() {
ensure_initialized();

let db = LoginDb::open_in_memory();
let mut added = Vec::new();
for origin in ["https://a.example.com", "https://b.example.com"] {
added.push(
db.add(LoginEntry {
origin: origin.into(),
http_realm: Some("https://www.example.com".into()),
username: "test".into(),
password: "sekret".into(),
..LoginEntry::default()
})
.expect("should be able to add login"),
);
}
let ids = added.iter().map(|l| l.meta.id.clone()).collect::<Vec<_>>();

// Neither `get_many()` nor `get_all()` promises an order, so compare them sorted.
let by_origin = |logins: Vec<EncryptedLogin>| {
let mut logins = logins;
logins.sort_by(|l, r| l.fields.origin.cmp(&r.fields.origin));
logins
};

// Asking for every id gives us exactly what `get_all()` does.
assert_eq!(
by_origin(db.get_many(&ids).unwrap()),
by_origin(db.get_all().unwrap())
);

// A subset gives us just that subset...
assert_eq!(db.get_many(&ids[1..]).unwrap(), added[1..]);

// ...and ids we don't have a login for are absent rather than an error.
assert_eq!(
db.get_many(&[ids[0].clone(), "no-such-guid".to_string()])
.unwrap(),
added[..1]
);
assert_eq!(db.get_many(&[]).unwrap(), Vec::new());
}

#[test]
fn test_add_many() {
ensure_initialized();
Expand Down
48 changes: 48 additions & 0 deletions components/logins/src/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
//! * [`Login`] - A [`LoginEntry`] plus DB record information. This includes the GUID and metadata
//! like time_last_used.
//! * [`EncryptedLogin`] -- A Login above with the username/password data encrypted.
//! * [`LoginCandidate`] -- A [`Login`] without the username/password, for callers who want to
//! filter on the cleartext fields before asking for the encryption key.
//! * [`LoginFields`], [`SecureLoginFields`], [`LoginMeta`] -- These group the common fields in the
//! structs above.
//!
Expand Down Expand Up @@ -718,6 +720,52 @@ impl EncryptedLogin {
}
}

/// A login stored in the database, minus the encrypted fields.
///
/// Getting one of these never needs the encryption key, so callers which only match on the
/// cleartext fields (eg, `origin`) can do so without forcing the user to authenticate. Once
/// they know which logins they want, `LoginStore::get_many()` decrypts just those.
#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
pub struct LoginCandidate {
// meta fields
pub id: String,
pub time_created: i64,
pub time_password_changed: i64,
pub time_last_used: i64,
pub times_used: i64,

// breach alerts
pub time_last_breach_alert_dismissed: Option<i64>,

// login fields
pub origin: String,
pub form_action_origin: Option<String>,
pub http_realm: Option<String>,
pub username_field: String,
pub password_field: String,
}

impl From<EncryptedLogin> for LoginCandidate {
fn from(login: EncryptedLogin) -> Self {
// Note the `sec_fields` are simply dropped - we never look at the key.
let EncryptedLogin { meta, fields, .. } = login;
Self {
id: meta.id,
time_created: meta.time_created,
time_password_changed: meta.time_password_changed,
time_last_used: meta.time_last_used,
times_used: meta.times_used,
time_last_breach_alert_dismissed: meta.time_last_breach_alert_dismissed,

origin: fields.origin,
form_action_origin: fields.form_action_origin,
http_realm: fields.http_realm,
username_field: fields.username_field,
password_field: fields.password_field,
}
}
}

fn string_or_default(row: &Row<'_>, col: &str) -> Result<String> {
Ok(row.get::<_, Option<String>>(col)?.unwrap_or_default())
}
Expand Down
32 changes: 32 additions & 0 deletions components/logins/src/logins.udl
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,28 @@ dictionary Login {
string username;
};

/// A login stored in the database, minus the encrypted fields.
///
/// Reading these never needs the encryption key, so consumers which filter on the cleartext
/// fields can do so without forcing the user to authenticate. See `list_candidates()`.
dictionary LoginCandidate {
// meta fields
string id;
i64 times_used;
i64 time_created;
i64 time_last_used;
i64 time_password_changed;
// breach fields
i64? time_last_breach_alert_dismissed;

// login fields
string origin;
string? http_realm;
string? form_action_origin;
string username_field;
string password_field;
};

/// Metrics tracking deletion of logins that cannot be decrypted, see `delete_undecryptable_records_for_remote_replacement`
/// for more details
dictionary LoginsDeletionMetrics {
Expand Down Expand Up @@ -292,6 +314,11 @@ interface LoginStore {
[Throws=LoginsApiError]
sequence<Login> list();

/// Like `list()`, but without the encrypted fields - and so without needing the encryption
/// key. Resolve the ids you're interested in with `get_many()`.
[Throws=LoginsApiError]
sequence<LoginCandidate> list_candidates();

[Throws=LoginsApiError]
sequence<Login> get_by_base_domain([ByRef] string base_domain);

Expand All @@ -304,6 +331,11 @@ interface LoginStore {
[Throws=LoginsApiError]
Login? get([ByRef] string id);

/// Get and decrypt the logins with the given ids. Ids which don't exist are skipped, as are
/// logins which fail to decrypt.
[Throws=LoginsApiError]
sequence<Login> get_many(sequence<string> ids);

/// Run maintenance on the DB
///
/// This is intended to be run during idle time and will take steps / to clean up / shrink the
Expand Down
Loading