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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ 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` 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.
- 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. When `get_logins` counts `otherMatches`, the picker's last row sends `inline-open-popup`, which runs `openPopup` (`chrome.action.openPopup`) and nothing else. Where that is missing or rejects, the answer carries the `fill-login` shortcut from `chrome.commands.getAll`, and the row turns into text naming the Extensions menu and the shortcut for the rest of the page (`popupFallback`).
- 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.
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ The latest release only. Every earlier version:
[GitHub Releases](https://github.com/rendyuwu/subclave/releases). Versions:
[SemVer](https://semver.org/); before `1.0` a minor bump may break things.

## [Unreleased]

### Changed

- The inline picker's "more logins" footer is now a row that opens the Subclave popup, which lists the logins saved for other hosts of the domain. Where the browser cannot open the popup from the page, the footer names the Extensions menu and the fill shortcut instead.

## [0.1.1] - 06-10-2026

No change to the desktop app or to the extension's code.
Expand Down
1 change: 1 addition & 0 deletions KNOWN-LIMITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ 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 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.
- **The more-logins row needs action.openPopup**: Chrome 118 to 126 open the popup only for a policy-installed extension and older Chrome not at all, and Firefox before 149 opens it only inside a user action handler (whether a click relayed from the page counts there is not tested). On those browsers the row's first click on a page turns it into text pointing at the Extensions menu and the fill shortcut for the rest of that page, and the next page shows the row again. `extension/src/background.ts` (`openPopup`), `extension/src/content/inline.ts` (`popupFallback` in `createInline`). Changes when: the minimum versions reach Chrome 127 and Firefox 149, or the service worker learns before the first click that the popup cannot open.
- **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.
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,26 @@ Pair from the Extensions menu as in step 4 above. If the popup offers Pair
with Subclave again after a restart, pair again and revoke the old entry under
Settings > Browser.

## Fill a login

Click a login field, or the key icon in it, to list the logins saved for that
page's exact host, and pick one.

Logins for another host of the same domain (an entry for `github.com` opened
from `gist.github.com`) are not listed. The picker's last row,
`N more logins on github.com`, opens the Subclave popup, which lists every
match. Where the browser cannot open the popup from the page, the row turns
into text; then open Subclave from the browser's Extensions menu (or the
toolbar once pinned) and pick the login there.

`Ctrl+Shift+L` (`Cmd+Shift+L` on macOS) fills the current page without the
picker: one match fills right away; several open the popup, or fill the most
recently used login where the browser cannot open it.

The browser leaves the shortcut unset when another extension already holds
it. Set it at `chrome://extensions/shortcuts`, or in Firefox under
`about:addons`, gear menu, Manage Extension Shortcuts.

## Build from source

Needs Rust stable, Node 20.19+ with pnpm, and
Expand Down
6 changes: 6 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ Until `1.0.0`, only the latest minor gets security fixes.
`example.com` login, which was the main vector in Marek Tóth's DEF CON 33
research
([DOM-based Extension Clickjacking](https://marektoth.com/blog/dom-based-extension-clickjacking)).
When other hosts of the domain hold matches, the picker's last row counts
them and only opens the popup (`chrome.action.openPopup`). It fills
nothing and releases nothing, so it needs a trusted click but not the six
guards. A clickjacked click on it can only open the popup, where a fill
still takes a click in browser-owned UI. Where the popup cannot be opened,
the row turns into text.
- Inline fill needs a trusted user action and passes the six guards in
`extension/src/content/guard.ts`. If any guard fails, nothing is filled.
- The page never supplies the URL. It comes from the browser:
Expand Down
38 changes: 25 additions & 13 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ const INLINE_REQUEST_TYPES: Record<string, true> = {
"inline-pending-save": true,
"inline-save": true,
"inline-save-cancel": true,
"inline-open-popup": true,
};

export type BackgroundOptions = {
Expand Down Expand Up @@ -413,18 +414,7 @@ export function startBackground(options: BackgroundOptions = {}): void {
return { type: "fill", ok: true, filled: 0 };
}
const entries = logins.result.entries;
// `chrome.action.openPopup` is Chrome 127+; Firefox and older Chrome have
// no such member, so it is read through a named interface.
const action: { openPopup?: () => Promise<void> } = chrome.action;
if (entries.length > 1 && action.openPopup) {
try {
await action.openPopup.call(chrome.action);
return { type: "fill", ok: true, filled: 0 };
} catch {
// Firefox and older Chrome reject openPopup; fall through to the
// newest-match fallback below.
}
}
if (entries.length > 1 && (await openPopup())) return { type: "fill", ok: true, filled: 0 };
let pick = entries[0];
if (entries.length > 1) {
for (const entry of entries) {
Expand Down Expand Up @@ -579,7 +569,8 @@ export function startBackground(options: BackgroundOptions = {}): void {
* 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). */
* decides, by the vault's Domain rule). `inline-open-popup` uses neither the
* tab nor the URL; it only opens the popup. */
const handleInline = async (
message: InlineRequest,
tabId: number,
Expand Down Expand Up @@ -622,6 +613,12 @@ export function startBackground(options: BackgroundOptions = {}): void {
case "inline-save-cancel":
await dropPending(tabId, message.id);
return { type: "save", ok: true };
case "inline-open-popup": {
if (await openPopup()) return { type: "popup", ok: true };
const commands = await chrome.commands.getAll();
const shortcut = commands.find((command) => command.name === "fill-login")?.shortcut;
return { type: "popup", ok: false, shortcut: shortcut ?? "" };
}
}
};

Expand Down Expand Up @@ -683,6 +680,21 @@ function runtimeMessageType(message: unknown): string | null {
return typeof type === "string" ? type : null;
}

/** Opens the toolbar popup in the focused window. `chrome.action.openPopup`
* is open to every extension from Chrome 127 and needs no user action from
* Firefox 149; older versions lack it or reject the call, which reads as
* `false`. Read through a named interface because the member can be missing. */
async function openPopup(): Promise<boolean> {
const action: { openPopup?: () => Promise<void> } = chrome.action;
if (!action.openPopup) return false;
try {
await action.openPopup.call(chrome.action);
return true;
} catch {
return false;
}
}

/** Chromium's UA always says "Chrome", so the other products are checked first. */
function browserName(): string {
const ua = navigator.userAgent;
Expand Down
39 changes: 34 additions & 5 deletions extension/src/content/inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,13 @@ function createInline(): Inline {
* to update. The prompt is the picker open with no anchor. */
let saving: { prompt: SavePrompt; listing: boolean } | null = null;
let submitSeq = 0;
/** The fill command's shortcut ("" when unbound) once the service worker
* could not open the popup for this page; the more-logins footer is text
* from then on. */
// ponytail: remembered per page, so a browser that can never open the popup
// shows the row until its first click on each page; have the service worker
// learn it up front if that matters.
let popupFallback: string | null = null;

const isOpen = (): boolean => picker.matches(":popover-open");

Expand Down Expand Up @@ -456,6 +463,21 @@ function createInline(): Inline {
}
};

/** Opens the toolbar popup, which lists the wider matches in browser-owned
* UI. Fills and releases nothing, so its row needs a trusted event only. */
const openPopup = async (state: Extract<SwState, { state: "ready" }>): Promise<void> => {
const mine = seq;
const response = await ask({ type: "inline-open-popup" });
if (!response || mine !== seq) return;
if (response.type === "popup" && response.ok) close();
else if (response.type === "popup") {
popupFallback = response.shortcut;
showReady(state);
} else if (response.type === "error") {
errorView(response.code, response.message);
}
};

const showReady = (state: Extract<SwState, { state: "ready" }>): void => {
const next: Row[] = [];
if (anchor && fields.get(anchor) === true) {
Expand Down Expand Up @@ -484,11 +506,18 @@ function createInline(): Inline {
if (state.entries.length === 0) lines.push(note(`No logins for ${state.host}.`));
const more = state.otherMatches;
if (more > 0) {
lines.push(
note(
`${more} more login${more === 1 ? "" : "s"} on ${state.domain}: use the Subclave toolbar button`,
),
);
const count = `${more} more login${more === 1 ? "" : "s"} on ${state.domain}`;
if (popupFallback === null) {
next.push({
label: count,
detail: "Open in Subclave popup",
guarded: false,
run: () => openPopup(state),
});
} else {
const press = popupFallback ? `, or press ${popupFallback}` : "";
lines.push(note(`${count}: open Subclave from the browser's Extensions menu${press}`));
}
}
render(next, lines);
};
Expand Down
9 changes: 7 additions & 2 deletions extension/src/lib/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export type PopupRequest =
* `inline-save-cancel`) act on the sign-in that same tab submitted: it saves
* to the `sender.url` stamped on its `inline-submitted`, and shows only on a
* page of that URL's site (`check-login` decides). `id` names the pending
* sign-in the prompt shows, so a newer one is never written in its place. */
* sign-in the prompt shows, so a newer one is never written in its place.
* `inline-open-popup` only opens the toolbar popup, which lists the wider
* matches for the active tab in browser-owned UI; it names no entry and no URL. */
export type InlineRequest =
| { type: "inline-settings" }
| { type: "inline-logins" }
Expand All @@ -37,7 +39,8 @@ export type InlineRequest =
| { type: "inline-submitted"; username: string; password: string }
| { type: "inline-pending-save" }
| { type: "inline-save"; id: string; entryId: string | null }
| { type: "inline-save-cancel"; id: string };
| { type: "inline-save-cancel"; id: string }
| { type: "inline-open-popup" };

/** A sign-in waiting for the save prompt. `id` is the pending sign-in's own;
* `host` is the submitted page's hostname. */
Expand Down Expand Up @@ -71,6 +74,8 @@ export type SwResponse =
| { type: "save-prompt"; prompt: SavePrompt | null }
| { type: "save"; ok: true }
| { type: "save"; ok: false; code: NmErrorCode; message: string }
| { type: "popup"; ok: true }
| { type: "popup"; ok: false; shortcut: string }
| { type: "error"; code: NmErrorCode; message: string };

/** `url` is the page the credential was released for, and `anchored` says the
Expand Down
Loading
Loading