git.lucas.co / cce-browser
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 }