web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
src/accounts.rs (21.8K)
1 //! Accounts from cce-secrets — the login suggestions the URL of a page earns.
2 //!
3 //! There is no cce-secrets *protocol*: that app fronts the freedesktop
4 //! **Secret Service** (gnome-keyring on this machine), and so does this. The
5 //! entry shape is the one cce-secrets writes and KeePassXC maps onto its own
6 //! fields: the item label is the title, and `UserName` / `URL` are ordinary
7 //! attributes beside it. Read the sibling crate's `CLAUDE.md` before changing
8 //! the attribute names here — both ends have to agree.
9 //!
10 //! Three rules shape everything below.
11 //!
12 //! **Secrets are fetched one at a time, at the moment of a pick.** Listing
13 //! reads labels, usernames and URLs only; no password is fetched to build a
14 //! menu, and none is held afterwards. [`Secret`] exists so that a password
15 //! cannot reach a log through a derived `Debug`.
16 //!
17 //! **Saving writes what cce-secrets writes.** A new login goes into the
18 //! default collection with the same shape cce-secrets' own "new entry" form
19 //! produces — the label as the title, `UserName` and `URL` attributes, a
20 //! `text/plain` secret — so it lists there, and cce-keyring-sync adopts it
21 //! like any keyring-born entry.
22 //!
23 //! **The keyring is never touched on the frame path.** A locked collection
24 //! prompts, and a prompt blocks for as long as the person takes to answer it,
25 //! so all of it runs on a worker thread that talks back through the app's
26 //! calloop channel. That is also why this uses the *blocking* Secret Service
27 //! API: on its own thread, blocking is the simple correct thing, and it keeps
28 //! an async runtime out of the browser.
29
30 use std::sync::mpsc;
31
32 use crate::Message;
33
34 /// Attribute names to read a username from, in order of preference. cce-secrets
35 /// writes `UserName`; entries born elsewhere in the keyring use lowercase.
36 const USER_KEYS: [&str; 3] = ["UserName", "username", "user"];
37 /// Same, for the entry's site.
38 const URL_KEYS: [&str; 3] = ["URL", "url", "uri"];
39
40 /// A password on its way from the keyring to one page field.
41 ///
42 /// The wrapper is the point: `Message` derives `Debug`, and a plain `String`
43 /// in it would put a live password into any log line that ever formats a
44 /// message. This one prints as `Secret(…)` and hands over its contents only
45 /// to a caller that asks for them by name.
46 #[derive(Clone, PartialEq)]
47 pub struct Secret(String);
48
49 impl Secret {
50 pub fn expose(&self) -> &str {
51 &self.0
52 }
53 }
54
55 impl From<String> for Secret {
56 fn from(s: String) -> Self {
57 Self(s)
58 }
59 }
60
61 impl std::fmt::Debug for Secret {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.write_str("Secret(…)")
64 }
65 }
66
67 /// One keyring entry as the chrome lists it — never its secret.
68 #[derive(Clone, Debug, PartialEq)]
69 pub struct Account {
70 /// Secret Service object path: the handle the secret is fetched by when
71 /// this account is picked.
72 pub path: String,
73 /// The entry's title.
74 pub label: String,
75 pub username: String,
76 /// The `URL` attribute as stored, empty when the entry has none.
77 pub url: String,
78 }
79
80 impl Account {
81 /// The host this entry claims, if any. Entries are written by people and
82 /// by importers, so the field holds anything from a full URL to a bare
83 /// domain; both have to work.
84 pub fn host(&self) -> Option<String> {
85 entry_host(&self.url)
86 }
87
88 /// Whether this entry is worth offering on `host`.
89 ///
90 /// Deliberately narrow. An exact host matches, and a *parent* domain
91 /// matches its subdomains — an entry for `example.com` is offered on
92 /// `login.example.com`, which is how sites actually split their login
93 /// pages. The reverse is not true: an entry for `login.example.com` is
94 /// not offered on `example.com`, and never on an unrelated host, because
95 /// a suggestion is a request to hand a password to whatever is on screen.
96 ///
97 /// An entry with no URL at all falls back to its title: a KeePass entry
98 /// called "GitHub" is offered on `github.com`. That one is a guess, so it
99 /// is only made when there is nothing better to go on.
100 pub fn matches(&self, host: &str) -> bool {
101 let page = normalize_host(host);
102 if page.is_empty() {
103 return false;
104 }
105 match self.host() {
106 Some(entry) => {
107 page == entry || (entry.contains('.') && page.ends_with(&format!(".{entry}")))
108 }
109 None => {
110 let title = self.label.trim().to_lowercase();
111 !title.is_empty()
112 && registrable_label(&page).is_some_and(|name| name == title)
113 }
114 }
115 }
116 }
117
118 /// Lowercase, and without the `www.` that no one means.
119 fn normalize_host(host: &str) -> String {
120 let h = host.trim().to_lowercase();
121 h.strip_prefix("www.").unwrap_or(&h).to_string()
122 }
123
124 /// The host inside a stored `URL` attribute: a real URL as written, a bare
125 /// host by guessing the scheme the same way the URL bar does.
126 pub fn entry_host(url: &str) -> Option<String> {
127 let s = url.trim();
128 if s.is_empty() {
129 return None;
130 }
131 let parsed = url::Url::parse(s)
132 .ok()
133 .or_else(|| url::Url::parse(&format!("https://{s}")).ok())?;
134 let host = parsed.host_str()?;
135 let host = normalize_host(host);
136 (!host.is_empty()).then_some(host)
137 }
138
139 /// The name a site goes by: `github` out of `github.com`, `bbc` out of
140 /// `bbc.co.uk`. Not a public-suffix list — it exists only for the
141 /// title-matching fallback, where being roughly right is the whole ambition.
142 fn registrable_label(host: &str) -> Option<String> {
143 let parts: Vec<&str> = host.split('.').filter(|p| !p.is_empty()).collect();
144 if parts.len() < 2 {
145 return None;
146 }
147 // Two-letter final labels are country codes, where the name sits one
148 // further left (co.uk, com.au) unless the domain is only two deep.
149 let idx = if parts.len() >= 3 && parts[parts.len() - 1].len() == 2 && parts[parts.len() - 2].len() <= 3
150 {
151 parts.len() - 3
152 } else {
153 parts.len() - 2
154 };
155 Some(parts[idx].to_string())
156 }
157
158 /// What the worker is asked to do.
159 enum Request {
160 /// Read every account the keyring holds (labels and attributes only).
161 Load,
162 /// Fetch one entry's password, by object path.
163 Fetch(String),
164 /// Write a new entry.
165 Save(NewLogin),
166 }
167
168 /// A login to write to the keyring, as the person agreed to save it.
169 #[derive(Clone, Debug)]
170 pub struct NewLogin {
171 /// The entry's title.
172 pub label: String,
173 pub username: String,
174 /// The origin the credential was typed into: what it will be offered on.
175 pub url: String,
176 pub password: Secret,
177 }
178
179 /// The account index, and the thread that reads it.
180 ///
181 /// Nothing here touches the keyring until something asks: the first login
182 /// field on the first page is what wakes it, so a browser that never sees a
183 /// login form never opens the store — and never triggers an unlock prompt at
184 /// launch, which is the behaviour that would have made this unwelcome.
185 pub struct Accounts {
186 tx: mpsc::Sender<Request>,
187 /// Every account the last load returned.
188 all: Vec<Account>,
189 /// A load has been asked for and not yet answered.
190 loading: bool,
191 /// Set once a load has come back, so an empty keyring is not retried on
192 /// every focus.
193 loaded: bool,
194 /// Why the last load failed, for the menu to say so instead of showing
195 /// an empty list that looks like "no accounts".
196 pub error: Option<String>,
197 }
198
199 impl Accounts {
200 /// Start the worker. It is idle until [`Accounts::ensure_loaded`].
201 pub fn spawn(sender: calloop::channel::Sender<Message>) -> Self {
202 let (tx, rx) = mpsc::channel();
203 std::thread::Builder::new()
204 .name("cce-accounts".to_string())
205 .spawn(move || worker(rx, sender))
206 .expect("spawn the accounts worker");
207 Self { tx, all: Vec::new(), loading: false, loaded: false, error: None }
208 }
209
210 /// Ask for the index if it is not already here or on its way.
211 pub fn ensure_loaded(&mut self) {
212 if self.loaded || self.loading {
213 return;
214 }
215 self.loading = true;
216 let _ = self.tx.send(Request::Load);
217 }
218
219 /// Take the worker's answer.
220 pub fn loaded(&mut self, result: Result<Vec<Account>, String>) {
221 self.loading = false;
222 self.loaded = true;
223 match result {
224 Ok(all) => {
225 self.all = all;
226 self.error = None;
227 }
228 Err(e) => {
229 self.all.clear();
230 self.error = Some(e);
231 }
232 }
233 }
234
235 /// Fetch one password. It comes back as [`Message::Credential`].
236 pub fn fetch(&self, path: &str) {
237 let _ = self.tx.send(Request::Fetch(path.to_string()));
238 }
239
240 /// Write a new entry. The outcome comes back as [`Message::Saved`], and
241 /// a successful save re-reads the index so the entry is offered at once.
242 pub fn save(&mut self, login: NewLogin) {
243 let _ = self.tx.send(Request::Save(login));
244 }
245
246 /// Forget the index, so the next [`Accounts::ensure_loaded`] reads it
247 /// again — after a save, which changed it.
248 pub fn invalidate(&mut self) {
249 self.loaded = false;
250 }
251
252 /// Whether the index has been read (successfully or not) and nothing is
253 /// in flight — when a question about what it holds can be answered.
254 pub fn is_ready(&self) -> bool {
255 self.loaded && !self.loading
256 }
257
258 /// Whether `username` already has an entry among `accounts` — what
259 /// decides that a sign-in is not new. Case-insensitive, since sites are,
260 /// and an entry with no username counts for an empty one.
261 pub fn knows(accounts: &[Account], username: &str) -> bool {
262 let wanted = username.trim().to_lowercase();
263 accounts.iter().any(|a| a.username.trim().to_lowercase() == wanted)
264 }
265
266 /// The accounts worth offering on `host`, best first: entries with a real
267 /// URL ahead of ones matched by their title alone, then by label.
268 pub fn matching(&self, host: &str) -> Vec<Account> {
269 let mut hits: Vec<Account> =
270 self.all.iter().filter(|a| a.matches(host)).cloned().collect();
271 hits.sort_by(|a, b| {
272 b.host()
273 .is_some()
274 .cmp(&a.host().is_some())
275 .then_with(|| a.label.to_lowercase().cmp(&b.label.to_lowercase()))
276 });
277 hits
278 }
279
280 pub fn is_loading(&self) -> bool {
281 self.loading
282 }
283 }
284
285 /// The worker thread: one Secret Service connection, held for the life of the
286 /// browser, serving requests in order.
287 fn worker(rx: mpsc::Receiver<Request>, tx: calloop::channel::Sender<Message>) {
288 use secret_service::blocking::SecretService;
289 use secret_service::EncryptionType;
290
291 let mut service: Option<SecretService> = None;
292 while let Ok(request) = rx.recv() {
293 // Connect on the first request, and again after a failure — the
294 // daemon can come and go.
295 if service.is_none() {
296 // Dh, not Plain: the secret then crosses the bus encrypted under a
297 // session key rather than in the clear.
298 match SecretService::connect(EncryptionType::Dh) {
299 Ok(s) => service = Some(s),
300 Err(e) => {
301 let _ = tx.send(Message::Accounts(Err(format!("no secret service: {e}"))));
302 continue;
303 }
304 }
305 }
306 let Some(ss) = service.as_ref() else { continue };
307 match request {
308 Request::Load => {
309 let _ = tx.send(Message::Accounts(load(ss)));
310 }
311 Request::Fetch(path) => {
312 if let Some(secret) = fetch(ss, &path) {
313 let _ = tx.send(Message::Credential(path, secret));
314 }
315 }
316 Request::Save(login) => {
317 let _ = tx.send(Message::Saved(save(ss, &login)));
318 }
319 }
320 }
321 }
322
323 fn load(ss: &secret_service::blocking::SecretService) -> Result<Vec<Account>, String> {
324 let collections = ss
325 .get_all_collections()
326 .map_err(|e| format!("listing collections failed: {e}"))?;
327 let mut accounts = Vec::new();
328 for collection in &collections {
329 // A locked collection is skipped rather than unlocked: the browser
330 // asking for the keyring password because a page happened to have a
331 // login field would be its own kind of phishing lesson. cce-secrets
332 // is the place to unlock.
333 if collection.is_locked().unwrap_or(true) {
334 continue;
335 }
336 let Ok(items) = collection.get_all_items() else { continue };
337 for item in items {
338 let Ok(attrs) = item.get_attributes() else { continue };
339 let pick = |keys: &[&str]| -> String {
340 keys.iter()
341 .find_map(|k| attrs.get(*k).filter(|v| !v.trim().is_empty()))
342 .cloned()
343 .unwrap_or_default()
344 };
345 let username = pick(&USER_KEYS);
346 let url = pick(&URL_KEYS);
347 // An entry with neither is not an account — a note, a key, a
348 // token — and has nothing to offer a login form.
349 if username.is_empty() && url.is_empty() {
350 continue;
351 }
352 accounts.push(Account {
353 path: item.item_path.to_string(),
354 label: item.get_label().unwrap_or_default(),
355 username,
356 url,
357 });
358 }
359 }
360 Ok(accounts)
361 }
362
363 /// Write one entry to the default collection — cce-secrets' own target, and
364 /// the one cce-keyring-sync syncs.
365 ///
366 /// A locked collection is refused rather than unlocked, for the same reason
367 /// listing skips one: the browser does not ask for the keyring password.
368 /// It is normally unlocked at login, so this is rare and says what to do.
369 /// `replace` is false: an existing entry is never overwritten from here.
370 fn save(ss: &secret_service::blocking::SecretService, login: &NewLogin) -> Result<String, String> {
371 let collection = ss
372 .get_default_collection()
373 .map_err(|e| format!("no default keyring collection: {e}"))?;
374 if collection.is_locked().unwrap_or(true) {
375 return Err("the keyring is locked — unlock it in cce-secrets, then sign in again".into());
376 }
377 let mut attrs = std::collections::HashMap::new();
378 if !login.username.is_empty() {
379 attrs.insert("UserName", login.username.as_str());
380 }
381 if !login.url.is_empty() {
382 attrs.insert("URL", login.url.as_str());
383 }
384 collection
385 .create_item(&login.label, attrs, login.password.expose().as_bytes(), false, "text/plain")
386 .map(|_| login.label.clone())
387 // The error text names the operation, not the secret.
388 .map_err(|e| format!("could not save to the keyring: {e}"))
389 }
390
391 /// The attribute the Raindrop.io token's keyring entry is found by.
392 pub const RAINDROP_TOKEN_ATTR: (&str, &str) = ("service", "raindrop.io");
393
394 /// The Raindrop.io test token, from the keyring.
395 ///
396 /// Stored as an entry with **no `UserName`**, found by `service=raindrop.io`:
397 /// cce-keyring-sync skips entries without a `UserName`, so the token stays on
398 /// this machine rather than travelling to 1Password, and the account index
399 /// above skips it too (no username, no URL), so it is never offered to a
400 /// login form. A locked keyring is an error, never an unlock prompt — the
401 /// same rule as everything else here.
402 pub fn raindrop_token() -> Result<Secret, String> {
403 use secret_service::blocking::SecretService;
404 use secret_service::EncryptionType;
405 let ss = SecretService::connect(EncryptionType::Dh)
406 .map_err(|e| format!("no secret service: {e}"))?;
407 let found = ss
408 .search_items(std::collections::HashMap::from([RAINDROP_TOKEN_ATTR]))
409 .map_err(|e| format!("searching the keyring failed: {e}"))?;
410 let Some(item) = found.unlocked.first() else {
411 return Err(if found.locked.is_empty() {
412 "no Raindrop token in the keyring — store one with: \
413 secret-tool store --label='Raindrop.io token' service raindrop.io"
414 .to_string()
415 } else {
416 "the keyring is locked — unlock it in cce-secrets".to_string()
417 });
418 };
419 let bytes = item.get_secret().map_err(|_| "could not read the Raindrop token".to_string())?;
420 let token = String::from_utf8_lossy(&bytes).trim().to_string();
421 if token.is_empty() {
422 return Err("the Raindrop token in the keyring is empty".to_string());
423 }
424 Ok(Secret(token))
425 }
426
427 /// Hosts the person has said never to offer saving on. One host per line in
428 /// `~/.local/state/cce/browser/never-save.txt`, beside history and bookmarks.
429 pub struct NeverSave {
430 path: std::path::PathBuf,
431 hosts: Vec<String>,
432 }
433
434 impl NeverSave {
435 pub fn load() -> Self {
436 Self::at(crate::pages::state_dir().join("never-save.txt"))
437 }
438
439 fn at(path: std::path::PathBuf) -> Self {
440 let hosts = std::fs::read_to_string(&path)
441 .map(|s| {
442 s.lines()
443 .map(|l| normalize_host(l))
444 .filter(|l| !l.is_empty())
445 .collect()
446 })
447 .unwrap_or_default();
448 Self { path, hosts }
449 }
450
451 pub fn contains(&self, host: &str) -> bool {
452 self.hosts.contains(&normalize_host(host))
453 }
454
455 pub fn add(&mut self, host: &str) {
456 let host = normalize_host(host);
457 if host.is_empty() || self.hosts.contains(&host) {
458 return;
459 }
460 self.hosts.push(host);
461 if let Some(dir) = self.path.parent() {
462 let _ = std::fs::create_dir_all(dir);
463 }
464 let mut body = self.hosts.join("\n");
465 body.push('\n');
466 if let Err(e) = std::fs::write(&self.path, body) {
467 log::warn!("could not write {}: {e}", self.path.display());
468 }
469 }
470 }
471
472 /// One entry's password. A failure is silent on purpose: the error text from
473 /// this call can carry the item's own label, and it has nowhere to go but a
474 /// log.
475 fn fetch(ss: &secret_service::blocking::SecretService, path: &str) -> Option<Secret> {
476 let path = zbus::zvariant::OwnedObjectPath::try_from(path).ok()?;
477 let item = ss.get_item_by_path(path).ok()?;
478 let bytes = item.get_secret().ok()?;
479 Some(Secret(String::from_utf8_lossy(&bytes).into_owned()))
480 }
481
482 #[cfg(test)]
483 mod tests {
484 use super::*;
485
486 fn account(label: &str, url: &str) -> Account {
487 Account {
488 path: "/org/freedesktop/secrets/item/1".to_string(),
489 label: label.to_string(),
490 username: "me".to_string(),
491 url: url.to_string(),
492 }
493 }
494
495 #[test]
496 fn a_stored_url_matches_its_own_host_and_its_subdomains() {
497 let a = account("Example", "https://example.com/login?next=/");
498 assert!(a.matches("example.com"));
499 assert!(a.matches("www.example.com"), "www is not a different site");
500 assert!(a.matches("login.example.com"), "a parent domain covers its subdomains");
501 assert!(!a.matches("example.com.evil.test"), "suffix games are not matches");
502 assert!(!a.matches("notexample.com"));
503 assert!(!a.matches("example.org"));
504 }
505
506 #[test]
507 fn a_subdomain_entry_does_not_leak_upward() {
508 let a = account("Mail", "https://mail.example.com/");
509 assert!(a.matches("mail.example.com"));
510 assert!(!a.matches("example.com"), "the parent is a different site");
511 assert!(!a.matches("chat.example.com"), "so is a sibling");
512 }
513
514 #[test]
515 fn a_bare_host_is_a_url_too() {
516 assert_eq!(entry_host("example.com"), Some("example.com".to_string()));
517 assert_eq!(entry_host("https://WWW.Example.COM/x"), Some("example.com".to_string()));
518 assert_eq!(entry_host(" "), None);
519 assert_eq!(entry_host("not a url at all"), None);
520 }
521
522 #[test]
523 fn an_entry_without_a_url_falls_back_to_its_title() {
524 let a = account("GitHub", "");
525 assert!(a.matches("github.com"));
526 assert!(a.matches("gist.github.com"), "the site name is the same one");
527 assert!(!a.matches("github.evil.test"));
528 assert!(!a.matches("gitlab.com"));
529
530 // The fallback is only for entries with nothing else to go on.
531 let titled = account("GitHub", "https://example.com/");
532 assert!(!titled.matches("github.com"), "a stored URL wins over the title");
533 }
534
535 #[test]
536 fn country_code_domains_still_find_their_name() {
537 assert_eq!(registrable_label("bbc.co.uk").as_deref(), Some("bbc"));
538 assert_eq!(registrable_label("www.example.com").as_deref(), Some("example"));
539 assert_eq!(registrable_label("localhost"), None);
540 }
541
542 #[test]
543 fn a_known_username_is_not_new() {
544 let mut a = account("Example", "https://example.com/");
545 a.username = "Me@Example.com".into();
546 assert!(Accounts::knows(&[a.clone()], " me@example.com"));
547 assert!(!Accounts::knows(&[a], "someone@example.com"));
548 assert!(!Accounts::knows(&[], "me"));
549 }
550
551 #[test]
552 fn never_save_persists_hosts() {
553 let dir = std::env::temp_dir().join(format!("cce-never-save-{}", std::process::id()));
554 let path = dir.join("never-save.txt");
555 let mut n = NeverSave::at(path.clone());
556 assert!(!n.contains("example.com"));
557 n.add("WWW.Example.com");
558 n.add("example.com");
559 assert!(n.contains("example.com"));
560 let again = NeverSave::at(path.clone());
561 assert!(again.contains("www.example.com"), "www is not a different site");
562 assert_eq!(std::fs::read_to_string(&path).unwrap(), "example.com\n");
563 let _ = std::fs::remove_dir_all(dir);
564 }
565
566 #[test]
567 fn a_password_never_prints_itself() {
568 let s = Secret("hunter2".to_string());
569 assert_eq!(format!("{s:?}"), "Secret(…)");
570 assert_eq!(format!("{:?}", Message::Credential("/p".into(), s.clone())),
571 "Credential(\"/p\", Secret(…))");
572 assert_eq!(s.expose(), "hunter2");
573 }
574 }