git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

src/accounts.rs (14.5K)

  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 //! Two 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 //! **The keyring is never touched on the frame path.** A locked collection
 18 //! prompts, and a prompt blocks for as long as the person takes to answer it,
 19 //! so all of it runs on a worker thread that talks back through the app's
 20 //! calloop channel. That is also why this uses the *blocking* Secret Service
 21 //! API: on its own thread, blocking is the simple correct thing, and it keeps
 22 //! an async runtime out of the browser.
 23 
 24 use std::sync::mpsc;
 25 
 26 use crate::Message;
 27 
 28 /// Attribute names to read a username from, in order of preference. cce-secrets
 29 /// writes `UserName`; entries born elsewhere in the keyring use lowercase.
 30 const USER_KEYS: [&str; 3] = ["UserName", "username", "user"];
 31 /// Same, for the entry's site.
 32 const URL_KEYS: [&str; 3] = ["URL", "url", "uri"];
 33 
 34 /// A password on its way from the keyring to one page field.
 35 ///
 36 /// The wrapper is the point: `Message` derives `Debug`, and a plain `String`
 37 /// in it would put a live password into any log line that ever formats a
 38 /// message. This one prints as `Secret(…)` and hands over its contents only
 39 /// to a caller that asks for them by name.
 40 #[derive(Clone, PartialEq)]
 41 pub struct Secret(String);
 42 
 43 impl Secret {
 44     pub fn expose(&self) -> &str {
 45         &self.0
 46     }
 47 }
 48 
 49 impl std::fmt::Debug for Secret {
 50     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 51         f.write_str("Secret(…)")
 52     }
 53 }
 54 
 55 /// One keyring entry as the chrome lists it — never its secret.
 56 #[derive(Clone, Debug, PartialEq)]
 57 pub struct Account {
 58     /// Secret Service object path: the handle the secret is fetched by when
 59     /// this account is picked.
 60     pub path: String,
 61     /// The entry's title.
 62     pub label: String,
 63     pub username: String,
 64     /// The `URL` attribute as stored, empty when the entry has none.
 65     pub url: String,
 66 }
 67 
 68 impl Account {
 69     /// The host this entry claims, if any. Entries are written by people and
 70     /// by importers, so the field holds anything from a full URL to a bare
 71     /// domain; both have to work.
 72     pub fn host(&self) -> Option<String> {
 73         entry_host(&self.url)
 74     }
 75 
 76     /// Whether this entry is worth offering on `host`.
 77     ///
 78     /// Deliberately narrow. An exact host matches, and a *parent* domain
 79     /// matches its subdomains — an entry for `example.com` is offered on
 80     /// `login.example.com`, which is how sites actually split their login
 81     /// pages. The reverse is not true: an entry for `login.example.com` is
 82     /// not offered on `example.com`, and never on an unrelated host, because
 83     /// a suggestion is a request to hand a password to whatever is on screen.
 84     ///
 85     /// An entry with no URL at all falls back to its title: a KeePass entry
 86     /// called "GitHub" is offered on `github.com`. That one is a guess, so it
 87     /// is only made when there is nothing better to go on.
 88     pub fn matches(&self, host: &str) -> bool {
 89         let page = normalize_host(host);
 90         if page.is_empty() {
 91             return false;
 92         }
 93         match self.host() {
 94             Some(entry) => {
 95                 page == entry || (entry.contains('.') && page.ends_with(&format!(".{entry}")))
 96             }
 97             None => {
 98                 let title = self.label.trim().to_lowercase();
 99                 !title.is_empty()
100                     && registrable_label(&page).is_some_and(|name| name == title)
101             }
102         }
103     }
104 }
105 
106 /// Lowercase, and without the `www.` that no one means.
107 fn normalize_host(host: &str) -> String {
108     let h = host.trim().to_lowercase();
109     h.strip_prefix("www.").unwrap_or(&h).to_string()
110 }
111 
112 /// The host inside a stored `URL` attribute: a real URL as written, a bare
113 /// host by guessing the scheme the same way the URL bar does.
114 pub fn entry_host(url: &str) -> Option<String> {
115     let s = url.trim();
116     if s.is_empty() {
117         return None;
118     }
119     let parsed = url::Url::parse(s)
120         .ok()
121         .or_else(|| url::Url::parse(&format!("https://{s}")).ok())?;
122     let host = parsed.host_str()?;
123     let host = normalize_host(host);
124     (!host.is_empty()).then_some(host)
125 }
126 
127 /// The name a site goes by: `github` out of `github.com`, `bbc` out of
128 /// `bbc.co.uk`. Not a public-suffix list — it exists only for the
129 /// title-matching fallback, where being roughly right is the whole ambition.
130 fn registrable_label(host: &str) -> Option<String> {
131     let parts: Vec<&str> = host.split('.').filter(|p| !p.is_empty()).collect();
132     if parts.len() < 2 {
133         return None;
134     }
135     // Two-letter final labels are country codes, where the name sits one
136     // further left (co.uk, com.au) unless the domain is only two deep.
137     let idx = if parts.len() >= 3 && parts[parts.len() - 1].len() == 2 && parts[parts.len() - 2].len() <= 3
138     {
139         parts.len() - 3
140     } else {
141         parts.len() - 2
142     };
143     Some(parts[idx].to_string())
144 }
145 
146 /// What the worker is asked to do.
147 enum Request {
148     /// Read every account the keyring holds (labels and attributes only).
149     Load,
150     /// Fetch one entry's password, by object path.
151     Fetch(String),
152 }
153 
154 /// The account index, and the thread that reads it.
155 ///
156 /// Nothing here touches the keyring until something asks: the first login
157 /// field on the first page is what wakes it, so a browser that never sees a
158 /// login form never opens the store — and never triggers an unlock prompt at
159 /// launch, which is the behaviour that would have made this unwelcome.
160 pub struct Accounts {
161     tx: mpsc::Sender<Request>,
162     /// Every account the last load returned.
163     all: Vec<Account>,
164     /// A load has been asked for and not yet answered.
165     loading: bool,
166     /// Set once a load has come back, so an empty keyring is not retried on
167     /// every focus.
168     loaded: bool,
169     /// Why the last load failed, for the menu to say so instead of showing
170     /// an empty list that looks like "no accounts".
171     pub error: Option<String>,
172 }
173 
174 impl Accounts {
175     /// Start the worker. It is idle until [`Accounts::ensure_loaded`].
176     pub fn spawn(sender: calloop::channel::Sender<Message>) -> Self {
177         let (tx, rx) = mpsc::channel();
178         std::thread::Builder::new()
179             .name("cce-accounts".to_string())
180             .spawn(move || worker(rx, sender))
181             .expect("spawn the accounts worker");
182         Self { tx, all: Vec::new(), loading: false, loaded: false, error: None }
183     }
184 
185     /// Ask for the index if it is not already here or on its way.
186     pub fn ensure_loaded(&mut self) {
187         if self.loaded || self.loading {
188             return;
189         }
190         self.loading = true;
191         let _ = self.tx.send(Request::Load);
192     }
193 
194     /// Take the worker's answer.
195     pub fn loaded(&mut self, result: Result<Vec<Account>, String>) {
196         self.loading = false;
197         self.loaded = true;
198         match result {
199             Ok(all) => {
200                 self.all = all;
201                 self.error = None;
202             }
203             Err(e) => {
204                 self.all.clear();
205                 self.error = Some(e);
206             }
207         }
208     }
209 
210     /// Fetch one password. It comes back as [`Message::Credential`].
211     pub fn fetch(&self, path: &str) {
212         let _ = self.tx.send(Request::Fetch(path.to_string()));
213     }
214 
215     /// The accounts worth offering on `host`, best first: entries with a real
216     /// URL ahead of ones matched by their title alone, then by label.
217     pub fn matching(&self, host: &str) -> Vec<Account> {
218         let mut hits: Vec<Account> =
219             self.all.iter().filter(|a| a.matches(host)).cloned().collect();
220         hits.sort_by(|a, b| {
221             b.host()
222                 .is_some()
223                 .cmp(&a.host().is_some())
224                 .then_with(|| a.label.to_lowercase().cmp(&b.label.to_lowercase()))
225         });
226         hits
227     }
228 
229     pub fn is_loading(&self) -> bool {
230         self.loading
231     }
232 }
233 
234 /// The worker thread: one Secret Service connection, held for the life of the
235 /// browser, serving requests in order.
236 fn worker(rx: mpsc::Receiver<Request>, tx: calloop::channel::Sender<Message>) {
237     use secret_service::blocking::SecretService;
238     use secret_service::EncryptionType;
239 
240     let mut service: Option<SecretService> = None;
241     while let Ok(request) = rx.recv() {
242         // Connect on the first request, and again after a failure — the
243         // daemon can come and go.
244         if service.is_none() {
245             // Dh, not Plain: the secret then crosses the bus encrypted under a
246             // session key rather than in the clear.
247             match SecretService::connect(EncryptionType::Dh) {
248                 Ok(s) => service = Some(s),
249                 Err(e) => {
250                     let _ = tx.send(Message::Accounts(Err(format!("no secret service: {e}"))));
251                     continue;
252                 }
253             }
254         }
255         let Some(ss) = service.as_ref() else { continue };
256         match request {
257             Request::Load => {
258                 let _ = tx.send(Message::Accounts(load(ss)));
259             }
260             Request::Fetch(path) => {
261                 if let Some(secret) = fetch(ss, &path) {
262                     let _ = tx.send(Message::Credential(path, secret));
263                 }
264             }
265         }
266     }
267 }
268 
269 fn load(ss: &secret_service::blocking::SecretService) -> Result<Vec<Account>, String> {
270     let collections = ss
271         .get_all_collections()
272         .map_err(|e| format!("listing collections failed: {e}"))?;
273     let mut accounts = Vec::new();
274     for collection in &collections {
275         // A locked collection is skipped rather than unlocked: the browser
276         // asking for the keyring password because a page happened to have a
277         // login field would be its own kind of phishing lesson. cce-secrets
278         // is the place to unlock.
279         if collection.is_locked().unwrap_or(true) {
280             continue;
281         }
282         let Ok(items) = collection.get_all_items() else { continue };
283         for item in items {
284             let Ok(attrs) = item.get_attributes() else { continue };
285             let pick = |keys: &[&str]| -> String {
286                 keys.iter()
287                     .find_map(|k| attrs.get(*k).filter(|v| !v.trim().is_empty()))
288                     .cloned()
289                     .unwrap_or_default()
290             };
291             let username = pick(&USER_KEYS);
292             let url = pick(&URL_KEYS);
293             // An entry with neither is not an account — a note, a key, a
294             // token — and has nothing to offer a login form.
295             if username.is_empty() && url.is_empty() {
296                 continue;
297             }
298             accounts.push(Account {
299                 path: item.item_path.to_string(),
300                 label: item.get_label().unwrap_or_default(),
301                 username,
302                 url,
303             });
304         }
305     }
306     Ok(accounts)
307 }
308 
309 /// One entry's password. A failure is silent on purpose: the error text from
310 /// this call can carry the item's own label, and it has nowhere to go but a
311 /// log.
312 fn fetch(ss: &secret_service::blocking::SecretService, path: &str) -> Option<Secret> {
313     let path = zbus::zvariant::OwnedObjectPath::try_from(path).ok()?;
314     let item = ss.get_item_by_path(path).ok()?;
315     let bytes = item.get_secret().ok()?;
316     Some(Secret(String::from_utf8_lossy(&bytes).into_owned()))
317 }
318 
319 #[cfg(test)]
320 mod tests {
321     use super::*;
322 
323     fn account(label: &str, url: &str) -> Account {
324         Account {
325             path: "/org/freedesktop/secrets/item/1".to_string(),
326             label: label.to_string(),
327             username: "me".to_string(),
328             url: url.to_string(),
329         }
330     }
331 
332     #[test]
333     fn a_stored_url_matches_its_own_host_and_its_subdomains() {
334         let a = account("Example", "https://example.com/login?next=/");
335         assert!(a.matches("example.com"));
336         assert!(a.matches("www.example.com"), "www is not a different site");
337         assert!(a.matches("login.example.com"), "a parent domain covers its subdomains");
338         assert!(!a.matches("example.com.evil.test"), "suffix games are not matches");
339         assert!(!a.matches("notexample.com"));
340         assert!(!a.matches("example.org"));
341     }
342 
343     #[test]
344     fn a_subdomain_entry_does_not_leak_upward() {
345         let a = account("Mail", "https://mail.example.com/");
346         assert!(a.matches("mail.example.com"));
347         assert!(!a.matches("example.com"), "the parent is a different site");
348         assert!(!a.matches("chat.example.com"), "so is a sibling");
349     }
350 
351     #[test]
352     fn a_bare_host_is_a_url_too() {
353         assert_eq!(entry_host("example.com"), Some("example.com".to_string()));
354         assert_eq!(entry_host("https://WWW.Example.COM/x"), Some("example.com".to_string()));
355         assert_eq!(entry_host("  "), None);
356         assert_eq!(entry_host("not a url at all"), None);
357     }
358 
359     #[test]
360     fn an_entry_without_a_url_falls_back_to_its_title() {
361         let a = account("GitHub", "");
362         assert!(a.matches("github.com"));
363         assert!(a.matches("gist.github.com"), "the site name is the same one");
364         assert!(!a.matches("github.evil.test"));
365         assert!(!a.matches("gitlab.com"));
366 
367         // The fallback is only for entries with nothing else to go on.
368         let titled = account("GitHub", "https://example.com/");
369         assert!(!titled.matches("github.com"), "a stored URL wins over the title");
370     }
371 
372     #[test]
373     fn country_code_domains_still_find_their_name() {
374         assert_eq!(registrable_label("bbc.co.uk").as_deref(), Some("bbc"));
375         assert_eq!(registrable_label("www.example.com").as_deref(), Some("example"));
376         assert_eq!(registrable_label("localhost"), None);
377     }
378 
379     #[test]
380     fn a_password_never_prints_itself() {
381         let s = Secret("hunter2".to_string());
382         assert_eq!(format!("{s:?}"), "Secret(…)");
383         assert_eq!(format!("{:?}", Message::Credential("/p".into(), s.clone())),
384                    "Credential(\"/p\", Secret(…))");
385         assert_eq!(s.expose(), "hunter2");
386     }
387 }