diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 380ce6c..6898fe7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -113,7 +113,8 @@ App data dir of the bundle id (`dev.rendy.subclave`; `pnpm tauri:dev` uses 3. **Lock**: manual, from the lock button, the status bar or the `vault.lock` shortcut (`useVaultStore.lock`, `vault_lock`, `lock_inner`); idle, when `spawn_vault_tick_thread` calls `VaultState::access` every 5 s past the deadline that `vault_touch` extends on activity (`drain_auto_lock`); minimize, in `on_main_window_event` when `lock_on_minimize` is set; tray, from the Lock item in `tray.rs`. Every path calls `Unlocked::wipe` and drops the key, then `emit_locked` closes the sync session (`session_closed`), drops every browser connection (`close_all`) and emits `subclave:vault-locked` with the `LockReason`. 4. **Sync pull and push**: `startSync` builds the scheduler (`createPolicy`). It pulls on unlock and on window focus (`onFocus`, at most once per `FOCUS_INTERVAL_MS`), and pushes `PUSH_DEBOUNCE_MS` after the first change of a burst (`markDirty`, on any `subclave:vault-changed` whose origin is not `sync`). Passes run one at a time (`serialize`) and re-read the switch each time (`readConfig`). Pull: `sync_pull` snapshots `locals_from_payload` and the etags, releases the lock, runs `pull` (list, fetch the objects whose etag moved, `open_envelope`, `merge`, prune this device's expired tombstones), lands the report with `apply_pull` (`merge_into_payload` per record) and `commit`s, then emits origin `sync`. Push: `sync_push` takes `take_dirty_envelopes`, `push` seals each one (`seal_envelope`) and writes it conditionally on its etag where the backend can, re-merging once on a conflict (`retry_merged`), then `finish_push` clears the marks that landed and `commit`s. 5. **Browser fill**: every request goes service worker, `createNativeTransport`, `subclave-proxy` (`read_frame`, `write_frame`), then the app's `accept_loop`, `handle_conn` and `dispatch` (status, locked check, `hello` and `auth` handshake, then the action). - - Inline: `startInline` and `createInline` draw an icon per field found by `scan`. Opening the picker sends `inline-logins`; the service worker answers it in `handleInline` for the sender's own URL at `scope: "host"` (`getState`, `get-logins`, `get_logins`, which returns no password). A pick passes `guard.check` (`createGuard`), then `inline-fill` runs `fillEntry` with `via: "inline"`: `get-credential` (`get_credential` re-runs `matching::matches` and requires `same_host`), then `subclave:fill` to the content script, where `fillTarget` refuses another origin and `fillCredential` fills. + - Inline: `startInline` and `createInline` draw an icon per field found by `scan`. Opening the picker sends `inline-logins`; the service worker answers it in `handleInline` at `scope: "host"`, and fills and the picker use the sender's own URL (`getState`, `get-logins`, `get_logins`, which returns no password). A pick passes `guard.check` (`createGuard`), then `inline-fill` runs `fillEntry` with `via: "inline"`: `get-credential` (`get_credential` re-runs `matching::matches` and requires `same_host`), then `subclave:fill` to the content script, where `fillTarget` refuses another origin and `fillCredential` fills. + - Save prompt: `pass` wires the form of each login field (`wireForm`). A trusted submit, or a click on its submit button, that follows a user gesture reads the pair with `submittedLogin` and sends `inline-submitted`; the service worker keeps it per tab in `chrome.storage.session` with the sender's URL and a fresh id. Each page load in that tab, and the submitting page itself once the password has left its form, sends `inline-pending-save`, which runs `check-login` with the asking page's URL as `pageUrl` (`check_login`: `other-site` off the submit's site by the Domain rule, else the submitted host's exact-host entries, no password returned); a new or changed login opens the picker as a prompt in the viewport's corner (`promptSave`). Add or Update pass `guard.check`, then `inline-save` carries the pair's id and sends `save-login` with `via: "inline"` (`save_login` requires `same_host` for an update, and the old password goes to history); Cancel sends `inline-save-cancel`, which drops the pair. A new submit closes a prompt that is showing. - Popup: `PopupApp` asks `get-state` for the active tab at `scope: "all"`, and a pick sends `fill-entry`, which runs `fillEntry` with `via: "popup"` (`ensureContentScript` injects `content.js` when it is missing). - Command: `fill-login` runs `runFillCommand`: `get-logins` for the active tab, then one match fills with `via: "command"`, and several open the popup where `chrome.action.openPopup` exists, or fill the most recently used one. - A released credential stamps `lastUsedAt` and emits `subclave:vault-changed` with origin `browser`. diff --git a/CHANGELOG.md b/CHANGELOG.md index e3b87e8..787dec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,5 +13,5 @@ The latest release only. Every earlier version: - Entries and nested groups with tags, favourites, expiry dates, search, Trash and per-entry version history. - Copy with clipboard auto-clear, TOTP codes, and a password generator with a strength meter. - End-to-end encrypted sync to your own S3-compatible bucket or WebDAV share, with a merge that keeps both sides of a conflict. -- Browser extension for Chromium browsers and Firefox: pairing, an icon and picker inside login fields, popup fill, the fill command, and "Generate for this site". +- Browser extension for Chromium browsers and Firefox: pairing, an icon and picker inside login fields, popup fill, the fill command, "Generate for this site", and a save prompt that adds or updates a login typed on a page. - CSV import from KeePassXC, Bitwarden, Chrome and Firefox, CSV export, and encrypted `.subclave-backup` files. diff --git a/KNOWN-LIMITS.md b/KNOWN-LIMITS.md index 6c36f6f..e9b9ba6 100644 --- a/KNOWN-LIMITS.md +++ b/KNOWN-LIMITS.md @@ -28,10 +28,15 @@ lives, and what would change it. - **No fill in cross-origin iframes**: the content script runs in the top frame only and reaches same-origin iframes through `contentDocument`, which a cross-origin frame does not expose, so a login form inside one is neither detected nor filled. `extension/src/content/fill.ts` (`rootsOf`). Changes when: the content script runs in every frame with a per-frame origin check. - **The inline guards are JavaScript against JavaScript**: the six clickjacking guards run in the same page as the code they guard against, and Firefox has no `IntersectionObserver` v2, so the visibility guard (guard 6) runs on Chromium only. `extension/src/content/guard.ts` (`createGuard`). Changes when: browsers give extension UI a visibility guarantee. -- **No inline UI without the Popover API**: Firefox 115 to 124 (and Chrome before 114) get no icon in login fields; the popup and the fill command still work. `extension/src/content/inline.ts` (`startInline`). Changes when: the minimum Firefox version reaches 125. +- **No inline UI without the Popover API**: Firefox 115 to 124 (and Chrome before 114) get no icon in login fields and no save prompt; the popup and the fill command still work. The save prompt is part of the inline UI, so "Show in login fields" off turns it off too. `extension/src/content/inline.ts` (`startInline`). Changes when: the minimum Firefox version reaches 125. - **Very late login forms get no icon**: a page with no login field 5 seconds after it has loaded stops watching for good, so a single-page app that shows its login form later (or navigates to it without a page load) gets no icon. The popup and the fill command still fill it. `extension/src/content/inline.ts` (`SETTLE_AFTER_LOAD_MS`, `settle` in `createInline`). Changes when: detection keeps a cheap watch running on pages without a field. - **An unhinted first login step gets no icon**: a text field on its own (the first step of a two-step login) gets an icon only when it says `autocomplete="username"` or `"email"`, which keeps icons out of search boxes and newsletter fields. A first step without the hint (Yahoo and Zoom when last checked) gets no icon; the popup and the fill command still fill it. `extension/src/content/detect.ts` (`scan`). Changes when: detection gets a name or label heuristic that still leaves search and newsletter fields alone. - **"Show in login fields" applies from the next page load**: the content script reads the setting once when the page loads, so open tabs keep their current state. `extension/src/content/index.ts` (`askSettings`). Changes when: the service worker tells open tabs about the change. +- **No save prompt for password-only forms**: a submitted form is offered for saving only when it holds a username next to the filled password, so the second step of a two-step login, and a change-password form without a username field, get no prompt. An update from such a form would blank the stored username. `extension/src/content/inline.ts` (`submittedLogin`). Changes when: the first step's username is carried to the second. +- **No save prompt for formless or scripted sign-ins**: the submit is caught on the `
` itself, from its `submit` event or a click on its submit button, so a sign-in sent by script without a ``, from a `type="button"` button or a non-button element, or by page script without a user gesture, is never offered. `extension/src/content/inline.ts` (`wireForm`, `SUBMIT_BUTTON`). Changes when: detection gets a formless submit rule. +- **A refused sign-in still waits to be saved**: a refused sign-in, or a click on a submit-type button that signs nothing in (a show-password toggle without `type="button"`), still leaves a pending sign-in. The page shows no prompt while the password is still in its form, but the tab's next page load offers it. `extension/src/content/inline.ts` (`wireForm`, `submitted`). Changes when: a sign-in can be told apart from a refused one. +- **A Domain-rule login still prompts on other hosts**: a login stored only under a Domain rule still gets a save prompt on another host of that domain, because the check, like every inline request, sees exact-host entries only. `src-tauri/src/modules/browser/actions.rs` (`check_login`). Changes when: wider matches get a browser-owned save path. +- **A typed password waits in session storage**: a pending sign-in is dropped after 3 page loads in its tab (loads on another site count, though the prompt shows only on the submit's site), and after 5 minutes once its tab loads another page. Until then, or until the tab closes or the browser exits, the typed password stays in `chrome.storage.session`, which the browser keeps in memory. `extension/src/background.ts` (`PENDING_SAVE_TTL_MS`, `PENDING_SAVE_PAGE_LOADS`). Changes when: the service worker can expire it on a timer that survives a restart. - **Sandboxed browsers**: for Snap and Flatpak installs the manifests are also written into the sandboxed profile path and Settings > Browser marks them, but native messaging from Snap Firefox is not tested yet. `src-tauri/src/modules/browser/manifests.rs` (`BrowserSlot`, `sandboxed`). Changes when: a run on Snap Firefox confirms it, or shows what it needs. ## Import and backup diff --git a/SECURITY.md b/SECURITY.md index 5ea4250..7d28951 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,7 +22,30 @@ Until `1.0.0`, only the latest minor gets security fixes. - **Web pages.** A malicious or compromised site must not trigger a fill, get a credential for another site, or read the vault. - The content script runs in the top frame only. It never reads extension - storage, and it holds a credential only after a guarded pick. + storage. It holds a credential only after a guarded pick, and reads the + login the user typed only on a submit that is trusted and follows a user + gesture. + - The service worker keeps that typed pair in `chrome.storage.session`, + for its tab only, until the save prompt is answered, the tab closes, the + tab's third page load passes, or the first page load after 5 minutes. + The save URL is the browser-stamped `MessageSender.url` of the submit. + The prompt shows only on a page of the submit's site: Rust compares the + asking page's `MessageSender.url` with the submit's URL by the Domain rule + (`check_login`). `check-login` sees the submitted host's exact-host + entries only and never returns a password. Add and Update pass the six + guards and save only the pair the prompt shows: the pair's id goes back + with the click, and a new submit closes a prompt that is showing. + Residual risk: + - After a user gesture, a page can plant values in its own form and + submit them. It can then learn whether a planted pair matches an entry + for its own exact host (whether the prompt shows), and offer the user an + Update the user must still click; the overwritten password stays in the + entry's history. + - When the tab moves to another host of the same site (`login.example.com` + to `www.example.com`), that host's page shows the prompt with the + submitted host's entry titles and usernames, inside the closed shadow + root. Its Add and Update can only save the pair the user typed to the + submitted host. - The inline picker offers only exact-host matches. The scheme and port rules still apply. Wider matches (subdomains, parent domain) are reachable only from diff --git a/SUBCLAVE.md b/SUBCLAVE.md index c2ea278..a3f591c 100644 --- a/SUBCLAVE.md +++ b/SUBCLAVE.md @@ -91,14 +91,16 @@ Agent memory and contributor reference for Subclave. Build and PR rules: verify script pins the agreement. - The service worker routes by sender (`senderKind` in `src/background.ts`): popup requests only from extension pages, inline requests only from the - top-frame content script, answered for the sender's own tab URL at - `scope: "host"` and `via: "inline"`. + top-frame content script, answered for the sender's own tab URL or, for the + save prompt, the URL the same tab's submit was stamped with (shown only on a + page of that site, which Rust decides), at `scope: "host"` and + `via: "inline"`. - The inline UI (`src/content/inline.ts`) lives in one closed shadow root under ``, built with `createElement`/`textContent` (no - `innerHTML`). Every fill, generate or update pick passes the six guards in - `src/content/guard.ts` first; `test/guard.spec.ts` has one attack fixture - per guard. `scripts/build.mjs` fails a build whose `content.js` is over - 30 KB. + `innerHTML`). Every fill, generate or update pick, and every save-prompt Add + or Update, passes the six guards in `src/content/guard.ts` first; + `test/guard.spec.ts` has one attack fixture per guard. `scripts/build.mjs` + fails a build whose `content.js` is over 30 KB. ### Import and backup (`src-tauri/src/modules/import/`, `src-tauri/src/modules/backup.rs`, `src/modules/backup/`) diff --git a/extension/src/background.ts b/extension/src/background.ts index d5f8700..0322c71 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -13,12 +13,14 @@ import type { InlineRequest, PairingCodeEvent, PopupRequest, + SavePrompt, SwResponse, SwState, } from "./lib/messages"; import { PAIRING_CODE_EVENT } from "./lib/messages"; import { PROTOCOL_VERSION } from "./lib/protocol"; import type { + CheckLoginResult, CredentialResult, GenerateResult, GetLoginsResult, @@ -35,6 +37,22 @@ import type { Transport } from "./lib/transport"; const CREDENTIALS_KEY = "subclave.credentials"; const SHOW_IN_LOGIN_FIELDS_KEY = "subclave.showInLoginFields"; +/** One pending sign-in per tab, in `chrome.storage.session` so it survives a + * service worker restart between the submit and the next page load. */ +const PENDING_SAVE_PREFIX = "subclave.pendingSave."; +/** How long a submitted login waits for its prompt to be answered. */ +const PENDING_SAVE_TTL_MS = 5 * 60_000; +/** How many page loads in its tab may show the prompt. */ +const PENDING_SAVE_PAGE_LOADS = 3; + +type PendingSave = { + id: string; + url: string; + username: string; + password: string; + at: number; + loads: number; +}; type Credentials = { clientId: string; secret: string }; type CallResult = { ok: true; result: T } | { ok: false; code: NmErrorCode; message: string }; @@ -59,6 +77,10 @@ const INLINE_REQUEST_TYPES: Record = { "inline-generate": true, "inline-pair": true, "inline-focus-app": true, + "inline-submitted": true, + "inline-pending-save": true, + "inline-save": true, + "inline-save-cancel": true, }; export type BackgroundOptions = { @@ -452,9 +474,112 @@ export function startBackground(options: BackgroundOptions = {}): void { return { type: "focus", ok: response.ok }; }; - /** Every inline request is answered for the sender's own tab and URL, at - * `scope: "host"` and `via: "inline"`, so the picker can never reach another - * page's logins or the popup's wider match. */ + // ponytail: one chain for all tabs; per-tab chains if it ever matters + let pendingChain: Promise = Promise.resolve(); + /** Runs `step` after every earlier one. Storage has no transactions, so each + * read-modify-write of a pending pair is one step; native calls stay out. */ + const withPending = (step: () => Promise): Promise => { + const result = pendingChain.then(step); + pendingChain = result.catch(() => undefined); + return result; + }; + + /** Call inside a `withPending` step. */ + const readPending = async (tabId: number): Promise => { + const key = PENDING_SAVE_PREFIX + tabId; + const stored = await chrome.storage.session.get(key); + // `inline-submitted` is the only writer of this key. + return (stored[key] as PendingSave | undefined) ?? null; + }; + + /** Drops the tab's pair only if it is still the one named `id`, so a slow + * answer never deletes a newer sign-in. */ + const dropPending = (tabId: number, id: string): Promise => + withPending(async () => { + if ((await readPending(tabId))?.id === id) { + await chrome.storage.session.remove(PENDING_SAVE_PREFIX + tabId); + } + }); + + /** The prompt for the tab's pending sign-in on the page at `url`, counted as + * one of its page loads. Nothing pending never connects to the app; a stored + * pair is dropped, and a failed check (locked, not running, unpaired) or a + * page on another site keeps it for later. Expiry is checked here only, so a + * prompt already showing can still be answered. */ + const pendingPrompt = async (tabId: number, url: string): Promise => { + const none: SwResponse = { type: "save-prompt", prompt: null }; + const key = PENDING_SAVE_PREFIX + tabId; + const pending = await withPending(async () => { + const stored = await readPending(tabId); + if (!stored) return null; + if (stored.loads >= PENDING_SAVE_PAGE_LOADS || Date.now() - stored.at > PENDING_SAVE_TTL_MS) { + await chrome.storage.session.remove(key); + return null; + } + await chrome.storage.session.set({ [key]: { ...stored, loads: stored.loads + 1 } }); + return stored; + }); + if (!pending) return none; + const checked = await call("check-login", { + url: pending.url, + pageUrl: url, + username: pending.username, + password: pending.password, + }); + if (!checked.ok || checked.result.state === "other-site") return none; + if (checked.result.state === "unchanged") { + await dropPending(tabId, pending.id); + return none; + } + let host = pending.url; + try { + host = new URL(pending.url).hostname; + } catch { + // Keep the raw URL as the label. + } + const prompt: SavePrompt = { + id: pending.id, + host, + username: pending.username, + entries: checked.result.entries, + }; + return { type: "save-prompt", prompt }; + }; + + /** Adds the pending sign-in `id` (`entryId` null) or updates that entry with + * it; a newer sign-in in the tab answers `no-match`. */ + const savePending = async ( + tabId: number, + id: string, + entryId: string | null, + ): Promise => { + const pending = await withPending(() => readPending(tabId)); + if (pending?.id !== id) { + return { + type: "save", + ok: false, + code: "no-match", + message: "This sign-in is no longer waiting to be saved.", + }; + } + const saved = await call("save-login", { + url: pending.url, + username: pending.username, + password: pending.password, + ...(entryId ? { entryId } : {}), + via: "inline", + }); + if (!saved.ok) return { type: "save", ok: false, code: saved.code, message: saved.message }; + await dropPending(tabId, id); + return { type: "save", ok: true }; + }; + + /** Every inline request is answered for the sender's own tab at + * `scope: "host"` and `via: "inline"`. Fills and the picker use the sender's + * URL, so they never reach another host's logins or the popup's wider match. + * The save prompt saves to the browser-stamped URL of the tab's own submitted + * sign-in, and shows only on a page of that sign-in's site (`check-login` + * decides, by the vault's Domain rule). */ const handleInline = async ( message: InlineRequest, tabId: number, @@ -474,6 +599,29 @@ export function startBackground(options: BackgroundOptions = {}): void { return pair((event) => sendToTab(tabId, event)); case "inline-focus-app": return focusApp(); + case "inline-submitted": { + // `url` is the browser-stamped `sender.url`; the content script never + // names one. A new submit replaces the tab's old pair. + const pending: PendingSave = { + id: crypto.randomUUID(), + url, + username: message.username, + password: message.password, + at: Date.now(), + loads: 0, + }; + await withPending(() => + chrome.storage.session.set({ [PENDING_SAVE_PREFIX + tabId]: pending }), + ); + return { type: "submitted" }; + } + case "inline-pending-save": + return pendingPrompt(tabId, url); + case "inline-save": + return savePending(tabId, message.id, message.entryId); + case "inline-save-cancel": + await dropPending(tabId, message.id); + return { type: "save", ok: true }; } }; @@ -485,6 +633,10 @@ export function startBackground(options: BackgroundOptions = {}): void { if (command === "fill-login") void runFillCommand(); }); + chrome.tabs.onRemoved.addListener((tabId) => { + void chrome.storage.session.remove(PENDING_SAVE_PREFIX + tabId); + }); + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { const type = runtimeMessageType(message); if (type === null) return false; diff --git a/extension/src/content/fill.ts b/extension/src/content/fill.ts index 6aea666..a1f9c82 100644 --- a/extension/src/content/fill.ts +++ b/extension/src/content/fill.ts @@ -94,8 +94,12 @@ export function scopeOf(input: HTMLInputElement): Node { } /** `autocomplete` is a token list (`"username webauthn"`), not one value. */ +export function autocompleteTokens(input: HTMLInputElement): string[] { + return (input.getAttribute("autocomplete") ?? "").toLowerCase().split(/\s+/); +} + export function hasAutocompleteToken(input: HTMLInputElement, token: string): boolean { - return (input.getAttribute("autocomplete") ?? "").toLowerCase().split(/\s+/).includes(token); + return autocompleteTokens(input).includes(token); } /** The first text-like input before the password field among `inputs`, which diff --git a/extension/src/content/index.ts b/extension/src/content/index.ts index 9cc14a0..04f3d7e 100644 --- a/extension/src/content/index.ts +++ b/extension/src/content/index.ts @@ -62,7 +62,8 @@ if (!scope.subclaveContent) { }); // A page load reads one setting from storage and draws icons; no native - // connection opens until the user opens the picker. + // connection opens until the user opens the picker, unless the tab has a + // sign-in waiting for the save prompt. const askSettings = (): void => { sendToBackground({ type: "inline-settings" }).then( (response) => { diff --git a/extension/src/content/inline.ts b/extension/src/content/inline.ts index 3e4e73f..23d516d 100644 --- a/extension/src/content/inline.ts +++ b/extension/src/content/inline.ts @@ -1,7 +1,9 @@ // The inline icon and picker: an icon in each detected login field, and a -// top-layer picker of the page's exact-host logins. A fill, generate or update -// row runs only after every guard in `guard.ts` passes. Matching, credential -// release and every write stay in the service worker and Rust. +// top-layer picker of the page's exact-host logins. After a trusted sign-in +// submit, the same picker shows the save prompt (Add, Update, Cancel) in the +// viewport's corner. A fill, generate, update, Add or Update row runs only +// after every guard in `guard.ts` passes. Matching, credential release and +// every write stay in the service worker and Rust. // // Everything is built with `createElement`/`textContent` inside one closed // shadow root. Placement goes through `element.style` (CSSOM, which a page CSP @@ -9,9 +11,19 @@ // the shadow `