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 }