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

commita981c586e679192dd9d92812ad446cc2e1fc2813
parent7874cdcdf7
authorLucas Galante <[email protected]>
date2026-09-09 15:57
feat: account autocomplete from cce-secrets on login forms

A login field now gets a list of the accounts the keyring holds for that
site; picking one fills the username and password. Same store cce-secrets
fronts — the freedesktop Secret Service — read the same way: item label as
title, UserName and URL as attributes. Typing filters, arrows and Enter pick,
Escape dismisses, and the list follows its field as the page scrolls.
`browser.accounts` (default true) is the single switch: off, nothing is
injected and the keyring is never opened.

The security shape is the design. Both halves run in a private script world,
so the page can neither replace the watcher's helpers nor post on the
chrome's channel; injection is top-frame only and every event's origin is
checked against the tab's own host. Matching is exact-host or parent-domain,
never upward or sideways, with a title fallback only for entries that have no
URL at all. No password is fetched to build a list — the pick fetches one
secret by object path — and accounts::Secret prints as Secret(…) so Message's
derived Debug cannot spill it. The fill re-checks on arrival that the list is
still open, still holds that account, and the tab is still on the same host,
and drops the credential otherwise. Nothing fills without a pick, nothing
submits, and a locked collection is skipped rather than unlocked. The list
says so on a non-https, non-loopback page.

Three findings worth the comments they carry: the engine's dirty flag is not
a navigation (clearing on it closed the list in the pump that opened it, so it
keys on the URL changing and events are drained after); a field focused before
the index loads needs the watcher nudged to re-report, or the first login form
of a session gets nothing; and the fill's own input/change events came back as
typing until the watcher learned to suppress them.

Verified against an isolated dbus + gnome-keyring fixture, never the real
store: list at the field, flip above when there is no room below, filter,
arrows, Enter, click, Escape, password-field focus, the fill reaching both
fields through the native setter, and the off-switch injecting nothing. 29
tests pass (9 new, covering host matching, credential escaping, event parsing
and the insecure-origin rule).

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md            |  84 +++++++-
 Cargo.toml           |  12 ++
 build.rs             |   7 +
 src/accounts.rs      | 387 +++++++++++++++++++++++++++++++++++++
 src/main.rs          | 531 ++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/settings.rs      |   6 +
 src/wpe/formwatch.rs | 282 +++++++++++++++++++++++++++
 src/wpe/host.rs      | 163 ++++++++++++++++
 src/wpe/mod.rs       |   4 +
 9 files changed, 1472 insertions(+), 4 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index b6eb8c5..08aae92 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -25,6 +25,8 @@ Eight files, ~3k lines:
 | `src/downloads.rs` | the chrome-side download pipeline (Servo has none) |
 | `src/session.rs` | open-tab persistence: the tab set survives a restart |
 | `src/settings.rs` | the per-app KDL config |
+| `src/accounts.rs` | accounts from cce-secrets: the Secret Service worker, and which entries a host earns |
+| `src/wpe/formwatch.rs` | the page half of account autocomplete: the watcher script, the fill script, and the events between them |
 
 ## Build
 
@@ -294,6 +296,80 @@ Wheel events pass **winit-signed deltas** (positive = up) with no separate scrol
 event: Servo hit-tests the wheel, gives the page its `preventDefault` chance, and
 applies the inverted delta itself.
 
+## Account autocomplete (cce-secrets)
+
+A login field on a page gets a list of the accounts the keyring holds for that
+site; picking one fills the username and password. There is no cce-secrets
+*protocol* — that app fronts the freedesktop **Secret Service** (gnome-keyring
+here) and so does this, reading the same entries: item label as the title,
+`UserName` and `URL` as attributes. `browser.accounts` (default true) is the
+one switch; with it off nothing is injected and the keyring is never opened.
+
+Three files meet: `accounts.rs` (which entries a host earns, and the worker
+that reads them), `wpe/formwatch.rs` (the page half), and `AcMenu` in
+`main.rs` (the list itself, drawn at the field like every other menu here).
+WPE only — the retired Servo backend has no user-script hooks — so the chrome
+side is `#[cfg(feature = "wpe")]`, while `accounts.rs` is not.
+
+The security shape is the design, not decoration:
+
+- **Everything runs in a private script world** (`formwatch::WORLD`). The page
+  cannot see or replace the watcher's helpers, so it cannot hook the moment a
+  credential is filled, and it cannot post on the chrome's message channel to
+  fake a focused field.
+- **Top frame only.** A password field in a cross-origin iframe gets no
+  suggestions: such a frame cannot report a position in the top document's
+  coordinates anyway, and an embedded frame asking for the embedder's
+  credentials is the attack this must not enable. The reported `location.origin`
+  is checked against the tab's own host on every event, on top of that.
+- **Matching is narrow** (`Account::matches`): exact host, or a *parent* domain
+  covering its subdomains — never upward, never sideways. An entry with no URL
+  falls back to its title against the site name (`GitHub` → `github.com`), the
+  one guess in here, made only when there is nothing better.
+- **No password is fetched to build a list.** Listing reads labels, usernames
+  and URLs; the pick is what asks the keyring for one secret, by object path.
+  `accounts::Secret` prints as `Secret(…)` so a derived `Debug` on `Message`
+  cannot spill it into a log.
+- **The fill is re-checked when it lands.** An unlock prompt can put seconds
+  between the pick and the answer, so `fill_account` drops the credential
+  unless the list is still open, still holds that account, and the tab is
+  still on the host it was opened for.
+- **Never automatic.** Nothing fills without a pick, nothing submits the form,
+  and a locked collection is skipped rather than unlocked — the browser asking
+  for the keyring password because a page happened to show a login field would
+  be its own phishing lesson. cce-secrets is where unlocking belongs.
+- The list says so when the page is not https and not loopback
+  (`insecure_origin`): the password would cross the network in the clear, and
+  only the person can decide that is fine.
+
+Things that were learned the hard way and are easy to undo:
+
+- **The keyring is read on the first login field, never at launch.** A browser
+  that never sees one never opens the store, which is what keeps this from
+  costing an unlock prompt at login.
+- **A field can be focused before the index has finished loading** — it always
+  is, on a page that autofocuses. The chrome answers the load by asking the
+  watcher to re-report (`request_form_state` → `RESCAN_JS`); without that
+  nudge the first login form of a session silently gets nothing.
+- **The engine's dirty flag is not a navigation.** Clearing the list on
+  `dirty` closed it in the same pump that opened it (title and loading
+  transitions set it too). It is keyed on the tab's URL actually changing
+  (`nav_url`), and form events are drained *after* that check so an event
+  arriving with the load survives it.
+- **A fill must not report itself.** The `input` and `change` events the fill
+  dispatches — which are the point, since frameworks ignore a plain assignment
+  — came back as "the user typed" and re-opened the list, filtered by the name
+  just filled in. The watcher holds a `filling` flag across the fill.
+- **CSS pixels are the chrome's logical pixels.** `resize` hands WPE the
+  *logical* size and sets the scale separately, so a viewport rect from the
+  page needs no conversion at any output scale (verified at scale 2).
+
+Testing it needs an isolated keyring, never the real one: `dbus-run-session`
+plus `gnome-keyring-daemon --unlock --components=secrets`, seeded with
+`secret-tool`, and the browser launched into that bus with
+`DBUS_SESSION_BUS_ADDRESS`. A `file:` page will not do — its origin is `null`,
+so serve the fixture over http on localhost.
+
 ## `cce://` pages
 
 `CceProtocol` registers the `cce` scheme with Servo's `ProtocolRegistry`, so
@@ -391,9 +467,11 @@ clipboard path as the rest of the DE.
 
 ## Not implemented yet
 
-Worth knowing before assuming a bug: no find-in-page, no zoom, no context menu, no
-favicons, no history/URL autocomplete, and no delegate hooks for JS dialogs
-(`alert`/`confirm`), permission prompts, or HTTP auth. Ctrl+Shift+O ("hand this page to
+Worth knowing before assuming a bug: no find-in-page, no zoom, no favicons, and no
+history/URL autocomplete. (The context menu, JS dialogs and HTTP auth landed with the
+WPE backend and are Servo-only gaps now.) Account autocomplete does not *save* a new
+login — cce-secrets is where entries are written — and it does not fill inside
+cross-origin iframes. Ctrl+Shift+O ("hand this page to
 another browser") is the deliberate escape hatch for pages Servo cannot follow, such as
 a Cloudflare challenge that never completes.
 
diff --git a/Cargo.toml b/Cargo.toml
index 060e5dd..1a66601 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -31,6 +31,18 @@ euclid = { version = "0.22", optional = true }
 rustls = { version = "0.23", features = ["aws-lc-rs"], optional = true }
 log = "0.4"
 env_logger = "0.11"
+# Accounts from cce-secrets: the same Secret Service store cce-secrets
+# fronts (gnome-keyring here), read over D-Bus. The *blocking* API on a
+# worker thread, deliberately — an unlock prompt can block for as long as it
+# takes and must never be on the frame path — so no async runtime is pulled
+# into the browser: async-io, not tokio.
+secret-service = { version = "5", features = ["rt-async-io-crypto-rust"] }
+# The account watcher's page messages are JSON, and cce-ui parses config
+# through serde_json already — this only names the parser directly.
+serde_json = "1"
+# Naming a Secret Service object path (the handle a picked account's secret is
+# fetched by). Already in the tree under secret-service; this only names it.
+zbus = "5"
 # Only used by the `wpe` backend, to hold GLib's changing pollfd set in one
 # epoll fd that calloop can watch. Optional so a default build skips it.
 rustix = { version = "0.38", features = ["event"], optional = true }
diff --git a/build.rs b/build.rs
index 7ffab51..e70e069 100644
--- a/build.rs
+++ b/build.rs
@@ -29,7 +29,14 @@ fn main() {
         // The engine, the embedding layer, and just enough GObject to
         // register subclasses and turn a main loop.
         .allowlist_item("(wpe|WPE|webkit|WebKit)_?.*")
+        // JavaScriptCore: a script message from the page arrives as a
+        // JSCValue, so reading one needs `jsc_value_*`. webkit.h already
+        // pulls in jsc.h; only the allowlist kept these out.
+        .allowlist_item("(jsc|JSC)_?.*")
         .allowlist_item("g_(object|type|signal|bytes|timeout|free|error)_.*")
+        // `g_free` itself, with no trailing word, misses the pattern above —
+        // and a JSC string comes back owned, so it is needed to hand it back.
+        .allowlist_function("g_free")
         // The main loop AND the context: `pump` drains the context directly.
         .allowlist_item("g_main_(loop|context)_.*")
         .allowlist_item("G(Object|Type|Value|Bytes|Error|MainLoop|ParamSpec|Closure).*")
diff --git a/src/accounts.rs b/src/accounts.rs
new file mode 100644
index 0000000..223e93e
--- /dev/null
+++ b/src/accounts.rs
@@ -0,0 +1,387 @@
+//! Accounts from cce-secrets — the login suggestions the URL of a page earns.
+//!
+//! There is no cce-secrets *protocol*: that app fronts the freedesktop
+//! **Secret Service** (gnome-keyring on this machine), and so does this. The
+//! entry shape is the one cce-secrets writes and KeePassXC maps onto its own
+//! fields: the item label is the title, and `UserName` / `URL` are ordinary
+//! attributes beside it. Read the sibling crate's `CLAUDE.md` before changing
+//! the attribute names here — both ends have to agree.
+//!
+//! Two rules shape everything below.
+//!
+//! **Secrets are fetched one at a time, at the moment of a pick.** Listing
+//! reads labels, usernames and URLs only; no password is fetched to build a
+//! menu, and none is held afterwards. [`Secret`] exists so that a password
+//! cannot reach a log through a derived `Debug`.
+//!
+//! **The keyring is never touched on the frame path.** A locked collection
+//! prompts, and a prompt blocks for as long as the person takes to answer it,
+//! so all of it runs on a worker thread that talks back through the app's
+//! calloop channel. That is also why this uses the *blocking* Secret Service
+//! API: on its own thread, blocking is the simple correct thing, and it keeps
+//! an async runtime out of the browser.
+
+use std::sync::mpsc;
+
+use crate::Message;
+
+/// Attribute names to read a username from, in order of preference. cce-secrets
+/// writes `UserName`; entries born elsewhere in the keyring use lowercase.
+const USER_KEYS: [&str; 3] = ["UserName", "username", "user"];
+/// Same, for the entry's site.
+const URL_KEYS: [&str; 3] = ["URL", "url", "uri"];
+
+/// A password on its way from the keyring to one page field.
+///
+/// The wrapper is the point: `Message` derives `Debug`, and a plain `String`
+/// in it would put a live password into any log line that ever formats a
+/// message. This one prints as `Secret(…)` and hands over its contents only
+/// to a caller that asks for them by name.
+#[derive(Clone, PartialEq)]
+pub struct Secret(String);
+
+impl Secret {
+    pub fn expose(&self) -> &str {
+        &self.0
+    }
+}
+
+impl std::fmt::Debug for Secret {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str("Secret(…)")
+    }
+}
+
+/// One keyring entry as the chrome lists it — never its secret.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Account {
+    /// Secret Service object path: the handle the secret is fetched by when
+    /// this account is picked.
+    pub path: String,
+    /// The entry's title.
+    pub label: String,
+    pub username: String,
+    /// The `URL` attribute as stored, empty when the entry has none.
+    pub url: String,
+}
+
+impl Account {
+    /// The host this entry claims, if any. Entries are written by people and
+    /// by importers, so the field holds anything from a full URL to a bare
+    /// domain; both have to work.
+    pub fn host(&self) -> Option<String> {
+        entry_host(&self.url)
+    }
+
+    /// Whether this entry is worth offering on `host`.
+    ///
+    /// Deliberately narrow. An exact host matches, and a *parent* domain
+    /// matches its subdomains — an entry for `example.com` is offered on
+    /// `login.example.com`, which is how sites actually split their login
+    /// pages. The reverse is not true: an entry for `login.example.com` is
+    /// not offered on `example.com`, and never on an unrelated host, because
+    /// a suggestion is a request to hand a password to whatever is on screen.
+    ///
+    /// An entry with no URL at all falls back to its title: a KeePass entry
+    /// called "GitHub" is offered on `github.com`. That one is a guess, so it
+    /// is only made when there is nothing better to go on.
+    pub fn matches(&self, host: &str) -> bool {
+        let page = normalize_host(host);
+        if page.is_empty() {
+            return false;
+        }
+        match self.host() {
+            Some(entry) => {
+                page == entry || (entry.contains('.') && page.ends_with(&format!(".{entry}")))
+            }
+            None => {
+                let title = self.label.trim().to_lowercase();
+                !title.is_empty()
+                    && registrable_label(&page).is_some_and(|name| name == title)
+            }
+        }
+    }
+}
+
+/// Lowercase, and without the `www.` that no one means.
+fn normalize_host(host: &str) -> String {
+    let h = host.trim().to_lowercase();
+    h.strip_prefix("www.").unwrap_or(&h).to_string()
+}
+
+/// The host inside a stored `URL` attribute: a real URL as written, a bare
+/// host by guessing the scheme the same way the URL bar does.
+pub fn entry_host(url: &str) -> Option<String> {
+    let s = url.trim();
+    if s.is_empty() {
+        return None;
+    }
+    let parsed = url::Url::parse(s)
+        .ok()
+        .or_else(|| url::Url::parse(&format!("https://{s}")).ok())?;
+    let host = parsed.host_str()?;
+    let host = normalize_host(host);
+    (!host.is_empty()).then_some(host)
+}
+
+/// The name a site goes by: `github` out of `github.com`, `bbc` out of
+/// `bbc.co.uk`. Not a public-suffix list — it exists only for the
+/// title-matching fallback, where being roughly right is the whole ambition.
+fn registrable_label(host: &str) -> Option<String> {
+    let parts: Vec<&str> = host.split('.').filter(|p| !p.is_empty()).collect();
+    if parts.len() < 2 {
+        return None;
+    }
+    // Two-letter final labels are country codes, where the name sits one
+    // further left (co.uk, com.au) unless the domain is only two deep.
+    let idx = if parts.len() >= 3 && parts[parts.len() - 1].len() == 2 && parts[parts.len() - 2].len() <= 3
+    {
+        parts.len() - 3
+    } else {
+        parts.len() - 2
+    };
+    Some(parts[idx].to_string())
+}
+
+/// What the worker is asked to do.
+enum Request {
+    /// Read every account the keyring holds (labels and attributes only).
+    Load,
+    /// Fetch one entry's password, by object path.
+    Fetch(String),
+}
+
+/// The account index, and the thread that reads it.
+///
+/// Nothing here touches the keyring until something asks: the first login
+/// field on the first page is what wakes it, so a browser that never sees a
+/// login form never opens the store — and never triggers an unlock prompt at
+/// launch, which is the behaviour that would have made this unwelcome.
+pub struct Accounts {
+    tx: mpsc::Sender<Request>,
+    /// Every account the last load returned.
+    all: Vec<Account>,
+    /// A load has been asked for and not yet answered.
+    loading: bool,
+    /// Set once a load has come back, so an empty keyring is not retried on
+    /// every focus.
+    loaded: bool,
+    /// Why the last load failed, for the menu to say so instead of showing
+    /// an empty list that looks like "no accounts".
+    pub error: Option<String>,
+}
+
+impl Accounts {
+    /// Start the worker. It is idle until [`Accounts::ensure_loaded`].
+    pub fn spawn(sender: calloop::channel::Sender<Message>) -> Self {
+        let (tx, rx) = mpsc::channel();
+        std::thread::Builder::new()
+            .name("cce-accounts".to_string())
+            .spawn(move || worker(rx, sender))
+            .expect("spawn the accounts worker");
+        Self { tx, all: Vec::new(), loading: false, loaded: false, error: None }
+    }
+
+    /// Ask for the index if it is not already here or on its way.
+    pub fn ensure_loaded(&mut self) {
+        if self.loaded || self.loading {
+            return;
+        }
+        self.loading = true;
+        let _ = self.tx.send(Request::Load);
+    }
+
+    /// Take the worker's answer.
+    pub fn loaded(&mut self, result: Result<Vec<Account>, String>) {
+        self.loading = false;
+        self.loaded = true;
+        match result {
+            Ok(all) => {
+                self.all = all;
+                self.error = None;
+            }
+            Err(e) => {
+                self.all.clear();
+                self.error = Some(e);
+            }
+        }
+    }
+
+    /// Fetch one password. It comes back as [`Message::Credential`].
+    pub fn fetch(&self, path: &str) {
+        let _ = self.tx.send(Request::Fetch(path.to_string()));
+    }
+
+    /// The accounts worth offering on `host`, best first: entries with a real
+    /// URL ahead of ones matched by their title alone, then by label.
+    pub fn matching(&self, host: &str) -> Vec<Account> {
+        let mut hits: Vec<Account> =
+            self.all.iter().filter(|a| a.matches(host)).cloned().collect();
+        hits.sort_by(|a, b| {
+            b.host()
+                .is_some()
+                .cmp(&a.host().is_some())
+                .then_with(|| a.label.to_lowercase().cmp(&b.label.to_lowercase()))
+        });
+        hits
+    }
+
+    pub fn is_loading(&self) -> bool {
+        self.loading
+    }
+}
+
+/// The worker thread: one Secret Service connection, held for the life of the
+/// browser, serving requests in order.
+fn worker(rx: mpsc::Receiver<Request>, tx: calloop::channel::Sender<Message>) {
+    use secret_service::blocking::SecretService;
+    use secret_service::EncryptionType;
+
+    let mut service: Option<SecretService> = None;
+    while let Ok(request) = rx.recv() {
+        // Connect on the first request, and again after a failure — the
+        // daemon can come and go.
+        if service.is_none() {
+            // Dh, not Plain: the secret then crosses the bus encrypted under a
+            // session key rather than in the clear.
+            match SecretService::connect(EncryptionType::Dh) {
+                Ok(s) => service = Some(s),
+                Err(e) => {
+                    let _ = tx.send(Message::Accounts(Err(format!("no secret service: {e}"))));
+                    continue;
+                }
+            }
+        }
+        let Some(ss) = service.as_ref() else { continue };
+        match request {
+            Request::Load => {
+                let _ = tx.send(Message::Accounts(load(ss)));
+            }
+            Request::Fetch(path) => {
+                if let Some(secret) = fetch(ss, &path) {
+                    let _ = tx.send(Message::Credential(path, secret));
+                }
+            }
+        }
+    }
+}
+
+fn load(ss: &secret_service::blocking::SecretService) -> Result<Vec<Account>, String> {
+    let collections = ss
+        .get_all_collections()
+        .map_err(|e| format!("listing collections failed: {e}"))?;
+    let mut accounts = Vec::new();
+    for collection in &collections {
+        // A locked collection is skipped rather than unlocked: the browser
+        // asking for the keyring password because a page happened to have a
+        // login field would be its own kind of phishing lesson. cce-secrets
+        // is the place to unlock.
+        if collection.is_locked().unwrap_or(true) {
+            continue;
+        }
+        let Ok(items) = collection.get_all_items() else { continue };
+        for item in items {
+            let Ok(attrs) = item.get_attributes() else { continue };
+            let pick = |keys: &[&str]| -> String {
+                keys.iter()
+                    .find_map(|k| attrs.get(*k).filter(|v| !v.trim().is_empty()))
+                    .cloned()
+                    .unwrap_or_default()
+            };
+            let username = pick(&USER_KEYS);
+            let url = pick(&URL_KEYS);
+            // An entry with neither is not an account — a note, a key, a
+            // token — and has nothing to offer a login form.
+            if username.is_empty() && url.is_empty() {
+                continue;
+            }
+            accounts.push(Account {
+                path: item.item_path.to_string(),
+                label: item.get_label().unwrap_or_default(),
+                username,
+                url,
+            });
+        }
+    }
+    Ok(accounts)
+}
+
+/// One entry's password. A failure is silent on purpose: the error text from
+/// this call can carry the item's own label, and it has nowhere to go but a
+/// log.
+fn fetch(ss: &secret_service::blocking::SecretService, path: &str) -> Option<Secret> {
+    let path = zbus::zvariant::OwnedObjectPath::try_from(path).ok()?;
+    let item = ss.get_item_by_path(path).ok()?;
+    let bytes = item.get_secret().ok()?;
+    Some(Secret(String::from_utf8_lossy(&bytes).into_owned()))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn account(label: &str, url: &str) -> Account {
+        Account {
+            path: "/org/freedesktop/secrets/item/1".to_string(),
+            label: label.to_string(),
+            username: "me".to_string(),
+            url: url.to_string(),
+        }
+    }
+
+    #[test]
+    fn a_stored_url_matches_its_own_host_and_its_subdomains() {
+        let a = account("Example", "https://example.com/login?next=/");
+        assert!(a.matches("example.com"));
+        assert!(a.matches("www.example.com"), "www is not a different site");
+        assert!(a.matches("login.example.com"), "a parent domain covers its subdomains");
+        assert!(!a.matches("example.com.evil.test"), "suffix games are not matches");
+        assert!(!a.matches("notexample.com"));
+        assert!(!a.matches("example.org"));
+    }
+
+    #[test]
+    fn a_subdomain_entry_does_not_leak_upward() {
+        let a = account("Mail", "https://mail.example.com/");
+        assert!(a.matches("mail.example.com"));
+        assert!(!a.matches("example.com"), "the parent is a different site");
+        assert!(!a.matches("chat.example.com"), "so is a sibling");
+    }
+
+    #[test]
+    fn a_bare_host_is_a_url_too() {
+        assert_eq!(entry_host("example.com"), Some("example.com".to_string()));
+        assert_eq!(entry_host("https://WWW.Example.COM/x"), Some("example.com".to_string()));
+        assert_eq!(entry_host("  "), None);
+        assert_eq!(entry_host("not a url at all"), None);
+    }
+
+    #[test]
+    fn an_entry_without_a_url_falls_back_to_its_title() {
+        let a = account("GitHub", "");
+        assert!(a.matches("github.com"));
+        assert!(a.matches("gist.github.com"), "the site name is the same one");
+        assert!(!a.matches("github.evil.test"));
+        assert!(!a.matches("gitlab.com"));
+
+        // The fallback is only for entries with nothing else to go on.
+        let titled = account("GitHub", "https://example.com/");
+        assert!(!titled.matches("github.com"), "a stored URL wins over the title");
+    }
+
+    #[test]
+    fn country_code_domains_still_find_their_name() {
+        assert_eq!(registrable_label("bbc.co.uk").as_deref(), Some("bbc"));
+        assert_eq!(registrable_label("www.example.com").as_deref(), Some("example"));
+        assert_eq!(registrable_label("localhost"), None);
+    }
+
+    #[test]
+    fn a_password_never_prints_itself() {
+        let s = Secret("hunter2".to_string());
+        assert_eq!(format!("{s:?}"), "Secret(…)");
+        assert_eq!(format!("{:?}", Message::Credential("/p".into(), s.clone())),
+                   "Credential(\"/p\", Secret(…))");
+        assert_eq!(s.expose(), "hunter2");
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 7f0c054..fe0df1c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8,6 +8,7 @@
 //! unfolds from under it. Input over the page area is translated into Servo input events; the
 //! URL bar is a small hand-rolled line editor.
 
+mod accounts;
 mod downloads;
 mod instance;
 mod lineedit;
@@ -105,6 +106,16 @@ const BM_GAP: f32 = 6.0;
 const BM_RM_W: f32 = 24.0;
 const BM_FONT: f32 = 13.0;
 const BM_TEXT_PAD: f32 = 10.0;
+/// The account list: suggestions from cce-secrets, dropped at the login
+/// field they are for rather than at the bar, because that is where the
+/// person is looking.
+const AC_W: f32 = 300.0;
+const AC_ROW_H: f32 = 34.0;
+const AC_PAD: f32 = 5.0;
+/// Rows before the list scrolls with the selection.
+const AC_MAX_ROWS: usize = 6;
+const AC_FONT: f32 = 13.0;
+const AC_SUB_FONT: f32 = 11.0;
 /// Utility-bar fill; the negative alpha marks the plate as blur-behind.
 /// The blurred page is the base and this color tints it at |alpha|
 /// opacity — keep |alpha| low so the frosted content shows through.
@@ -310,6 +321,86 @@ enum BmHit {
     Manage,
 }
 
+/// The account list: what cce-secrets can offer the login field that is
+/// focused right now.
+///
+/// It belongs to a *field*, not to the bar — it opens when one takes focus,
+/// follows it when the page scrolls, and goes when focus does. Only the
+/// accounts matching the tab's own host are ever in it, and no password is
+/// fetched to build it: a pick is what asks the keyring for one.
+#[cfg(feature = "wpe")]
+struct AcMenu {
+    /// Matches for this host, before filtering.
+    all: Vec<accounts::Account>,
+    /// What survives what has been typed into the username field.
+    shown: Vec<accounts::Account>,
+    /// Keyboard selection, an index into `shown`.
+    selected: usize,
+    /// First visible row, when `shown` is longer than the list can show.
+    scroll: usize,
+    /// The field, in the chrome's own coordinates.
+    anchor: Rect,
+    /// The host this list was built for. A fetched password is checked
+    /// against it before it is filled: the keyring answers asynchronously,
+    /// and by then the tab could be somewhere else entirely.
+    host: String,
+    /// The page is not on a secure origin — worth saying before a password
+    /// goes into it.
+    insecure: bool,
+    /// Pointer-hovered row.
+    hover: Option<usize>,
+}
+
+#[cfg(feature = "wpe")]
+impl AcMenu {
+    /// Narrow the list to what has been typed. Matching is on the username
+    /// and the entry's title, case-insensitively and anywhere in either —
+    /// people type the middle of an address as readily as its start.
+    fn refilter(&mut self, typed: &str) {
+        let needle = typed.trim().to_lowercase();
+        self.shown = self
+            .all
+            .iter()
+            .filter(|a| {
+                needle.is_empty()
+                    || a.username.to_lowercase().contains(&needle)
+                    || a.label.to_lowercase().contains(&needle)
+            })
+            .cloned()
+            .collect();
+        self.selected = self.selected.min(self.shown.len().saturating_sub(1));
+        self.scroll = self.scroll.min(self.shown.len().saturating_sub(1));
+        self.keep_selected_visible();
+    }
+
+    fn first_row(&self) -> usize {
+        self.scroll
+            .min(self.shown.len().saturating_sub(self.shown.len().min(AC_MAX_ROWS)))
+    }
+
+    /// Move the keyboard selection, scrolling the window to follow it.
+    fn step(&mut self, delta: isize) {
+        if self.shown.is_empty() {
+            return;
+        }
+        let n = self.shown.len() as isize;
+        self.selected = (((self.selected as isize + delta) % n + n) % n) as usize;
+        self.keep_selected_visible();
+    }
+
+    fn keep_selected_visible(&mut self) {
+        let rows = self.shown.len().min(AC_MAX_ROWS);
+        if rows == 0 {
+            return;
+        }
+        if self.selected < self.scroll {
+            self.scroll = self.selected;
+        } else if self.selected >= self.scroll + rows {
+            self.scroll = self.selected + 1 - rows;
+        }
+    }
+}
+
 /// Every rect the menu draws and hit-tests, derived once — the same
 /// one-geometry rule the bar's own helpers follow.
 struct BmLayout {
@@ -326,6 +417,11 @@ struct BmLayout {
 
 #[derive(Debug, Clone)]
 pub enum Message {
+    /// The accounts worker answered a load: the index, or why it failed.
+    Accounts(Result<Vec<accounts::Account>, String>),
+    /// One entry's password arrived for the account at this object path.
+    /// The payload prints as `Secret(…)`; see `accounts::Secret`.
+    Credential(String, accounts::Secret),
     /// Servo requested an event-loop spin (waker or delegate signal).
     Spin,
     /// Last tab closed: exit the app.
@@ -388,6 +484,18 @@ struct BrowserApp {
     bookmarks: std::sync::Arc<pages::Bookmarks>,
     /// The open bookmarks menu, if any.
     bm_menu: Option<BmMenu>,
+    /// Accounts from cce-secrets, and the worker that reads them.
+    accounts: accounts::Accounts,
+    /// The open account list, if a login field is focused and something in
+    /// the keyring matches the page.
+    #[cfg(feature = "wpe")]
+    ac_menu: Option<AcMenu>,
+    /// The active tab's URL as of the last page-state sync, for spotting an
+    /// actual navigation. The engine's dirty flag is not that: it also fires
+    /// for a title, a loading transition, a favicon — and treating those as
+    /// navigations closed the account list in the same pump that opened it.
+    #[cfg(feature = "wpe")]
+    nav_url: Option<String>,
     /// Hovered pill in the favorites strip — a repaint, like the dot.
     fav_hover: Option<usize>,
 }
@@ -521,6 +629,18 @@ fn url_rect(bar: &Rect, position: settings::BarPosition) -> Rect {
     Rect { x, y: controls_y(bar), width: (right - x).max(60.0), height: BTN_H }
 }
 
+/// Whether a password filled into this page would leave it in the clear.
+///
+/// Loopback is not: nothing crosses a network. Everything else that is not
+/// https is, including a `file:` page, which has no origin to speak of.
+#[cfg(feature = "wpe")]
+fn insecure_origin(origin: &str, host: &str) -> bool {
+    let secure_scheme = origin.split(':').next() == Some("https");
+    let loopback = matches!(host, "localhost" | "127.0.0.1" | "::1")
+        || host.ends_with(".localhost");
+    !secure_scheme && !loopback
+}
+
 /// Turn URL-bar input into something loadable: a real URL as-is, a bare
 /// host gets https://, anything else becomes a search.
 fn parse_url_input(input: &str, search_prefix: &str) -> Option<Url> {
@@ -613,6 +733,185 @@ impl BrowserApp {
         self.refresh_favorites();
     }
 
+    /// The tab's host, for matching accounts and for checking that a field
+    /// event came from the page the chrome thinks is on screen.
+    fn page_host(&self) -> Option<String> {
+        self.host.url().and_then(|u| u.host_str().map(str::to_string))
+    }
+
+    /// A login field was reported. Open, move or refill the account list.
+    ///
+    /// The origin check is the guard: the watcher runs in the top frame, so
+    /// its origin must be the tab's own. Anything else is dropped rather than
+    /// offered a credential.
+    #[cfg(feature = "wpe")]
+    fn on_form_event(&mut self, event: wpe::FormEvent) -> bool {
+        use wpe::FormEvent;
+        if !self.settings.accounts {
+            return false;
+        }
+        match event {
+            FormEvent::Blur => {
+                let was = self.ac_menu.is_some();
+                self.ac_menu = None;
+                was
+            }
+            FormEvent::Field { origin, password, rect, value, moved } => {
+                log::debug!(
+                    "login field: password={password} moved={moved} origin={origin} \
+                     page={:?} rect={rect:?}",
+                    self.page_host()
+                );
+                let Some(host) = self.page_host() else {
+                    self.ac_menu = None;
+                    return false;
+                };
+                let same_origin = url::Url::parse(&origin)
+                    .ok()
+                    .and_then(|u| u.host_str().map(|h| h == host))
+                    .unwrap_or(false);
+                if !same_origin {
+                    self.ac_menu = None;
+                    return false;
+                }
+                // The index is read the first time a login field appears —
+                // never at launch, so a browser that sees no login form never
+                // opens the keyring.
+                self.accounts.ensure_loaded();
+                let anchor = self.field_rect(rect);
+                let all = self.accounts.matching(&host);
+                log::debug!("{} accounts match {host}", all.len());
+                let insecure = insecure_origin(&origin, &host);
+                // A password field filters by nothing; a username field by
+                // what is in it.
+                let filter = if password { String::new() } else { value };
+                match self.ac_menu.as_mut() {
+                    Some(menu) if moved => {
+                        menu.anchor = anchor;
+                        menu.all = all;
+                        menu.host = host.clone();
+                        menu.insecure = insecure;
+                        menu.refilter(&filter);
+                    }
+                    _ => {
+                        let mut menu = AcMenu {
+                            all,
+                            shown: Vec::new(),
+                            selected: 0,
+                            scroll: 0,
+                            anchor,
+                            host: host.clone(),
+                            insecure,
+                            hover: None,
+                        };
+                        menu.refilter(&filter);
+                        self.ac_menu = Some(menu);
+                    }
+                }
+                // An empty list is no list: nothing matched, or the index is
+                // still loading and the next `Accounts` message will reopen.
+                if self.ac_menu.as_ref().is_some_and(|m| m.shown.is_empty()) {
+                    self.ac_menu = None;
+                }
+                true
+            }
+        }
+    }
+
+    /// A viewport rect from the page, in the chrome's coordinates.
+    ///
+    /// These are the same space, and that is worth stating rather than
+    /// rediscovering: `resize` gives WPE the **logical** size and sets the
+    /// scale separately (`logical_size`), so a CSS pixel in the page is a
+    /// logical pixel in the chrome at any output scale. Verified at scale 2.
+    #[cfg(feature = "wpe")]
+    fn field_rect(&self, rect: (f32, f32, f32, f32)) -> Rect {
+        Rect { x: rect.0, y: rect.1, width: rect.2, height: rect.3 }
+    }
+
+    /// Hand a picked account's credential to the page and close the list.
+    ///
+    /// The keyring answers on its own schedule — an unlock prompt can put
+    /// seconds between the pick and this — so everything is checked again
+    /// here: the list is still open, it still holds the account that was
+    /// picked, and the tab is still on the host it was opened for. If any of
+    /// that has changed the credential is dropped on the floor rather than
+    /// typed into whatever page is there now.
+    #[cfg(feature = "wpe")]
+    fn fill_account(&mut self, path: &str, secret: &accounts::Secret) {
+        let same_page = self
+            .ac_menu
+            .as_ref()
+            .zip(self.page_host())
+            .is_some_and(|(menu, host)| menu.host == host);
+        let username = self
+            .ac_menu
+            .as_ref()
+            .filter(|_| same_page)
+            .and_then(|m| m.shown.iter().find(|a| a.path == path))
+            .map(|a| a.username.clone());
+        match username {
+            Some(username) => self.host.fill_credentials(&username, secret.expose()),
+            None => log::warn!("dropped a credential: the page moved on before it arrived"),
+        }
+        self.ac_menu = None;
+    }
+
+    /// Ask for the password behind the selected row.
+    #[cfg(feature = "wpe")]
+    fn pick_account(&mut self, index: usize) {
+        let Some(account) = self.ac_menu.as_ref().and_then(|m| m.shown.get(index)) else {
+            return;
+        };
+        // The secret is fetched now, for this one entry, and arrives as
+        // `Message::Credential`. Nothing is held in the menu.
+        self.accounts.fetch(&account.path);
+    }
+
+    /// The account list's plate and rows, or `None` when it is closed. Draw
+    /// and hit-test read this, as everywhere else in this chrome.
+    #[cfg(feature = "wpe")]
+    fn ac_layout(&self) -> Option<(Rect, Vec<Rect>)> {
+        let menu = self.ac_menu.as_ref()?;
+        if menu.shown.is_empty() {
+            return None;
+        }
+        let rows = menu.shown.len().min(AC_MAX_ROWS);
+        let height = 2.0 * AC_PAD + rows as f32 * AC_ROW_H + if menu.insecure { 18.0 } else { 0.0 };
+        let width = AC_W.min(self.win.0 - 2.0 * BAR_MARGIN).max(180.0);
+        let x = menu
+            .anchor
+            .x
+            .clamp(0.0, (self.win.0 - width).max(0.0));
+        // Under the field, or above it when there is no room below — the
+        // list must never cover the field it is filling.
+        let below = menu.anchor.y + menu.anchor.height + 2.0;
+        let y = if below + height <= self.win.1 - BAR_MARGIN {
+            below
+        } else {
+            (menu.anchor.y - 2.0 - height).max(0.0)
+        };
+        let plate = Rect { x, y, width, height };
+        // Row *positions*; which account each shows is `first_row() + k`.
+        let rects = (0..rows)
+            .map(|k| Rect {
+                x: plate.x + 2.0,
+                y: plate.y + AC_PAD + (k as f32) * AC_ROW_H,
+                width: plate.width - 4.0,
+                height: AC_ROW_H,
+            })
+            .collect();
+        Some((plate, rects))
+    }
+
+    /// The account row at a pointer position, if any.
+    #[cfg(feature = "wpe")]
+    fn ac_hit(&self, x: f32, y: f32) -> Option<usize> {
+        let (_, rows) = self.ac_layout()?;
+        let first = self.ac_menu.as_ref()?.first_row();
+        rows.iter().position(|r| hit(r, x, y)).map(|k| first + k)
+    }
+
     /// Whether the active page is one that can be saved at all: an internal
     /// page or a blank tab cannot.
     fn saveable(&self) -> bool {
@@ -1049,6 +1348,13 @@ impl BrowserApp {
             return false;
         }
         downloads::set_download_dir(new.download_dir.clone());
+        #[cfg(feature = "wpe")]
+        if new.accounts != self.settings.accounts {
+            self.host.set_accounts_enabled(new.accounts);
+            if !new.accounts {
+                self.ac_menu = None;
+            }
+        }
         self.host.set_history_enabled(new.history);
         self.host.set_color_scheme_dark(new.color_scheme.is_dark());
         self.host.set_force_dark(new.color_scheme.forces_dark());
@@ -1144,6 +1450,12 @@ impl BrowserApp {
     }
 
     fn switch_tab(&mut self, index: usize) {
+        // The other tab has its own fields, and may have none.
+        #[cfg(feature = "wpe")]
+        {
+            self.ac_menu = None;
+            self.host.clear_form_events();
+        }
         self.host.activate(index);
         self.url_focused = false;
         self.sync_page_state();
@@ -1261,6 +1573,71 @@ impl BrowserApp {
         }
     }
 
+    /// Draw the account list at the login field it belongs to.
+    ///
+    /// Two lines per row: the username that will be filled, and the entry's
+    /// own title under it, because a keyring holds several accounts on one
+    /// site and the title is how they were told apart when they were saved.
+    #[cfg(feature = "wpe")]
+    fn paint_ac_menu(&mut self, pc: &mut PaintCtx, sans: &str) {
+        let Some((plate, rows)) = self.ac_layout() else { return };
+        let Some(menu) = self.ac_menu.as_ref() else { return };
+        let first = menu.first_row();
+        pc.plate(
+            plate,
+            (8.0, 8.0, 8.0, 8.0),
+            [0.13, 0.14, 0.16, 1.0],
+            cce_ui::layout::bevel_width().min(3.0),
+        );
+        for (k, r) in rows.iter().enumerate() {
+            let Some(account) = menu.shown.get(first + k) else { continue };
+            let picked = first + k == menu.selected || menu.hover == Some(first + k);
+            if picked {
+                pc.rounded_rect(*r, 5.0, (true, true, true, true), TAB_ACTIVE_BG);
+            }
+            let width = r.width - 2.0 * BM_TEXT_PAD;
+            let user = if account.username.is_empty() {
+                account.label.clone()
+            } else {
+                account.username.clone()
+            };
+            pc.text(
+                Self::fit_text(&user, sans, AC_FONT, width),
+                r.x + BM_TEXT_PAD,
+                r.y + 5.0,
+                AC_FONT,
+                TEXT,
+            );
+            // The second line names where the entry came from: its title, and
+            // the site it is stored against when that is not the title.
+            let mut sub = account.label.clone();
+            if let Some(host) = account.host() {
+                if !sub.to_lowercase().contains(&host) {
+                    sub = if sub.is_empty() { host } else { format!("{sub} — {host}") };
+                }
+            }
+            pc.text(
+                Self::fit_text(&sub, sans, AC_SUB_FONT, width),
+                r.x + BM_TEXT_PAD,
+                r.y + 5.0 + AC_FONT + 3.0,
+                AC_SUB_FONT,
+                TEXT_DIM,
+            );
+        }
+        // Say it plainly when the page is not https: the password is about to
+        // cross the network in the clear, and only the person can decide that
+        // is fine.
+        if menu.insecure {
+            pc.text(
+                "insecure page — this password would be sent unencrypted",
+                plate.x + BM_TEXT_PAD,
+                plate.y + plate.height - 15.0,
+                AC_SUB_FONT,
+                [212, 155, 155],
+            );
+        }
+    }
+
     /// Draw the bookmarks menu: the same plate-and-rows vocabulary as the
     /// right-click menu, in three sections — what to do with this page, the
     /// pages already saved, and the way out to the full collection.
@@ -1510,7 +1887,8 @@ impl Application for BrowserApp {
         let first = queue.remove(0);
 
         #[cfg(all(not(feature = "wpe"), feature = "servo"))]
-        let mut host = Host::new(sender, first, (1200, 800), settings.color_scheme.forces_dark());
+        let mut host =
+            Host::new(sender.clone(), first, (1200, 800), settings.color_scheme.forces_dark());
         #[cfg(feature = "wpe")]
         let mut host = {
             let _ = &sender; // WPE wakes through register_sources, not a waker
@@ -1535,6 +1913,9 @@ impl Application for BrowserApp {
         host.set_color_scheme_dark(settings.color_scheme.is_dark());
         #[cfg(feature = "wpe")]
         host.set_force_dark(settings.color_scheme.forces_dark());
+        #[cfg(feature = "wpe")]
+        host.set_accounts_enabled(settings.accounts);
+        let accounts = accounts::Accounts::spawn(sender.clone());
         let favorites = host.favorites();
         let favs = favorites.snapshot();
         let bookmarks = host.bookmarks();
@@ -1564,6 +1945,11 @@ impl Application for BrowserApp {
             fav_hover: None,
             bookmarks,
             bm_menu: None,
+            accounts,
+            #[cfg(feature = "wpe")]
+            ac_menu: None,
+            #[cfg(feature = "wpe")]
+            nav_url: None,
         }
     }
 
@@ -1641,15 +2027,61 @@ impl Application for BrowserApp {
                     self.open_internal_page("cce://downloads");
                 }
                 if dirty {
+                    // A navigation retires whatever field was focused. Only a
+                    // real one: the URL changing, not the dirty flag, which
+                    // also fires while the page that owns the field is still
+                    // settling.
+                    #[cfg(feature = "wpe")]
+                    {
+                        let now = self.host.url().map(|u| u.to_string());
+                        if now != self.nav_url {
+                            self.nav_url = now;
+                            self.ac_menu = None;
+                        }
+                    }
                     self.sync_page_state();
                     // Navigation reaches the tab set through these signals,
                     // so this is where an address change gets persisted.
                     self.persist_session();
                 }
+                // Drained after the navigation check, so a field reported in
+                // the same pump that finished the load is not thrown away
+                // with the page it arrived on.
+                #[cfg(feature = "wpe")]
+                while let Some(event) = self.host.take_form_event() {
+                    if self.on_form_event(event) {
+                        *needs_rebuild = true;
+                    }
+                }
                 if new_frame || dirty {
                     *needs_rebuild = true;
                 }
             }
+            Message::Accounts(result) => {
+                match &result {
+                    Ok(list) => log::info!("accounts: {} entries from the keyring", list.len()),
+                    Err(e) => log::warn!("accounts unavailable: {e}"),
+                }
+                self.accounts.loaded(result);
+                // A field may have been focused while the index was still
+                // being read; this is when its list can finally open.
+                #[cfg(feature = "wpe")]
+                {
+                    self.host.request_form_state();
+                    *needs_rebuild = true;
+                }
+            }
+            Message::Credential(path, secret) => {
+                #[cfg(feature = "wpe")]
+                {
+                    self.fill_account(&path, &secret);
+                    *needs_rebuild = true;
+                }
+                #[cfg(not(feature = "wpe"))]
+                {
+                    let _ = (path, secret);
+                }
+            }
             Message::Quit => *exit = true,
             Message::OpenExternal(arg) => {
                 match arg {
@@ -1718,6 +2150,22 @@ impl Application for BrowserApp {
             *_needs_rebuild = true;
             return;
         }
+        // The account list tracks hover the same way, and shields the page
+        // under it.
+        #[cfg(feature = "wpe")]
+        if self.ac_menu.is_some() {
+            let over = self.ac_hit(pos.x, pos.y);
+            if self.ac_menu.as_ref().is_some_and(|m| m.hover != over) {
+                if let Some(m) = self.ac_menu.as_mut() {
+                    m.hover = over;
+                }
+                *_needs_rebuild = true;
+            }
+            if over.is_some() {
+                return;
+            }
+        }
+
         // An open bookmarks menu tracks hover, and the page under it sees
         // no moves at all.
         if self.bm_menu.is_some() {
@@ -1803,6 +2251,25 @@ impl Application for BrowserApp {
             return None;
         }
 
+        // A click on an account row picks it. A click anywhere else closes
+        // the list and goes on to the page as usual — unlike the chrome's own
+        // menus, this one sits over the page's own controls, and swallowing
+        // the click that dismisses it would eat a button press.
+        #[cfg(feature = "wpe")]
+        if self.ac_menu.is_some() {
+            if let Some(index) = self.ac_hit(pos.x, pos.y) {
+                if pressed && button == MouseButton::Left {
+                    self.pick_account(index);
+                }
+                *needs_rebuild = true;
+                return None;
+            }
+            if pressed {
+                self.ac_menu = None;
+                *needs_rebuild = true;
+            }
+        }
+
         // The bookmarks menu owns the next click while it is open: a row
         // acts, a click off the plate closes it, and either way the click
         // goes no further — the rule the right-click menu already follows.
@@ -1933,6 +2400,16 @@ impl Application for BrowserApp {
     }
 
     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        // The account list moves with its field, so the page keeps the
+        // wheel — but not under the plate itself.
+        #[cfg(feature = "wpe")]
+        if self
+            .ac_layout()
+            .is_some_and(|(plate, _)| hit(&plate, pos.x, pos.y))
+        {
+            return;
+        }
+
         // An open menu takes the wheel: over its plate it scrolls the list,
         // anywhere else it is swallowed rather than scrolling the page
         // behind it.
@@ -2015,6 +2492,43 @@ impl Application for BrowserApp {
             return None;
         }
 
+        // An open account list takes the keys that drive it, and passes on
+        // everything else — the person is typing into the page's own field,
+        // and that typing is what filters the list.
+        #[cfg(feature = "wpe")]
+        if self.ac_menu.is_some() && event.state == ElementState::Pressed && !self.url_focused {
+            match &event.logical_key {
+                Key::Named(NamedKey::ArrowDown) => {
+                    if let Some(m) = self.ac_menu.as_mut() {
+                        m.step(1);
+                    }
+                    *needs_rebuild = true;
+                    return None;
+                }
+                Key::Named(NamedKey::ArrowUp) => {
+                    if let Some(m) = self.ac_menu.as_mut() {
+                        m.step(-1);
+                    }
+                    *needs_rebuild = true;
+                    return None;
+                }
+                Key::Named(NamedKey::Enter) => {
+                    let selected = self.ac_menu.as_ref().map(|m| m.selected);
+                    if let Some(i) = selected {
+                        self.pick_account(i);
+                    }
+                    *needs_rebuild = true;
+                    return None;
+                }
+                Key::Named(NamedKey::Escape) => {
+                    self.ac_menu = None;
+                    *needs_rebuild = true;
+                    return None;
+                }
+                _ => {}
+            }
+        }
+
         // An open bookmarks menu owns Escape, ahead of the URL bar and the
         // page both.
         if self.bm_menu.is_some()
@@ -2364,6 +2878,8 @@ impl Application for BrowserApp {
 
         self.paint_bm_menu(&mut pc, &sans);
         #[cfg(feature = "wpe")]
+        self.paint_ac_menu(&mut pc, &sans);
+        #[cfg(feature = "wpe")]
         self.paint_ctx_menu(&mut pc, &sans);
         #[cfg(feature = "wpe")]
         self.paint_modal(&mut pc, &sans);
@@ -2417,6 +2933,19 @@ mod tests {
         std::fs::remove_file(&page).unwrap();
     }
 
+    #[cfg(feature = "wpe")]
+    #[test]
+    fn only_loopback_escapes_the_insecure_warning() {
+        assert!(!insecure_origin("https://example.com", "example.com"));
+        assert!(insecure_origin("http://example.com", "example.com"));
+        assert!(!insecure_origin("http://localhost:8731", "localhost"));
+        assert!(!insecure_origin("http://127.0.0.1:8080", "127.0.0.1"));
+        assert!(!insecure_origin("http://dev.localhost", "dev.localhost"));
+        // A file: page has no transport to secure, and no origin worth the
+        // name; say so rather than stay quiet.
+        assert!(insecure_origin("null", ""));
+    }
+
     #[test]
     fn startup_arg_still_takes_urls_and_searches() {
         let u = parse_startup_arg("https://example.com/x", SEARCH).unwrap();
diff --git a/src/settings.rs b/src/settings.rs
index 3ea64ea..0c5795d 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -88,6 +88,10 @@ pub struct Settings {
     pub download_dir: Option<PathBuf>,
     /// Record page visits to cce://history.
     pub history: bool,
+    /// Offer accounts from cce-secrets on login forms. On by default, and a
+    /// single switch for the whole feature: with it off the browser injects
+    /// no watcher script and never opens the keyring.
+    pub accounts: bool,
     /// Window edge the utility bar floats against.
     pub bar_position: BarPosition,
     /// What pages are told to prefer.
@@ -104,6 +108,7 @@ impl Default for Settings {
             search_prefix: search_prefix("duckduckgo").to_string(),
             download_dir: None,
             history: true,
+            accounts: true,
             bar_position: BarPosition::Top,
             color_scheme: ColorScheme::Dark,
             external_browser: None,
@@ -146,6 +151,7 @@ pub fn load() -> Settings {
         search_prefix: search_prefix(b["search"].as_str().unwrap_or("duckduckgo")).to_string(),
         download_dir,
         history: b["history"].as_bool().unwrap_or(true),
+        accounts: b["accounts"].as_bool().unwrap_or(true),
         bar_position: BarPosition::from_key(b["bar-position"].as_str().unwrap_or("top")),
         color_scheme: ColorScheme::from_key(b["color-scheme"].as_str().unwrap_or("dark")),
         external_browser: b["external-browser"]
diff --git a/src/wpe/formwatch.rs b/src/wpe/formwatch.rs
new file mode 100644
index 0000000..1f0103e
--- /dev/null
+++ b/src/wpe/formwatch.rs
@@ -0,0 +1,282 @@
+//! The page half of account autocomplete: what the chrome knows about a
+//! login form, and how a picked account gets into it.
+//!
+//! Both directions run in a **private script world** (`WORLD`), not the
+//! page's. Two things follow, and they are the reason for the whole
+//! arrangement: the page cannot see or replace the helpers this installs, so
+//! it cannot hook the moment a credential is filled; and the message channel
+//! the chrome listens on cannot be spoofed by page script, so a page cannot
+//! make the chrome believe a login field is focused when none is.
+//!
+//! The script is injected into the **top frame only**. A password field
+//! inside a cross-origin iframe therefore gets no suggestions — the deliberate
+//! trade: such a frame cannot report a position in the top document's
+//! coordinates, and an embedded frame asking for the embedder's credentials
+//! is exactly the shape of the attack this feature must not enable.
+
+/// The isolated world everything here lives in.
+pub const WORLD: &str = "cce-accounts";
+/// The message channel the injected script posts on.
+pub const CHANNEL: &str = "cceAccounts";
+
+/// Watches the top frame for login fields and reports them to the chrome.
+///
+/// It reports *positions*, *field kinds* and *what is typed* — never page
+/// content at large. Rects are CSS pixels relative to the viewport, which the
+/// chrome converts with the same scale it sized the view at.
+pub const WATCH_JS: &str = r#"
+(() => {
+  const post = (m) => {
+    try { window.webkit.messageHandlers.cceAccounts.postMessage(JSON.stringify(m)); }
+    catch (e) {}
+  };
+  const state = { user: null, pass: null, filling: false };
+  window.__cceAccounts = state;
+
+  const isPassword = (el) =>
+    el && el.tagName === 'INPUT' && el.type === 'password' && !el.disabled && !el.readOnly;
+  // A username field is a text-ish input that keeps company with a password
+  // one: same form, or — for the many login pages that use no form element —
+  // anywhere on a page that has one. Autocomplete hints and the usual names
+  // are accepted on their own, since some pages ask for the username first
+  // and only render the password field on the next step.
+  const textish = (el) =>
+    el && el.tagName === 'INPUT' &&
+    ['text', 'email', 'tel', ''].includes((el.type || '').toLowerCase()) &&
+    !el.disabled && !el.readOnly;
+  const named = (el) => {
+    const hint = ((el.autocomplete || '') + ' ' + (el.name || '') + ' ' +
+                  (el.id || '') + ' ' + (el.getAttribute('aria-label') || '')).toLowerCase();
+    return /user|email|login|account|ident/.test(hint);
+  };
+  const passwordsIn = (root) =>
+    Array.from((root || document).querySelectorAll('input[type=password]'))
+         .filter(isPassword);
+
+  const kindOf = (el) => {
+    if (isPassword(el)) return 'pass';
+    if (!textish(el)) return null;
+    const form = el.form;
+    if (passwordsIn(form).length) return 'user';
+    if (named(el) && passwordsIn(document).length) return 'user';
+    if (named(el) && el.type.toLowerCase() === 'email') return 'user';
+    return null;
+  };
+
+  const rectOf = (el) => {
+    const r = el.getBoundingClientRect();
+    return [r.left, r.top, r.width, r.height];
+  };
+
+  const report = (el, kind, type) => {
+    // A fill is not something to report back: the input events it dispatches
+    // would arrive as "the user typed", re-opening the list that was just
+    // used and filtering it by the name it had just filled in.
+    if (state.filling) return;
+    if (kind === 'pass') { state.pass = el; } else { state.user = el; }
+    // Remember the pair, so filling reaches both fields from either one.
+    const form = el.form;
+    const pass = passwordsIn(form).concat(passwordsIn(document))[0] || null;
+    if (pass) state.pass = pass;
+    if (kind === 'user') state.user = el;
+    post({
+      t: type,
+      kind: kind,
+      origin: location.origin,
+      rect: rectOf(el),
+      value: kind === 'pass' ? '' : (el.value || ''),
+    });
+  };
+
+  document.addEventListener('focusin', (e) => {
+    const kind = kindOf(e.target);
+    if (kind) report(e.target, kind, 'focus');
+  }, true);
+
+  document.addEventListener('focusout', (e) => {
+    if (kindOf(e.target)) post({ t: 'blur' });
+  }, true);
+
+  // Typing in the username field is the filter; the password field's own
+  // text is never reported.
+  document.addEventListener('input', (e) => {
+    const kind = kindOf(e.target);
+    if (kind === 'user' && document.activeElement === e.target) {
+      report(e.target, kind, 'input');
+    }
+  }, true);
+
+  // The page moving under an open list would leave it pointing at nothing.
+  const moved = () => {
+    const el = document.activeElement;
+    const kind = kindOf(el);
+    if (kind) report(el, kind, 'move'); else post({ t: 'blur' });
+  };
+  window.addEventListener('scroll', moved, true);
+  window.addEventListener('resize', moved, true);
+  // The chrome calls this when it has something new to offer — the account
+  // index finishing its first read, after a field was already focused.
+  state.rescan = moved;
+})();
+"#;
+
+/// Fill the remembered pair. Evaluated in [`WORLD`], so it reads the elements
+/// the watcher recorded rather than trusting anything the page exposes.
+///
+/// Values go in through the prototype's own `value` setter and are followed by
+/// `input` and `change` events: frameworks that track their inputs (React's
+/// value tracker above all) ignore a plain assignment, and a page whose state
+/// never saw the credential appear will submit an empty form.
+///
+/// It does not submit. Filling is the chrome's business; pressing the button
+/// is the person's.
+pub fn fill_js(username: &str, password: &str) -> String {
+    format!(
+        r#"
+(() => {{
+  const s = window.__cceAccounts || {{}};
+  // Suppress the watcher for the duration: dispatching `input` is the whole
+  // point of filling, and it must not come back as typing. Synchronous, so
+  // the flag is down again before anything else runs.
+  s.filling = true;
+  const set = (el, v) => {{
+    if (!el) return false;
+    const d = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value');
+    if (d && d.set) {{ d.set.call(el, v); }} else {{ el.value = v; }}
+    el.dispatchEvent(new Event('input', {{ bubbles: true }}));
+    el.dispatchEvent(new Event('change', {{ bubbles: true }}));
+    return true;
+  }};
+  const user = {user};
+  const pass = {pass};
+  const filledUser = user.length ? set(s.user, user) : false;
+  const filledPass = set(s.pass, pass);
+  if (filledUser && !filledPass && s.user) {{ s.user.focus(); }}
+  s.filling = false;
+}})();
+"#,
+        user = json_string(username),
+        pass = json_string(password),
+    )
+}
+
+/// A JSON string literal — the only escaping this file needs, and it has to be
+/// exact: a credential is about to cross into a script source, where a stray
+/// quote would end the string and the rest would be parsed as code.
+pub fn json_string(s: &str) -> String {
+    let mut out = String::with_capacity(s.len() + 2);
+    out.push('"');
+    for c in s.chars() {
+        match c {
+            '"' => out.push_str("\\\""),
+            '\\' => out.push_str("\\\\"),
+            '\n' => out.push_str("\\n"),
+            '\r' => out.push_str("\\r"),
+            '\t' => out.push_str("\\t"),
+            // Line separators are literal newlines to a JS parser.
+            '\u{2028}' => out.push_str("\\u2028"),
+            '\u{2029}' => out.push_str("\\u2029"),
+            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
+            c => out.push(c),
+        }
+    }
+    out.push('"');
+    out
+}
+
+/// Ask the watcher to re-report whatever login field is focused right now.
+///
+/// The chrome needs this exactly once per page in practice: a field can take
+/// focus before the account index has finished its first read, and without a
+/// nudge nothing would report it again until the person clicked away and back.
+pub const RESCAN_JS: &str =
+    "window.__cceAccounts && window.__cceAccounts.rescan && window.__cceAccounts.rescan();";
+
+/// What the watcher saw, as the chrome consumes it.
+#[derive(Debug, Clone)]
+pub enum FormEvent {
+    /// A login field took focus, or moved, or its text changed.
+    Field {
+        /// `location.origin` of the frame that reported it, checked against
+        /// the tab's own URL before anything is offered.
+        origin: String,
+        /// A password field rather than a username one.
+        password: bool,
+        /// Viewport rect in CSS pixels: x, y, width, height.
+        rect: (f32, f32, f32, f32),
+        /// What the username field holds, for filtering. Always empty for a
+        /// password field — the chrome has no business with what is typed
+        /// into one.
+        value: String,
+        /// True when this is a re-report of a field that was already focused
+        /// (scroll, resize, typing) rather than a fresh focus.
+        moved: bool,
+    },
+    /// Focus left the login field.
+    Blur,
+}
+
+/// Parse one message from the watcher. Anything unexpected is dropped: this
+/// is a channel the chrome acts on, so it takes only what it recognizes.
+pub fn parse_event(json: &str) -> Option<FormEvent> {
+    let value: serde_json::Value = serde_json::from_str(json).ok()?;
+    match value["t"].as_str()? {
+        "blur" => Some(FormEvent::Blur),
+        t @ ("focus" | "input" | "move") => {
+            let rect = value["rect"].as_array()?;
+            let num = |i: usize| rect.get(i).and_then(|v| v.as_f64()).map(|f| f as f32);
+            Some(FormEvent::Field {
+                origin: value["origin"].as_str().unwrap_or_default().to_string(),
+                password: value["kind"].as_str() == Some("pass"),
+                rect: (num(0)?, num(1)?, num(2)?, num(3)?),
+                value: value["value"].as_str().unwrap_or_default().to_string(),
+                moved: t != "focus",
+            })
+        }
+        _ => None,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn a_credential_cannot_break_out_of_the_fill_script() {
+        // The whole hazard in one string: quotes, a backslash, a closing
+        // script tag, a newline and a line separator.
+        let nasty = "a\"b\\c</script>\nd\u{2028}e";
+        let quoted = json_string(nasty);
+        assert_eq!(quoted, "\"a\\\"b\\\\c</script>\\nd\\u2028e\"");
+        let js = fill_js("user", nasty);
+        assert!(js.contains(&quoted));
+        // No raw newline from the credential ever reaches the source.
+        assert!(!js.contains("d\u{2028}"));
+    }
+
+    #[test]
+    fn events_parse_and_junk_is_dropped() {
+        let focus = parse_event(
+            r#"{"t":"focus","kind":"user","origin":"https://example.com","rect":[10,20,120,24],"value":"me"}"#,
+        );
+        match focus {
+            Some(FormEvent::Field { origin, password, rect, value, moved }) => {
+                assert_eq!(origin, "https://example.com");
+                assert!(!password);
+                assert_eq!(rect, (10.0, 20.0, 120.0, 24.0));
+                assert_eq!(value, "me");
+                assert!(!moved);
+            }
+            other => panic!("expected a field event, got {other:?}"),
+        }
+        assert!(matches!(parse_event(r#"{"t":"blur"}"#), Some(FormEvent::Blur)));
+        assert!(matches!(
+            parse_event(r#"{"t":"input","kind":"pass","origin":"x","rect":[0,0,1,1],"value":""}"#),
+            Some(FormEvent::Field { password: true, moved: true, .. })
+        ));
+        // Nonsense, and a field event with no rect, are both ignored.
+        assert!(parse_event("not json").is_none());
+        assert!(parse_event(r#"{"t":"focus","kind":"user"}"#).is_none());
+        assert!(parse_event(r#"{"t":"evil"}"#).is_none());
+    }
+}
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index 8f59818..489f6d3 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -142,6 +142,10 @@ pub struct WebKitHost {
     download_started: Rc<Cell<bool>>,
     /// A page asked something and is blocked until we answer.
     prompts: Rc<RefCell<Prompts>>,
+    /// The injected account watcher, kept so the setting can take it away
+    /// again. `None` when account autocomplete is off, which is also when no
+    /// page carries the script at all.
+    watcher: Option<*mut WebKitUserScript>,
     /// Retained only so tests can assert on rendered output; the registry
     /// owns the copy that actually gets drawn.
     last_frame: Option<(Vec<u8>, u32, u32)>,
@@ -291,13 +295,145 @@ impl WebKitHost {
                 prompts,
                 last_frame: None,
                 ucm: webkit_user_content_manager_new(),
+                watcher: None,
                 spare: None,
             };
+            // The account watcher's channel, in its own script world. Both
+            // halves are registered here, once, on the shared content
+            // manager every tab is built against.
+            host.register_account_channel();
             host.open_tab(url);
             host
         }
     }
 
+    /// Listen for the account watcher's messages, in its private world.
+    ///
+    /// The world is the security boundary: page script cannot post on a
+    /// channel registered for another world, so a message arriving here came
+    /// from the injected watcher and not from the page pretending to be one.
+    fn register_account_channel(&self) {
+        use super::formwatch;
+        unsafe {
+            let name = cstr(formwatch::CHANNEL);
+            let world = cstr(formwatch::WORLD);
+            if webkit_user_content_manager_register_script_message_handler(
+                self.ucm,
+                name.as_ptr(),
+                world.as_ptr(),
+            ) == 0
+            {
+                log::warn!("could not register the account message channel");
+                return;
+            }
+            let signal = cstr(&format!("script-message-received::{}", formwatch::CHANNEL));
+            g_signal_connect_data(
+                self.ucm as *mut _,
+                signal.as_ptr(),
+                Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(
+                    on_account_message as *const () as usize,
+                )),
+                Rc::into_raw(self.prompts.clone()) as gpointer,
+                Some(drop_prompts_ref),
+                0,
+            );
+        }
+    }
+
+    /// Install or remove the login-field watcher — the whole page-side
+    /// footprint of the feature, so a browser with accounts turned off
+    /// injects nothing at all.
+    pub fn set_accounts_enabled(&mut self, on: bool) {
+        use super::formwatch;
+        unsafe {
+            match (on, self.watcher.take()) {
+                (true, None) => {
+                    let source = cstr(formwatch::WATCH_JS);
+                    let world = cstr(formwatch::WORLD);
+                    // Top frame only, and at document start so the listeners
+                    // are in place before a login page's own script runs.
+                    let script = webkit_user_script_new_for_world(
+                        source.as_ptr(),
+                        WebKitUserContentInjectedFrames::WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
+                        WebKitUserScriptInjectionTime::WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
+                        world.as_ptr(),
+                        std::ptr::null(),
+                        std::ptr::null(),
+                    );
+                    webkit_user_content_manager_add_script(self.ucm, script);
+                    self.watcher = Some(script);
+                }
+                (false, Some(script)) => {
+                    webkit_user_content_manager_remove_script(self.ucm, script);
+                    webkit_user_script_unref(script);
+                }
+                // Already in the asked-for state; `take` above is why the
+                // enabled case has to put its handle back.
+                (true, Some(script)) => self.watcher = Some(script),
+                (false, None) => {}
+            }
+        }
+    }
+
+    /// Nudge the watcher into re-reporting the focused login field, for when
+    /// the chrome has something to offer that it did not have a moment ago.
+    pub fn request_form_state(&self) {
+        if self.watcher.is_none() {
+            return;
+        }
+        unsafe {
+            let source = cstr(super::formwatch::RESCAN_JS);
+            let world = cstr(super::formwatch::WORLD);
+            webkit_web_view_evaluate_javascript(
+                self.active_tab().webview,
+                source.as_ptr(),
+                -1,
+                world.as_ptr(),
+                std::ptr::null(),
+                std::ptr::null_mut(),
+                None,
+                std::ptr::null_mut(),
+            );
+        }
+    }
+
+    /// The next login-field event the watcher reported.
+    pub fn take_form_event(&self) -> Option<super::formwatch::FormEvent> {
+        self.prompts.borrow_mut().form_events.pop_front()
+    }
+
+    /// Drop anything the watcher reported for a page that is going away, so a
+    /// stale focus cannot open a list over the next one.
+    pub fn clear_form_events(&self) {
+        self.prompts.borrow_mut().form_events.clear();
+    }
+
+    /// Put a picked account into the page's login fields.
+    ///
+    /// Runs in the watcher's world, where the elements it recorded live and
+    /// where the page cannot have replaced the setter being used. The script
+    /// carries the credential, so it is built here and dropped immediately;
+    /// it is never logged, and `source_uri` is left null so it cannot show up
+    /// named in a devtools listing either.
+    pub fn fill_credentials(&self, username: &str, password: &str) {
+        use super::formwatch;
+        let script = formwatch::fill_js(username, password);
+        unsafe {
+            let source = cstr(&script);
+            let world = cstr(formwatch::WORLD);
+            webkit_web_view_evaluate_javascript(
+                self.active_tab().webview,
+                source.as_ptr(),
+                -1,
+                world.as_ptr(),
+                std::ptr::null(),
+                std::ptr::null_mut(),
+                None,
+                std::ptr::null_mut(),
+            );
+        }
+    }
+
     fn build_webview(&self, url: &Url, state: &Rc<TabState>) -> (*mut WebKitWebView, *mut WPEView) {
         unsafe {
             let (p_display, p_ucm, p_session) = (
@@ -1195,6 +1331,10 @@ pub(super) struct Prompts {
     auth: Option<(*mut WebKitAuthenticationRequest, PendingAuth)>,
     /// The page asked for a context menu; the chrome draws its own.
     context_menu: Option<ContextMenuInfo>,
+    /// Login fields the account watcher reported, oldest first. A queue and
+    /// not a slot: a blur followed by a focus is two different states, and
+    /// collapsing them would leave the list open over the wrong field.
+    form_events: std::collections::VecDeque<crate::wpe::formwatch::FormEvent>,
 }
 
 /// What was under the pointer when the page asked for a context menu, read
@@ -1245,6 +1385,29 @@ unsafe fn connect_raw(
     );
 }
 
+/// A message from the account watcher. Anything that does not parse as one of
+/// its events is dropped without comment — this is a channel the chrome acts
+/// on, so it accepts only what it recognizes.
+unsafe extern "C" fn on_account_message(
+    _ucm: *mut WebKitUserContentManager,
+    value: *mut JSCValue,
+    data: gpointer,
+) {
+    let prompts = &*(data as *const RefCell<Prompts>);
+    let raw = jsc_value_to_string(value);
+    let Some(json) = from_cstr(raw) else { return };
+    g_free(raw as *mut _);
+    if let Some(event) = super::formwatch::parse_event(&json) {
+        let mut p = prompts.borrow_mut();
+        // A page that spins on scroll must not grow this without bound; the
+        // chrome only ever cares about the last few.
+        if p.form_events.len() > 8 {
+            p.form_events.pop_front();
+        }
+        p.form_events.push_back(event);
+    }
+}
+
 unsafe extern "C" fn drop_prompts_ref(data: gpointer, _c: *mut GClosure) {
     drop(Rc::from_raw(data as *const RefCell<Prompts>));
 }
diff --git a/src/wpe/mod.rs b/src/wpe/mod.rs
index 59405be..47645c7 100644
--- a/src/wpe/mod.rs
+++ b/src/wpe/mod.rs
@@ -13,7 +13,11 @@ mod subclass;
 mod input;
 mod glib_source;
 mod host;
+/// The page half of account autocomplete: the watcher script, the fill
+/// script, and the events they exchange with the chrome.
+pub mod formwatch;
 
 // Not consumed yet — main.rs still drives ServoHost.
 #[allow(unused_imports)]
+pub use formwatch::FormEvent;
 pub use host::{ContextMenuInfo, PendingAuth, PendingDialog, Tab, WebKitHost};