Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 6 additions & 1 deletion KNOWN-LIMITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<form>` itself, from its `submit` event or a click on its submit button, so a sign-in sent by script without a `<form>`, 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
Expand Down
25 changes: 24 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions SUBCLAVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<subclave-inline>`, 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/`)

Expand Down
Loading
Loading