diff --git a/AUTOFILL.md b/AUTOFILL.md new file mode 100644 index 0000000..39a8884 --- /dev/null +++ b/AUTOFILL.md @@ -0,0 +1,461 @@ +# Portpass Autofill + +Autofill allows users to automatically fill login form fields using credential data from their Portpass vault. A bookmarklet communicates with the Portpass PWA via an encrypted postMessage channel, fills form fields directly using the browser's DOM APIs, and navigates between fields using tab order — no clipboard involved. + +## Scope + +**Desktop browsers, bookmarklet-first.** Same-profile autofill is the primary zero-install path. Cross-profile and cross-browser autofill use the local switchboard relay when the user wants to keep Portpass in a separate clean browser profile; the relay transport and stricter authorization policy are implemented, while the full human-authenticated pairing ceremony is still in progress. + +Portpass's core value is PWA portability — a URL that works on any device with no installation. Same-profile autofill preserves that model. Cross-profile autofill is an optional exception for users who deliberately want a clean Portpass browser profile; it requires a small local relay because browsers do not provide native cross-profile messaging. + +Consequences of this scope: +- Mobile browsers: not supported — the bookmarks bar is not accessible +- Same-profile: works with no helper process. `window.open()`, `postMessage`, and `BroadcastChannel` stay within one browser profile. +- Cross-profile and cross-browser: uses a local WebSocket switchboard relay. Browser-native `BroadcastChannel` cannot cross profile or browser boundaries, so the relay is the transport bridge. +- Clean browser profile: supported by the relay protocol and copy/paste pairing ceremony. + +### Cross-profile / cross-browser bridging options + +These approaches trade the extension attack surface on the Portpass side for other costs. The local relay is the implemented cross-profile path. + +- **Local WebSocket relay / switchboard** (companion app): a minimal local process runs a WebSocket switchboard on loopback, normally `http://localhost:7577` / `ws://localhost:7577/ws`. Cross-profile and cross-browser. Requires a running process. **Implemented** — see the `switchboard` repository and the **Cross-profile autofill** section below for the full design. +- **Protocol handler + clipboard dead drop** (not implemented): a connectionless protocol using `web+portpass://` launch URLs and a temporary encrypted clipboard response. This remains experimental because browser/OS routing across profiles is inconsistent. +- **Reflector server**: a remote WebSocket relay. Works cross-machine. Introduces server dependency, availability risk, and metadata leakage; conflicts with the no-server design principle. + +Mobile autofill (iOS Credential Provider, Android Autofill Service) is a separate product decision that involves consciously leaving the PWA model. It is out of scope here. + +### Current paired-delegate design (May 28, 2026) + +The older cross-profile implementation proved that the relay model works, but its delegate private key was embedded in the bookmarklet. That made the page-side bookmarklet a long-lived authority holder, which is the wrong trust boundary: a hostile website or extension in the filling profile should never be able to steal a reusable key that can query the clean Portpass profile later. + +The redesign treats the WebSocket relay as a public, untrusted packet carrier and makes `autofill.html` the paired delegate. The bookmarklet becomes a short-lived page agent with no durable secret. + +Actors: +- **B**: bookmarklet/page agent, running on the login page in browser profile A +- **A**: `autofill.html`, running on the Portpass origin in browser profile A +- **D**: Dashboard, running on the Portpass origin in browser profile B with the unlocked vault +- **R**: public relay/switchboard; may observe, delay, replay, drop, reorder, or inject packets + +Trust boundaries: +- B runs in the website page context and is not trusted with any long-lived secret. +- A is trusted only as an enrolled delegate. Its private key lives in Portpass-origin storage in profile A, preferably as a non-extractable WebCrypto key. +- D owns vault access and makes all authorization decisions. +- R is never trusted for confidentiality, integrity, ordering, or identity. + +#### Pairing and delegate identity + +A delegate is an enrolled autofill popup/profile. Its durable authority is the non-extractable ECDSA P-256 signing key stored by `autofill.html` in Portpass-origin IndexedDB. The bookmarklet does **not** contain the private key. + +The delegate ID is deterministic from the delegate signing public key: + +``` +afp1_ +``` + +This ID is public. It selects the registered delegate record, while the private key proves control. VaultSheet may show a short display code derived from the same fingerprint, such as the final 8 base32 characters grouped as `ABCD-2345`. + +Same-profile setup currently uses the "New bookmarklet" flow: +1. Portpass generates A's non-extractable signing keypair in the current browser profile. +2. Portpass stores A's private signing key, public key, delegate ID, created time, and relay URL in Portpass-origin storage. +3. Dashboard stores the delegate public key, name, created time, display code, and revocation/use metadata. +4. The generated bookmarklet contains only the Portpass URL, relay routing data, delegate ID, and page-agent code. + +True cross-profile pairing needs one additional UI ceremony because profile A and profile B do not share IndexedDB. The implemented copy/paste-token flow is: + +1. User opens D in the clean Portpass profile and chooses "Add autofill profile". +2. User opens A in the filling profile. +3. A generates a delegate signing keypair locally. The private key never leaves profile A's Portpass origin storage. +4. A shows a `ppair1_...` token containing its public key, relay URL, expiry, pairing ID, and short display code. +5. D stores A's public key, delegate name, created time, and revocation metadata. +6. A stores D's public key or pairing identifier so it can authenticate D's replies. + +Pairing is the only moment where D grants durable authority. Revoking the delegate in D immediately prevents future cross-profile requests from A. + +The same delegate model is used for same-profile and cross-profile autofill. The transport changes; the identity and authorization model does not. + +#### Runtime exact-match flow + +1. User clicks B while on a login page. +2. B opens A and sends page URL/routing data using `postMessage`. +3. A creates a fresh session ID and ephemeral ECDH keypair for its session with D. +4. A validates `event.source === opener`, validates that `event.origin` matches `pageUrl`, and rejects non-HTTPS pages except localhost. +5. A signs a request to D containing `{version, delegateId, sessionId, pageOrigin, pageUrl, aEphemeralPublicKey, action: "match", timestamp, nonce}`. +6. D verifies A's signature, freshness, delegate status, and message binding. +7. D searches only records whose saved URL is authorized for the verified page origin. +8. If an exact match is allowed by policy, D encrypts the credential payload to A's ephemeral public key and replies. +9. A decrypts the credential payload, waits for the user to click the form field if needed, and sends fill instructions to B. +10. B fills the page and discards all session material. + +#### Same-profile behavior + +Same-profile uses `BroadcastChannel('portpass-autofill')` between `autofill.html` and the unlocked Dashboard tab. The page-side bookmarklet opens `autofill.html`, passes page URL/routing data by `postMessage`, and then waits for the popup to send back fill instructions. + +Same-profile keeps the existing convenience behavior: +- Initial lookup can return exact matches and same-site fuzzy metadata for the picker. +- If exactly one exact match exists, the popup auto-advances to the "click a field to begin" state. +- If multiple exact matches or fuzzy suggestions exist, the popup shows a picker. +- Picker search is available in the same-profile path. +- Credentials are fetched lazily after the user selects a record, encrypted to the popup's session ECDH key, then posted to the bookmarklet for filling. +- Save URL can update a selected non-readonly record after explicit user action. + +#### Cross-profile behavior + +Cross-profile uses the local switchboard WebSocket relay. The relay is a public packet carrier: it can observe metadata, delay, drop, replay, reorder, or inject packets, but it cannot forge signed delegate requests or decrypt credential replies. + +Cross-profile is intentionally stricter than same-profile: +- Initial lookup releases no credentials unless there is an exact authorized saved URL match. +- If there is no exact match, D returns only a near-match count and the popup offers to view/edit the records in Portpass. +- Global cross-profile search is disabled by default. If it is reintroduced, it should be an explicit high-risk setting and metadata-only until an exact URL authorization exists. +- Relay `fill-uuid` credential fetches require the selected record's saved URL to exactly match the verified current page URL. +- Revoked delegates, stale timestamps, reused nonces, missing bindings, or wrong-origin URL claims are rejected. + +#### Picker/search flow + +For anything other than one exact authorized match, D returns metadata only to A: +- vault UUID +- record UUID +- title +- saved URL +- match type +- read-only flag + +D must not include passwords, TOTP secrets/codes, sensitive custom fields, notes, or autotype field values in picker/search metadata. + +Recommended policy: +- Exact match: may return one matching credential payload after A's signed request. +- Same-site fuzzy match: may return metadata to A for user selection. +- No same-site match: return "no match"; do not expose global vault search over cross-profile autofill. Could summarize count of fuzzy matches and offer to show them in Portpass to facilitate editing there. +- Save URL: require explicit user action in A and a signed request from A; D may update only the selected record with the verified current page URL. + +If global search is retained for convenience, it should be an explicit high-risk feature, disabled by default, and should return metadata only until the user selects a record. A stronger mode is to require the user to open D for global search or URL attachment. + +#### Message requirements + +Every signed message should include: +- protocol version +- sender delegate ID +- intended recipient ID +- session ID +- action +- verified page origin and URL when relevant +- monotonic counter or nonce +- timestamp with a short validity window +- previous message hash or request ID for replies + +D should reject messages with missing bindings, stale timestamps, reused nonces, unknown delegate IDs, revoked delegates, unexpected actions, or page origins outside the requested record's saved URL scope. + +Sensitive credential payloads are encrypted to A's ephemeral session key for the selected fill session. A then sends the selected fill instructions to B over `postMessage` restricted to the verified opener origin. + +#### Security properties + +This redesign removes the transport trust concern: R can see traffic, but cannot read credentials, forge requests, or alter replies without detection. + +It also removes the reusable-secret exposure from B. A malicious website can still observe credentials after they are filled into the page, and a sufficiently privileged extension in profile A can still steal filled values. That is inherent to autofill. The goal is narrower and achievable: the page-side actor cannot obtain a durable delegate key and cannot browse or request arbitrary vault records outside the verified current site authorization policy. + +#### Remaining work + +1. Add relay-focused tests for replay rejection, wrong-origin URL claims, metadata-only no-match behavior, and exact-match credential release. +2. Optionally add QR or short-code pairing on top of the copy/paste token flow. +3. Optionally add activity logs in VaultSheet: delegate, channel, page origin, action, record title, timestamp, and whether credentials were released. + +--- + +## Relationship to the Password Safe format + +Autofill uses the existing **Autotype field** (field type 0x0e) from the Password Safe v3 format. The default sequence `\u\t\p\n` covers the common case: fill username, tab to password, fill password, submit. Users configure this manually by editing the Autotype field on each record. + +Documentation for the Password Safe Autotype feature is here: https://pwsafe.org/help/pwsafe.html + +--- + +## Autotype codes recognised by Portpass + +### Standard fields + +| Code | Meaning | +|---|---| +| `\u` | Username | +| `\p` | Password | +| `\m` | Email | +| `\2` | TOTP one-time code (current value at fill time) | + +**Security restriction:** if the autotype sequence references any sensitive field — `\p` (password), `\2` (TOTP), or a sensitive custom field (`\fN` where that field is hidden in the record view) — and the login page is served over plain HTTP (not HTTPS or localhost), Portpass refuses to fill and shows an error. Non-sensitive fields (`\u`, `\m`, non-sensitive `\fN`, literal text) are not restricted. + +### Navigation + +| Code | Meaning | +|---|---| +| `\t` | Tab — advance focus to the next field in tab order | +| `\s` | Shift-Tab — move focus to the previous field in tab order | +| `\n` | Enter — submit the form (`form.requestSubmit()`) | + +### Delays + +| Code | Meaning | +|---|---| +| `\wNNN` | Wait NNN milliseconds (1–3 digits, 0–999) | +| `\WNNN` | Wait NNN seconds (1–3 digits, 0–999) | + +### Literal text + +| Code | Meaning | +|---|---| +| `\\` | Literal backslash character | +| *(any other character)* | Typed literally into the current field | + +Literal characters and `\\` are accumulated into a single fill operation. For example, `abc\\def` fills the current field with `abc\def` in one step, not three. + +### Custom fields (Portpass extension — not in official Password Safe) + +| Code | Meaning | +|---|---| +| `\f` | Value of custom field 1 (bare `\f` defaults to field 1) | +| `\fN` | Value of custom field N, where N is a single digit 1–9 | + +`\f0` is an error. `\f` codes are a Portpass-specific extension; the official Password Safe desktop app does not recognise them and will treat them as unknown. + +### Unknown codes + +Any `\X` not listed above is treated as an **unknown code**. Portpass: +- Allows saving the sequence (shows an amber warning, does not block Save) +- Shows the same warning in the record read view +- Silently skips the unknown code at fill time — surrounding literal text is preserved and joined + +This means sequences written for the official Password Safe app (which supports additional codes such as `\g` group, `\i` title, `\l` notes, `\e` Escape, and various key codes) can be stored in Portpass without error, and the recognised portions will execute without error but will not behave the same as when they are executed by the official Password Safe app. + +--- + +## User flow + +### Installation (one-time per browser profile) + +#### Same-profile installation + +1. Open Portpass in the target browser profile +2. Open VaultSheet settings — drag the Autofill bookmarklet link to the browser bookmarks bar + +The bookmarklet contains no private key. Portpass creates a paired autofill profile with a non-extractable signing key in Portpass-origin storage and registers the corresponding public key as a delegate. Each browser profile — and each browser — requires its own independent installation. + +#### Cross-profile installation + +1. Start the local switchboard relay. +2. Open and unlock Portpass in the clean profile. +3. Enable cross-profile autofill and configure the relay URL if needed. +4. Pair the filling profile's `autofill.html` delegate with the clean-profile Dashboard. +5. Install the bookmarklet in the filling profile. + +Cross-profile pairing is separate from the same-profile "New bookmarklet" flow. The filling profile holds the non-extractable private signing key and displays a `ppair1_...` token from `autofill.html?pair=1`; the clean profile imports that token with Vault settings → Autofill → Add autofill profile and stores the matching public delegate record. + +### Per-use + +1. Make sure Portpass is open in a tab and the vault is unlocked (any tab — doesn't need to be the active tab) +2. Switch to the login page tab +3. Click the bookmarklet +4. Same-profile: a picker appears listing exact URL matches first. If there is no exact match, it can show fuzzy suggestions (Levenshtein ≤ 5 on hostname) plus the currently open record if any. +5. Cross-profile: exact authorized matches can be filled. If there is no exact match, the popup shows only a near-match count and offers to open Portpass so the saved URL can be updated there. +6. Select a record when a picker is shown. Optionally save/replace the URL on a non-readonly record to make future requests exact matches. +7. If the page had a focused input when the bookmarklet was clicked (`document.activeElement`), autofill executes immediately on that field; otherwise a "Click the field to start from" prompt appears. + +The URL matching (step 4) removes the need to pre-select a record in Portpass before switching tabs. Note that URLs are canonicalized (remove "https://[www.]" and and url parameters ("?trackingcode=abc123#section3") + +### Focus handling + +Clicking the bookmarks bar typically causes the page to lose its focused element. Autofill handles this with a two-step model: + +- **Step 1 (bookmarklet click):** Portpass popup opens, authenticates the paired delegate, and looks up records for the current page URL +- **Step 2 (field click):** Overlay prompts "Click the field to start from" — when the user clicks a form input, autofill begins from that element + +macOS users can configure an App Shortcut (System Settings → Keyboard → App Shortcuts) to trigger the bookmarklet via keyboard. This preserves the page's focused element and skips Step 2 entirely, matching the desktop app's workflow. + +### Field filling + +Fields are filled using the native input value setter, which works correctly with React, Vue, Angular, and other frameworks that intercept the standard `value` property setter: + +```javascript +const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; +setter.call(field, value); +field.dispatchEvent(new Event('input', { bubbles: true })); +field.dispatchEvent(new Event('change', { bubbles: true })); +``` + +`\t` advances focus by calling `.focus()` on the next focusable element in tab order. `\n` submits via `form.requestSubmit()` or by clicking the submit button. + +--- + +## Error states + +Displayed in a small overlay on the page: + +| Condition | Message | +|---|---| +| Vault locked | "Vault is locked — unlock Portpass first" | +| No record open in Portpass | "Open a record in Portpass first" | +| Autotype field empty or missing | "No autofill sequence set — edit the record in Portpass to add one" | +| Unrecognised token in sequence | "Could not parse autofill sequence: [value]" | + +--- + +## Security model + +Same-profile credentials travel: Portpass WASM memory → encrypted BroadcastChannel → autofill.html → postMessage → bookmarklet JS → form field. Cross-profile credentials travel: Portpass WASM memory → signed/encrypted relay reply → autofill.html → postMessage → bookmarklet JS → form field. No clipboard is used at any point. + +**Credential briefly in JS:** after decryption, the credential exists as a JS variable in the bookmarklet's main-world context for the duration of the fill. This is equivalent in exposure to a user manually typing a memorised password — not a meaningful regression. + +**After filling:** the credential is in `input.value`, readable by any extension on the page. This is identical to the exposure from manual typing or any other password manager. It cannot be avoided without browser-level autofill APIs (which require site cooperation). + +**Cross-profile isolation:** `window.open()`, `postMessage`, and `BroadcastChannel` are browser-internal and scoped to a single browser profile. Cross-profile communication uses the local switchboard relay and does not trust the relay for confidentiality, integrity, ordering, identity, or freshness. + +**Passkeys:** where a site supports passkeys (WebAuthn), using them is always preferable — no credential to intercept at any stage. Autofill is for sites that still require passwords. + +### Channel notes + +Same-profile Dashboard communication uses `BroadcastChannel('portpass-autofill')`. The channel is origin-scoped but still broadcast-style, so requests are signed by the paired delegate and credential payloads are encrypted to a per-session ECDH key. + +Cross-profile communication uses the switchboard relay. The relay is treated as hostile transport: signatures authenticate requests, timestamps and nonces protect freshness, and encrypted replies protect credential contents. + +--- + +## Implementation status (as of 2026-05-28) + +The secure paired-delegate foundation is implemented. The bookmarklet no longer contains a private key; the durable signing key lives in `autofill.html`'s Portpass-origin IndexedDB storage and is created as a non-extractable WebCrypto key. Same-profile autofill is working through this model. Cross-profile transport, copy/paste pairing, reply binding, and stricter relay-side authorization are implemented. + +### What is built + +**Go (`pwsafe/db.go`)**: `Search(query, mode int)` — mode 0 = all fields (incl. non-sensitive custom fields), mode 1 = names only, mode 2 = URL exact match on `CanonicalURL`. `CanonicalURL` strips scheme/www/query/fragment/trailing slash. + +**WASM (`cmd/wasm/main.go`)**: `getDBData` now returns `url` per item. `searchRecords` takes mode int (JS callers updated from bool to 0/1/2). + +**Autofill popup (`pwa/public/autofill.html`)**: A minimal static HTML page (no WASM, no Svelte). Loads its paired delegate profile from IndexedDB, signs requests with the non-extractable signing key, and uses a `$transport` abstraction set once at startup: BroadcastChannel path (same-profile, if Portpass responds to a `ping`) or switchboard WebSocket path (cross-profile). Picker shows autotype sequence as read-only chips; theme/accent are received from Portpass in the first reply. + +**Dashboard.svelte**: BC autofill handler responds to signed paired-popup `hello` messages, performs replay/freshness checks, and encrypts credential payloads to the popup's ECDH session key. `connectSwitchboard` maintains a persistent WebSocket connection to the switchboard on vault unlock, verifies signed relay messages by delegate ID, rejects replayed nonces, and enforces exact saved-URL authorization before releasing credentials on the relay path. + +**App.svelte**: `tryBridge()` in popup `onMount` — pings main Portpass tab via BroadcastChannel; if found, enters bridge mode (`bridgeMode = true`, skips WASM, bridges postMessage↔BC). Shows "Portpass autofill in progress" text. `handleIntent` parses `web+portpass://` LaunchQueue intents (currently reaches Portpass in the same Chrome profile rather than a clean profile on Linux — see cross-profile section for details). + +**bookmarklet.js**: Page-agent variant — `makeDelegateBookmarkletUrl(portpassUrl, delegateId, relayUrl)` embeds only routing data and the public delegate ID in the `javascript:` URL. Bookmarklet opens `autofill.html` as popup, sends `{url, saveUrl, isSecure, delegateId, relayUrl}` via `postMessage`, and bridges field-click events. Autotype execution and DOM helpers are self-contained in the IIFE. + +**pairedAutofill.js**: IDB module for A-side paired profiles. Generates non-extractable ECDSA P-256 signing keys, exports the public SPKI, derives `delegateId = afp1_`, and stores created time, relay URL, display code, public key, and the private `CryptoKey`. + +**delegates.js**: IDB module for D-side delegate records keyed by vault UUID. Records contain `{id, name, publicKey, displayCode, created, bcCount, bcLastUsed, relayCount, relayLastUsed}`. Functions include `getDelegates`, `addDelegate`, `revokeDelegate`, `verifyDelegateById`, and usage counter updates. + +**RecordEdit.svelte**: Visual/Raw toggle for autotype field. Visual mode: chip builder with `parseTokens`/`tokensToRaw`, drag-to-reorder, three-row palette, inline mini-forms for freeform text and wait delays, raw equivalence line. Error/warning shown as styled banner cards. Raw mode: monospace input with three-row token legend. + +**RecordRead.svelte**: Read-only chip display of autotype sequence (same chip styles as RecordEdit). Warning banner for unknown codes. + +### Open UX issue: bookmarklet shows globe icon in bookmarks bar + +When the bookmarklet `javascript:` link is dragged from VaultSheet to the bookmarks bar, Chrome shows a generic globe icon instead of the Portpass Pkey logo. Root cause: App.svelte replaces the `` href with a `data:image/svg+xml,...` URL for theme-aware tab favicon display. Chrome stores that data: URL as the bookmark's favicon URL, but cannot later fetch it from storage → falls back to globe. The browser tab shows the correct icon because it uses the data: URL directly in memory. + +Appending a separate `` element (rather than mutating the original) was tried and did not resolve the issue. + +**Workaround**: bookmark the Portpass page normally first (Chrome captures the favicon at this point), then right-click the bookmark → Edit → paste the `javascript:` URL. Chrome preserves the captured favicon when the URL is edited. + +--- + +## Cross-profile autofill + +Allows Portpass to run in a dedicated clean browser profile (no extensions, single purpose) while autofill is triggered from a regular browsing profile in the same or a different browser on the same machine. + +### switchboard + +A tiny local WebSocket pub/sub broker (standalone repo: github.com/dbro/switchboard). Protocol: + +- Portpass (subscriber) → `{"type":"subscribe","channels":["portpass-autofill"]}` +- autofill.html (publisher) → `{"type":"publish","channel":"portpass-autofill","replyTo":"nonce",...signed payload...}` +- Switchboard forwards the publish verbatim to the subscriber for that channel +- Portpass → `{"type":"reply","replyTo":"nonce",...encrypted blob...}` +- Switchboard routes the reply back to the waiting autofill.html connection + +The switchboard is a dumb pipe — it never inspects payload content. Publisher connections that receive no reply within 60 seconds are closed. Binds to `127.0.0.1` only. Builds as a single portable APE binary via Cosmopolitan Libc (Linux/macOS/Windows/FreeBSD). Runs continuously as a background process (systemd/launchd/Task Scheduler). + +Auto-start instructions (systemd, launchd, Task Scheduler) are documented in the switchboard repository. + +### Trust model + +The attack surface of the bookmarklet approach: any JavaScript running on a page (XSS, malicious ad) can replicate the page-agent mechanics — open `autofill.html` and ask for data. The page-side bookmarklet therefore must not be treated as a durable authority holder. + +The solution is to make `autofill.html`, not the bookmarklet, the cryptographic authenticator. + +**Trusted islands:** +- **autofill.html** — served from the Portpass HTTPS origin; cross-origin isolated from the page it autofills. Holds the non-extractable delegate signing key in profile-local IndexedDB. +- **Portpass PWA / Dashboard** — holds the registered delegate public key; verifies every request signature, freshness value, nonce, delegate status, and URL binding before acting. +- **Bookmarklet URL** — stored in the browser's bookmark store. It contains only routing data, delegate ID, and page-agent code. It is not a secret. + +**Untrusted channels** (carry only public/encrypted material — no secrets leak even if observed): +- Page → autofill.html postMessage: secured by `targetOrigin` +- switchboard: sees only encrypted blobs it cannot decrypt +- localhost network: same + +An attacker who controls the page can observe credentials after they are filled into that page, which is inherent to autofill. The narrower guarantee is that the page-side actor cannot steal a reusable delegate private key from the bookmarklet and cannot use the relay to browse or fetch arbitrary vault records outside the current-site authorization policy. OS-level compromise is outside the threat model. + +### Delegate model + +Each paired autofill profile is a **delegate** — a registered autofill agent that Portpass trusts to request credentials on the user's behalf. + +**Per-delegate record (stored in IDB, keyed by vault UUID):** +``` +{ + id: "afp1_", + name: string, // user-assigned: "Chrome — work profile" + publicKey: ECDSA P-256 SPKI, + displayCode: string, // short UI code derived from the same fingerprint + created: timestamp, + bcCount: number, + bcLastUsed: timestamp, + relayCount: number, + relayLastUsed: timestamp, +} +``` + +Use counters are updated only on requests that pass signature verification — they serve as a record of legitimate use and provide the user feedback on how much time the feature is saving them. + +**Key storage:** A stores its non-extractable private signing key in Portpass-origin IndexedDB in the filling profile. D stores the delegate public key in IndexedDB keyed by vault UUID. If site data is cleared, delegates must be re-paired and new bookmarklets dragged to the bar. Acceptable tradeoff — vault storage would require extending the psafe3 format. + +**Revocation:** delete the public key entry from D's delegate list. Any bookmarklet naming that delegate ID is immediately rejected because D no longer accepts signatures for that delegate. + +### VaultSheet UI + +The AUTOFILL section in VaultSheet lists registered bookmarklets (called "delegates" internally): + +| Name | Created | Uses | Last used | | +|---|---|---|---|---| +| Chrome — work profile | 2026-05-20 | 47 | today | Revoke | + +"New bookmarklet" button: prompts for a name → generates a non-extractable ECDSA P-256 key pair → derives the delegate ID from the public key fingerprint → stores the private key in the paired autofill profile and the public key in the delegate list → shows draggable bookmarklet `` chip with routing data and delegate ID only. + +### Protocol flow + +1. User clicks bookmarklet on a login page +2. Bookmarklet opens `autofill.html` as a popup, passes `{url, saveUrl, isSecure, delegateId, relayUrl}` via `postMessage` with `targetOrigin = Portpass origin` +3. `autofill.html` loads the paired delegate profile, generates an ECDH key pair, and signs `{version, sender: delegateId, recipient, url, nonce, ecdhSpki, timestamp, action}` with its non-extractable private key +4. autofill.html opens a WebSocket to `ws://localhost:7577/ws`; sends `{type:"publish", channel:"portpass-autofill", delegateId, replyTo:nonce, url, ecdh, ts, sig, pub}` +5. Switchboard forwards the publish to the Portpass subscriber registered for that channel (or returns `{type:"error"}` if Portpass is not connected) +6. Portpass verifies the ECDSA signature against the registered public key; rejects silently if invalid +7. Portpass checks the current-site authorization policy. On the cross-profile path, credentials are released only for exact saved URL matches; otherwise only near-match count metadata is returned. +8. Portpass encrypts allowed credential payloads for `autofill.html`'s ECDH public key, sends `{type:"reply", replyTo:nonce, ...blob}` on its switchboard WebSocket +9. Switchboard routes the reply to `autofill.html`'s publisher connection; `autofill.html` decrypts and shows the picker or near-match notice +10. User selects a record when a picker is available; `autofill.html` sends `{type: 'fill', ...}` to `window.opener`; bookmarklet executes autotype +11. Portpass increments same-profile or relay use counters for the verified delegate after a fill completes + +**Note on `web+portpass://` LaunchQueue:** The original design routed cross-profile requests via a `web+portpass://` URL handled by the OS. On Chrome/Linux, the protocol handler routes to the active browser profile rather than the profile where the PWA is installed, making it unreliable for cross-profile use. The relay-server polling approach above is used instead. The LaunchQueue handler (`handleIntent` in App.svelte) remains wired up and may work correctly on macOS/Windows — untested. + +### Same-profile versus cross-profile behavior + +The popup code uses the same broad UI shell in both modes, but credential release policy differs: + +| Behavior | Same-profile | Cross-profile | +|---|---|---| +| Transport | BroadcastChannel within the same profile | Local WebSocket switchboard | +| Request authentication | Signed by A's paired non-extractable key | Signed by A's paired non-extractable key | +| Initial lookup | Exact matches plus fuzzy picker metadata | Exact authorized matches only, otherwise near-match count | +| Global search | Available in the same-profile picker | Disabled by default | +| Credential fetch after selection | Allowed for selected picker record | Allowed only when selected record URL exactly matches verified page URL | +| Save URL | Explicit user action updates selected non-readonly record | Should require explicit user action and signed request; exact-match policy applies before future credential release | +| Revocation | Delete delegate record in VaultSheet | Delete delegate record in clean-profile VaultSheet | + +### Browser notes + +- **Chrome and Firefox** tested for the same-profile bookmarklet flow. Cross-profile relay transport and copy/paste pairing UI are implemented; relay end-to-end testing still needs the local switchboard path exercised in CI/manual QA. +- **Switchboard URL**: use `http://localhost:7577` (not `http://127.0.0.1:7577`). Firefox's mixed-content loopback exemption is specified for `localhost`; `127.0.0.1` may not be exempt on older Firefox versions. +- **Protocol handler** (`web+portpass://` LaunchQueue): tested on Chrome/Linux where it routes to the active profile rather than the PWA's profile. May behave correctly on macOS/Windows — `handleIntent` in App.svelte is wired up for this path. + +--- diff --git a/README.md b/README.md index b5391ec..2aa644e 100644 --- a/README.md +++ b/README.md @@ -14,19 +14,19 @@ You decide where to store your vault file: on-device, self-hosted, or in a cloud ## What Portpass does -* works fully offline, no network connection required after initial installation. Can also work with cloud-hosted files if you choose. -* runs on all your devices: mobile, tablet, and desktop -* stores each vault as a file on your device, for easy sync/backup/sharing -* unlocks vault files using WebAuthn methods: fingerprint, face recognition, and PIN -* fills login forms on websites using a bookmarklet in two clicks (desktop only). No browser extension with excessive permissions, no copying secrets to the system clipboard +* works fully **offline**, no network connection required after initial installation. Can also work with cloud-hosted files if you choose. +* runs on all your devices: **mobile, tablet, and desktop** +* stores each vault as a file on your device, for easy **sync/backup/sharing** +* unlocks vault files using WebAuthn methods: **fingerprint, face recognition, and PIN** +* **fills login forms on websites** using a bookmarklet in two clicks (desktop only). No browser extension with excessive permissions, no copying secrets to the system clipboard * generates strong passwords * keeps a history of previous password values -* generates one-time codes (TOTP) for two-factor authentication (2FA) -* supports custom fields (eg. credit card numbers, PIN codes, account numbers, API keys) +* generates **one-time codes (TOTP)** for two-factor authentication (2FA) +* supports **custom fields** (eg. credit card numbers, PIN codes, account numbers, API keys) * searches instantly across multiple vaults and multiple data fields * organizes password records into groups for browsing * encrypts your vault using an established open source format (pwsafe v3) -* opens multiple vaults simultaneously (eg. personal, work, family), supports sharing vaults with other people +* opens **multiple vaults simultaneously** (eg. personal, work, family), supports sharing vaults with other people * respects read-only file permissions for each vault * has a mobile-first design with both light and dark modes @@ -126,7 +126,7 @@ There is no server, no account, and nothing to trust except the open source code **Biometric/PIN unlock** can be enabled to use your device's built-in authentication (fingerprint, face recognition, or PIN) so you don't have to type your master password on repeat visits. Your master password is encrypted with a key only your device can produce and stored locally, it is never transmitted anywhere. -On Android, Chrome routes biometric/PIN unlock setup through [Google Password Manager](https://passwords.google.com/), which requires a recovery PIN to have been set up previously. Google Password Manager stores a synced copy of the passkey in Google's cloud (but not your vault's master password, which always stays on your device). To set up or reset a Google Password Manager recovery PIN, visit [passwords.google.com/passkeys/reset/intro](https://passwords.google.com/passkeys/reset/intro). +The Chrome browser routes biometric/PIN unlock setup through [Google Password Manager](https://passwords.google.com/), which requires a recovery PIN to have been set up previously. Google Password Manager stores a synced copy of the passkey in Google's cloud (but not your vault's master password, which always stays on your device). To set up or reset a Google Password Manager recovery PIN, visit [passwords.google.com/passkeys/reset/intro](https://passwords.google.com/passkeys/reset/intro). Other browsers beyond Chrome use similar hosted services (eg. Microsoft password manager, Apple iCloud keychain). ## Autofill @@ -142,11 +142,15 @@ Portpass finds matching vault entries by URL, lets you pick one if there are mul ### How Autofill works -A `javascript:` bookmarklet in your browser's bookmarks bar opens a small picker popup when you click it on a login page. The popup shows credentials that match the current page's URL. Click a record and Portpass fills the fields directly, following the record's Autofill sequence setting (default: fill username → Tab → fill password → Submit). +A `javascript:` bookmarklet in your browser's bookmarks bar opens a small picker popup when you click it on a login page. The popup shows credentials that match the current page's URL. Click a record and Portpass fills the fields directly, following the record's Autofill sequence setting (default: fill username -> Tab -> fill password -> Submit). -The bookmarklet communicates with your open Portpass vault over an encrypted channel. No credentials pass through the clipboard at any point — this matters on Windows and Linux, where clipboard contents can be read by any running process, and in browsers where extensions with clipboard permission could read a copied password before it is pasted. The encryption key is unique to each new bookmarklet created in Portpass, and can be revoked from Portpass's vault settings. +The bookmarklet itself is not a secret and contains no private key. It opens Portpass's `autofill.html` popup, which holds a non-extractable signing key in Portpass-origin browser storage for that profile. Portpass stores the matching public key as a revocable autofill delegate. Requests are signed by the popup, and credential replies are encrypted to a fresh per-session key. -Portpass searches all unlocked vaults for URLs that match the current web page. It compares the canonical version, removing "www." as well as url parameters after the "?" and "#" characters. It looks for exact matches first, then falls back to offering the current open record (if one is open) as well as up to 5 near matches. If one of the non-exact matches is chosen, you can instruct Portpass to update that entry's URL in the vault to match the current webpage URL to accelerate future Autofill requests on this webpage. The "near match" method uses edit distance (Levenshtein) showing the five closest matches within a distance of 5 edits. +No credentials pass through the clipboard at any point -- this matters on Windows and Linux, where clipboard contents can be read by any running process, and in browsers where extensions with clipboard permission could read a copied password before it is pasted. + +In same-profile autofill, Portpass searches all unlocked vaults for URLs that match the current web page. It compares the canonical version, removing "www." as well as url parameters after the "?" and "#" characters. It looks for exact matches first, then falls back to offering the current open record (if one is open) as well as up to 5 near matches. If one of the non-exact matches is chosen, you can instruct Portpass to update that entry's URL in the vault to match the current webpage URL to accelerate future Autofill requests on this webpage. The "near match" method uses edit distance (Levenshtein) showing the five closest matches within a distance of 5 edits. + +In cross-profile autofill, credential release is stricter: Portpass only sends credentials over the relay for exact authorized URL matches. If there is no exact match, it returns metadata only, such as the count of near matches, and prompts you to view or update the record inside Portpass. ### Setting up autofill @@ -157,6 +161,14 @@ Portpass searches all unlocked vaults for URLs that match the current web page. For cross-profile setup, start the [switchboard](https://github.com/dbro/switchboard) as a background service on your machine before using the bookmarklet. See the repo README for instructions to run switchboard automatically in the background. +Cross-profile setup uses a separate pairing ceremony because the filling profile and the clean Portpass profile do not share browser storage: + +1. In the filling browser profile, open `https://dbro.github.io/portpass/autofill.html?pair=1`. +2. Click **Create pairing token** and copy the `ppair1_...` token. +3. In the clean Portpass profile, open vault settings -> **Autofill** -> **Add autofill profile**. +4. Paste the token, compare the short pairing code, and click **Pair profile**. +5. Drag or copy the generated bookmarklet into the filling profile's bookmarks bar. + Screenshot of autofill bookmarklet creation ### Autofill form field configuration @@ -188,7 +200,7 @@ The text representation is also possible, and is easier to document here. The de ### Best practices with Autofill -- **Use a unique bookmarklet for each browser profile.** Each bookmarklet holds a unique private key. Create a separate bookmarklet for each browser and profile where you want autofill, and give each a descriptive name so you can revoke individual ones if needed. +- **Use a unique autofill profile for each browser profile.** Each profile has its own non-extractable signing key stored by `autofill.html` in that browser profile's Portpass-origin storage. Create a separate bookmarklet/delegate for each browser and profile where you want autofill, and give each a descriptive name so you can revoke individual ones if needed. - **Revoke bookmarklets you no longer use.** Open vault settings → Autofill, and click **Revoke** next to any entry you want to invalidate. The corresponding bookmarklet will be rejected immediately, even if it is still in someone's bookmarks bar. - **Prefer autofill over copy-paste on Windows and Linux (X11).** On these platforms, any running process can read the clipboard at any time. Autofill writes directly to the form field without ever putting the credential in the clipboard, eliminating that exposure window entirely. (Linux Wayland has better clipboard security than X11.) @@ -202,9 +214,9 @@ The official desktop Password Safe app has a function called "Autotype" that can It is possible to autofill while running Portpass in a separate clean profile, following the security best-practice to reduce exposure to browser extensions -- however, it requires a helper switchboard running in its own process on your system. It is also possible to run Portpass in a different browser (eg. Chrome) and use autofill in another browser (eg. Firefox). -**Same-profile**: Portpass and the pages you fill are in the same browser profile. The bookmarklet opens a relay popup that talks to Portpass directly via a browser-internal channel. No extra software needed. This is the simpler approach, but it means that all your browser extensions could try to attack Portpass. If you trust your browser extensions, this is ok. +**Same-profile**: Portpass and the pages you fill are in the same browser profile. The bookmarklet opens `autofill.html`, which talks to Portpass directly via a browser-internal channel. No extra software needed. This is the simpler approach, but it means that all your browser extensions could try to attack Portpass. If you trust your browser extensions, this is ok. -**Cross-profile**: To protect against malicious browser extensions, you could choose to run Portpass in a separate browser profile with no extensions installed. This means that the browser has stronger isolation between Portpass and the websites that you visit, and the browser prevents the bookmarklet from communicating with Portpass to transfer information from your vault to the bookmarklet. In this scenario, a helper service provides a simple message switchboard between Portpass and the bookmarklet. This service is called **[switchboard](https://github.com/dbro/switchboard)**, and it runs in the background acting as a very limited shared memory. No data leaves your machine. All messages sent between Portpass and the bookmarklet via the switchboard are encrypted end-to-end using the key that is set up when the bookmarklet is installed. An eavesdropper would see only encrypted blobs. +**Cross-profile**: To protect against malicious browser extensions, you can run Portpass in a separate browser profile with no extensions installed. The filling profile pairs its `autofill.html` popup with the clean Portpass profile using a short-lived copy/paste token. A helper service called **[switchboard](https://github.com/dbro/switchboard)** then provides a local message relay between the two profiles. No data leaves your machine. The relay is treated as untrusted: requests are signed by the paired popup, replies are encrypted to that popup's per-session key, replayed requests are rejected, and credentials are released only for exact authorized URL matches. See [SECURITY.md](SECURITY.md) for setup instructions. @@ -214,6 +226,17 @@ Note that while Portpass should run in Chrome or Safari, the bookmarklet can run Portpass's threat model, known limitations, and guidance on protecting yourself from malicious browser extensions are documented in [SECURITY.md](SECURITY.md). +## Roadmap + +Possible future improvements: +* Allow changing vault's master password and the number of key stretching rounds +* Import/Export other vault file formats +* Automatically lock vault after an amount of time or system event (eg screen lock) +* Companion mobile keyboard app to autofill values +* Display and store attachments in the vault (one for each password) + +Some of these capabilitites can be done today using other apps that read and write pwsafe v3 files (change master password, import/export). + ## Credits Portpass is built on the Go/WebAssembly backend from [gopwsafe](https://github.com/tkuhlman/gopwsafe). Portpass started as a fork of that project and has contributed changes back upstream. diff --git a/SECURITY.md b/SECURITY.md index c5dfaba..9aff5a9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -52,7 +52,7 @@ Neither browser provides a consolidated view — you must check each extension i Once you have identified which extensions have broad access, consider two distinct surfaces: -1. **Extensions that can run on the Portpass URL** (`dbro.github.io`): highest impact. They can observe your master password as you type it, read any revealed password in the UI, and — during the brief window when the "New bookmarklet" modal is open — read the delegate private key from the chip's link attribute. If any extension you cannot fully trust has this access, use a dedicated profile for Portpass instead. +1. **Extensions that can run on the Portpass URL** (`dbro.github.io`): highest impact. They can observe your master password as you type it, read any revealed password in the UI, and interact with Portpass-origin storage and scripts. If any extension you cannot fully trust has this access, use a dedicated profile for Portpass instead. 2. **Extensions that can run on login pages**: they can read form field values after autofill. This is the same exposure that exists with every password manager, including native apps — an extension that runs on `bank.com` can read whatever was typed or filled into that page. Audit extensions on your sensitive login pages separately from the Portpass audit. @@ -84,7 +84,7 @@ Extensions are installed per-profile. A profile with no extensions has no extens - **Cross-profile autofill**: Portpass runs in a clean, extension-free profile; the bookmarklet runs in your main browsing profile. Credentials travel via [switchboard](https://github.com/dbro/switchboard), a tiny local WebSocket broker (`localhost:7577`). This preserves full extension isolation — Portpass never touches the browsing profile. Requires switchboard to be running as a background service. -Both modes use the **delegate model**: each bookmarklet installation holds an ECDSA P-256 private key; the corresponding public key is registered in Portpass. Every autofill request is signed with the private key and verified by Portpass before any credentials are exchanged. This means a malicious extension on the page cannot impersonate a legitimate bookmarklet and trick Portpass into delivering credentials to an attacker's key. +Both modes use the **delegate model**: each paired autofill profile has an ECDSA P-256 signing key stored by Portpass's `autofill.html` page in Portpass-origin browser storage. The key is created as non-extractable where the browser supports WebCrypto non-extractability. The bookmarklet contains only routing data and the public delegate ID; it does not contain the private key. Every autofill request is signed by `autofill.html` and verified by Portpass before any credentials are exchanged. --- @@ -143,39 +143,35 @@ Passkeys (WebAuthn) eliminate the clipboard and extension risks entirely — the ## Autofill security -The Autofill bookmarklet uses a **delegate model** to create a cryptographically authenticated channel between the bookmarklet and Portpass, without any server infrastructure and without browser extensions. +The Autofill bookmarklet uses a **delegate model** to create a cryptographically authenticated channel between a paired `autofill.html` popup and Portpass, without a browser extension. Same-profile autofill needs no helper service. Cross-profile autofill uses a local switchboard relay because browser profiles cannot communicate directly. ### Trusted islands -- **Bookmarklet URL** — stored in the browser's bookmark store, which no web API can read or modify. Contains an ECDSA P-256 private key unique to this installation. -- **relay.html** — served from the Portpass HTTPS origin, cross-origin isolated from the page being autofilled. Receives the private key from the bookmarklet via `postMessage` with strict `targetOrigin`. -- **Portpass** — holds the registered public key for each delegate; verifies every autofill request signature before acting. +- **Bookmarklet URL** — stored in the browser's bookmark store, which no web API can read or modify. Contains only page-agent code, the Portpass URL, relay routing data, and the public delegate ID. It is not a secret. +- **autofill.html** — served from the Portpass HTTPS origin, cross-origin isolated from the page being autofilled. Holds the paired delegate's non-extractable ECDSA P-256 signing key in Portpass-origin browser storage. +- **Portpass** — holds the registered public key for each delegate; verifies every autofill request signature, freshness value, delegate status, and URL binding before acting. ### Authentication -Before exchanging any credentials, relay.html signs a challenge `{relayNonce, ecdhSpki}` (same-profile) or `{url, nonce, ecdh, timestamp}` (cross-profile) with the delegate's ECDSA P-256 private key. Portpass verifies the signature against the registered public keys. A forged or unsigned request is silently rejected. +Before exchanging any credentials, `autofill.html` creates a fresh ECDH key pair and signs a request containing the delegate ID, action, current page URL/origin, timestamp, nonce, and ECDH public key. Portpass verifies the signature against the registered delegate public key. Stale timestamps, reused nonces, revoked delegates, wrong-origin URL claims, and forged or unsigned requests are rejected. -This prevents the masquerade attack: a malicious extension or page script running at the Portpass origin can observe the BroadcastChannel but cannot forge a valid ECDSA signature for a registered delegate's key. +This prevents the masquerade attack: a malicious page script can open the popup and send page context, but it cannot forge a valid ECDSA signature for a registered delegate key. A malicious extension that can run on the Portpass origin remains in the high-impact category described above. ### Credential encryption in transit -After authentication, credentials are encrypted with ECDH P-256 + AES-256-GCM. The session key is ephemeral — a fresh ECDH key pair is generated for each autofill session. Credentials in transit are ciphertext only; no key material appears on the channel. +After authentication, credentials are encrypted with ECDH P-256 + AES-256-GCM. The session key is ephemeral -- a fresh ECDH key pair is generated for each autofill session. Credential replies are bound to the request ID, previous message hash, delegate ID, and recipient before encryption. Credentials in transit are ciphertext only; no key material appears on the channel. ### Cross-profile relay server -The switchboard (`localhost:7577`) is a dumb pipe — it stores and forwards encrypted blobs without inspecting content. It binds to `127.0.0.1` only and is not accessible over the network. An attacker who can read the relay server's memory has OS-level access and is outside the threat model. +The switchboard (`localhost:7577`) is a dumb pipe: it stores and forwards signed requests and encrypted replies without needing to inspect their contents. It binds to `127.0.0.1` only and is not accessible over the network. Portpass treats the relay as untrusted transport that may observe metadata, delay, drop, replay, reorder, or inject packets. Signatures, timestamps, nonces, reply binding, and encryption provide the security properties; the relay itself is not trusted. + +Cross-profile credential release is intentionally stricter than same-profile autofill. Portpass sends credentials over the relay only for exact authorized URL matches. If there is no exact match, Portpass returns metadata only, such as a near-match count, and the user must view or update the record inside Portpass. ### What autofill does not protect against - **Credential in the DOM**: after filling, the credential is in `input.value` and readable by any extension on the page. This is identical to manual typing or any other password manager and cannot be avoided without browser-level APIs. -- **Delegate private key in the bookmark store**: the bookmark store is stored as a plaintext JSON file in the browser profile directory. Any process that can read that directory — including backup software, another user account with filesystem access, or malware with user-level permissions — can extract the private key. The key persists indefinitely, so an exfiltrated copy remains valid until the delegate is explicitly revoked in Portpass. - - However, the key is an *authentication token*, not an encryption key. It does not contain or unlock any vault data on its own. An attacker who holds the key can only use it by making a signed request to a running, unlocked Portpass instance — and only via BroadcastChannel (same browser profile) or the local switchboard WebSocket (same machine). Both channels require the attacker to be active on the same machine at the same moment the user has Portpass open. At that point the attacker already has access to more direct attacks. - - This makes the key fundamentally different from a password captured off the clipboard: a clipboard password is immediately and permanently usable anywhere; the delegate key requires an ongoing session to exploit and becomes worthless the moment the vault is locked. - - The same property that protects the key — the bookmark store is inaccessible to web pages — also means Portpass cannot rotate it automatically. Periodic manual rotation (revoke the old delegate in VaultSheet, drag a new bookmarklet) is good hygiene, especially if you suspect a device may have been accessed by someone else. A future VaultSheet version may display key age to prompt rotation. -- **Extension present at drag-install time**: an extension running in the **Portpass profile** (the clean profile where the bookmarklet is dragged *from*) could modify the bookmarklet's JavaScript or substitute a different key before the drag completes. This is why the clean profile must have no extensions — not just to protect the vault, but to ensure the bookmarklet delivered to the bookmarks bar is genuine. Extensions in the *destination* browsing profile cannot intercept the bookmarklet because it arrives already stored in the bookmark store, which extensions cannot read. +- **Delegate key in Portpass-origin browser storage**: the durable signing key is no longer embedded in the bookmarklet, but it still lives in the filling browser profile's site storage. It is created as a non-extractable WebCrypto key, which prevents ordinary JavaScript export, but a fully compromised browser profile, malicious browser, local malware, or privileged debugging access may still be able to abuse it by asking the browser to sign requests. Revoke the delegate in Portpass if a profile or device may be compromised. +- **Extension present at pairing or bookmarklet install time**: an extension running on the Portpass origin in either the clean profile or filling profile could interfere with pairing, alter the bookmarklet JavaScript, or abuse the paired `autofill.html` page. This is why the clean Portpass profile should have no extensions. Extensions in the filling profile cannot extract the non-extractable private key through normal web APIs, but if they can run on login pages they can still read values after they are filled. --- diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index f78fb86..f7d1e0f 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -333,9 +333,10 @@ func updateRecordFields(this js.Value, args []js.Value) interface{} { } else { // Use *string for Value so JSON null (withheld) can be distinguished from "" (clear). type cfInput struct { - Name string `json:"Name"` - Value *string `json:"Value"` - Sensitive bool `json:"Sensitive"` + Name string `json:"Name"` + Value *string `json:"Value"` + Sensitive bool `json:"Sensitive"` + OriginalName string `json:"OriginalName,omitempty"` } var inputs []cfInput if err := json.Unmarshal([]byte(value), &inputs); err != nil { @@ -350,8 +351,12 @@ func updateRecordFields(this js.Value, args []js.Value) interface{} { if inp.Value != nil { cfs[i].Value = *inp.Value } + lookupName := inp.Name + if inp.OriginalName != "" { + lookupName = inp.OriginalName + } for _, ex := range rec.CustomFields { - if ex.Name == inp.Name { + if ex.Name == lookupName { if inp.Value == nil { cfs[i].Value = ex.Value } @@ -568,18 +573,23 @@ func standardFieldValue(rec pwsafe.Record, fieldname string) (string, error) { } } -// writeToClipboard writes value to the clipboard via the browser API. -// Returns "{}" or a JSON hash object depending on returnHash. +// writeToClipboard returns the value (and optionally its SHA-256 hash) as JSON. +// The caller (JS) is responsible for writing to the clipboard so the async +// Promise rejection can be observed and surfaced to the user. func writeToClipboard(value string, returnHash bool) string { - js.Global().Get("navigator").Get("clipboard").Call("writeText", value) if !returnHash { - return `{}` + type Result struct { + Value string `json:"value"` + } + data, _ := json.Marshal(Result{Value: value}) + return string(data) } h := sha256.Sum256([]byte(value)) type Result struct { - Hash string `json:"hash"` + Value string `json:"value"` + Hash string `json:"hash"` } - data, _ := json.Marshal(Result{Hash: hex.EncodeToString(h[:])}) + data, _ := json.Marshal(Result{Value: value, Hash: hex.EncodeToString(h[:])}) return string(data) } @@ -645,8 +655,11 @@ func copyTOTP(this js.Value, args []js.Value) interface{} { t0 = rec.TOTPStartTime.Unix() } code, _ := pwsafe.ComputeTOTP(rec.TwoFactorKey, time.Now().Unix(), t0, rec.TOTPTimeStep, rec.TOTPLength) - js.Global().Get("navigator").Get("clipboard").Call("writeText", code) - return `{}` + type Result struct { + Value string `json:"value"` + } + data, _ := json.Marshal(Result{Value: code}) + return string(data) } func getFieldValueFn(this js.Value, args []js.Value) interface{} { diff --git a/pwa/public/autofill.html b/pwa/public/autofill.html index 778496b..c10bbe1 100644 --- a/pwa/public/autofill.html +++ b/pwa/public/autofill.html @@ -13,6 +13,7 @@ --bg: #14161a; --surface: #1c1f24; --surface-2: #262a31; --border: #2c3038; --text: #f1ede4; --text-muted: #a6a39b; --text-soft: #757279; --amber: #d9a358; --amber-bg: #3a2e1a; + --accent: #d9a358; --accent-strong: #e6b572; --accent-soft: #3a2e1a; --accent-on: #1a1407; --success: #6cba8a; --green-bg: #1a3025; --red: #e08673; --orange: #c47030; } @@ -233,10 +234,10 @@ // ── Theme ───────────────────────────────────────────────────────────────── var _accentMap = { - amber: { light: ['#b07418', '#f3e6c9'], dark: ['#d9a358', '#3a2e1a'] }, - sage: { light: ['#5a7a4f', '#dde6d4'], dark: ['#97b386', '#2c3a26'] }, - slate: { light: ['#4a5d82', '#dee3ee'], dark: ['#8da3c8', '#25304a'] }, - burgundy: { light: ['#8a3a3a', '#ead4d4'], dark: ['#c98a8a', '#3a2222'] }, + amber: { light: ['#b07418', '#8c5c12', '#f3e6c9', '#ffffff'], dark: ['#d9a358', '#e6b572', '#3a2e1a', '#1a1407'] }, + sage: { light: ['#5a7a4f', '#466239', '#dde6d4', '#ffffff'], dark: ['#97b386', '#a8c298', '#2c3a26', '#11160d'] }, + slate: { light: ['#4a5d82', '#364770', '#dee3ee', '#ffffff'], dark: ['#8da3c8', '#9fb6da', '#25304a', '#0f1320'] }, + burgundy: { light: ['#8a3a3a', '#6b2828', '#ead4d4', '#ffffff'], dark: ['#c98a8a', '#d99b9b', '#3a2222', '#1a0d0d'] }, } function applyTheme(dark, accent) { var r = document.documentElement.style @@ -266,10 +267,26 @@ r.setProperty('--orange', '#c47030') } var av = (_accentMap[accent] || _accentMap.amber)[dark ? 'dark' : 'light'] - r.setProperty('--amber', av[0]) - r.setProperty('--amber-bg', av[1]) + r.setProperty('--accent', av[0]) + r.setProperty('--accent-strong', av[1]) + r.setProperty('--accent-soft', av[2]) + r.setProperty('--accent-on', av[3]) + r.setProperty('--amber', av[0]) + r.setProperty('--amber-bg', av[2]) } - // Theme applied when first message arrives from Portpass via applyTheme(). + function applyStoredThemeFromParams() { + var p = new URLSearchParams(location.search) + var theme = p.get('theme') || localStorage.getItem('theme') || 'dark' + var accent = p.get('accent') || localStorage.getItem('accent') || 'amber' + if (theme !== 'light' && theme !== 'dark') theme = 'dark' + if (!_accentMap[accent]) accent = 'amber' + try { + localStorage.setItem('theme', theme) + localStorage.setItem('accent', accent) + } catch(_) {} + applyTheme(theme === 'dark', accent) + } + // Theme is applied from Portpass messages during fill, and from URL/storage on the pairing page. // ── SVG icons ───────────────────────────────────────────────────────────── var _S = ' viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"' @@ -278,6 +295,7 @@ var SVG_X_ICO = '' var SVG_PENCIL = '' var SVG_UNDO = '' + var SVG_COPY = '' var SVG_CHECK_SM = '' var SVG_CHECK_MD = '' // Large error icons (40px) @@ -287,15 +305,16 @@ var SVG_WAITING = '' // ── Shared state ─────────────────────────────────────────────────────────── + var pairMode = new URLSearchParams(location.search).has('pair') var pingNonce = crypto.randomUUID() - var ch = new BroadcastChannel('portpass-autofill') + var ch = pairMode ? null : new BroadcastChannel('portpass-autofill') var bookmarkletOrigin = null var isSecure = false var currentUrl = '' var saveUrl = '' // Mode detection - var cprivKey = null + var pairedProfile = null var delegateId = null var bcPongCount = 0 var wsProbeReplies = [] @@ -370,6 +389,14 @@ $content.appendChild(el) } + var PAIR_BASE32 = 'abcdefghijklmnopqrstuvwxyz234567' + + if (pairMode) { + applyStoredThemeFromParams() + showPairingSetup() + return + } + // ── Startup: generate ECDH key pair, signal ready ───────────────────────── crypto.subtle.generateKey( { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey'] @@ -404,7 +431,6 @@ currentUrl = msg.url || '' saveUrl = msg.saveUrl || currentUrl isSecure = !!msg.isSecure - cprivKey = msg.privKey || null delegateId = msg.delegateId || null hasActiveField = !!msg.hasActiveField if (msg.relayUrl) wsRelayUrl = msg.relayUrl.replace(/^http/, 'ws').replace(/\/ws$/, '') + '/ws' @@ -419,7 +445,14 @@ } catch(_) {} } - startProbe() + loadPairedProfile(delegateId).then(function(profile) { + pairedProfile = profile + if (!pairedProfile) { sendError('Autofill profile is not paired — create a new bookmarklet in Portpass'); return } + cpPubSpkiB64 = pairedProfile.publicKeyB64 || btoa(String.fromCharCode.apply(null, new Uint8Array(pairedProfile.publicKey || []))) + startProbe() + }).catch(function() { + sendError('Autofill profile is not paired — create a new bookmarklet in Portpass') + }) } // ── Messages from bookmarklet (field-clicked, fill-done, fill-error) ────── @@ -452,21 +485,13 @@ } async function startWsProbe() { - if (!cprivKey) return + if (!pairedProfile || !pairedProfile.signingKey) return try { - cpSigningKey = await crypto.subtle.importKey( - 'jwk', cprivKey, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'] - ) - var pubJwk = { kty: cprivKey.kty, crv: cprivKey.crv, x: cprivKey.x, y: cprivKey.y } - var pubKey = await crypto.subtle.importKey( - 'jwk', pubJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'] - ) - var pubSpki = await crypto.subtle.exportKey('spki', pubKey) - cpPubSpkiB64 = btoa(String.fromCharCode.apply(null, new Uint8Array(pubSpki))) + cpSigningKey = pairedProfile.signingKey setupWsTransport() var ts = Date.now() - var signedPayload = JSON.stringify({ url: currentUrl, nonce: pingNonce, ecdh: cpEcdhSpkiB64, ts: ts }) + var signedPayload = JSON.stringify({ version: 1, sender: delegateId || '', recipient: pairedProfile.dashboardId || '', url: currentUrl, nonce: pingNonce, ecdh: cpEcdhSpkiB64, ts: ts, msgType: '', uuid: '', vaultUuid: '', query: '' }) var sigBytes = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, cpSigningKey, new TextEncoder().encode(signedPayload) ) @@ -476,7 +501,7 @@ wsProbeWs.onopen = function() { wsProbeWs.send(JSON.stringify({ type: 'publish', channel: 'portpass-autofill', replyTo: pingNonce, - url: currentUrl, ecdh: cpEcdhSpkiB64, ts: ts, sig: sigB64, pub: cpPubSpkiB64 + delegateId: delegateId, url: currentUrl, ecdh: cpEcdhSpkiB64, ts: ts, sig: sigB64, pub: cpPubSpkiB64 })) } wsProbeWs.onmessage = function(e) { @@ -503,11 +528,11 @@ $transport = { search: async function(q) { var blob = await cpSignAndSend('search', { url: q, query: q }) - return decryptReply(blob) + return decryptReply(blob, blob.replyTo) }, fill: async function(uuid, vu) { - var blob = await cpSignAndSend('fill-uuid', { url: uuid, uuid: uuid, vaultUuid: vu || null }) - var results = await decryptReply(blob) + var blob = await cpSignAndSend('fill-uuid', { url: currentUrl, uuid: uuid, vaultUuid: vu || null }) + var results = await decryptReply(blob, blob.replyTo) if (!results || !results.length) throw new Error('Could not get credentials') return results[0] }, @@ -554,6 +579,7 @@ ws.onmessage = function(e) { try { var msg = JSON.parse(e.data) + if (msg._switchboard_origin !== location.origin) return if (msg.type === 'error') done(reject, new Error(msg.message || 'Autofill failed')) else if (msg.type === 'reply') done(resolve, msg) } catch(_) { done(reject, new Error('Invalid relay message')) } @@ -563,7 +589,7 @@ }) } - async function decryptReply(blob) { + async function decryptReply(blob, expectedRequestId) { var ephPubJwk = JSON.parse(atob(blob.ephPub)) var ephPubKey = await crypto.subtle.importKey('jwk', ephPubJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, []) var cpSessionKey = await crypto.subtle.deriveKey( @@ -573,16 +599,24 @@ var iv = Uint8Array.from(atob(blob.iv), function(c) { return c.charCodeAt(0) }) var ct = Uint8Array.from(atob(blob.ciphertext), function(c) { return c.charCodeAt(0) }) var pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, cpSessionKey, ct) - return JSON.parse(new TextDecoder().decode(pt)) + var parsed = JSON.parse(new TextDecoder().decode(pt)) + if (parsed && parsed.v === 1 && Object.prototype.hasOwnProperty.call(parsed, 'data')) { + if (expectedRequestId && parsed.requestId !== expectedRequestId) throw new Error('Relay reply did not match request') + if (parsed.delegateId && delegateId && parsed.delegateId !== delegateId) throw new Error('Relay reply was for a different delegate') + if (parsed.recipient && parsed.recipient !== 'autofill-popup') throw new Error('Relay reply was for a different recipient') + return parsed.data + } + return parsed } async function processRelayBlob(blob) { if (blob.error) { sendError(blob.error); return } try { - var data = await decryptReply(blob) + var data = await decryptReply(blob, blob.replyTo) if (data && data.records) { applyTheme(data.theme === 'dark', data.accent || 'amber') - showPicker(data.records) + if ((!data.records || !data.records.length) && data.nearMatchCount !== undefined) showNearMatchSummary(data.nearMatchCount) + else showPicker(data.records) } else { showPicker(data || []) } @@ -592,14 +626,15 @@ async function cpSignAndSend(msgType, extraFields) { var ts = Date.now() var reqNonce = crypto.randomUUID() - var signedPayload = JSON.stringify({ url: extraFields.url || '', nonce: reqNonce, ecdh: cpEcdhSpkiB64, ts: ts }) + var signedPayload = JSON.stringify({ version: 1, sender: delegateId || '', recipient: pairedProfile?.dashboardId || '', url: extraFields.url || '', nonce: reqNonce, ecdh: cpEcdhSpkiB64, ts: ts, + msgType: msgType, uuid: extraFields.uuid || '', vaultUuid: extraFields.vaultUuid || '', query: extraFields.query || '' }) var sigBytes = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, cpSigningKey, new TextEncoder().encode(signedPayload) ) var sigB64 = btoa(String.fromCharCode.apply(null, new Uint8Array(sigBytes))) return wsPost(Object.assign({ type: 'publish', channel: 'portpass-autofill', replyTo: reqNonce, - msgType: msgType, ecdh: cpEcdhSpkiB64, ts: ts, sig: sigB64, pub: cpPubSpkiB64 + delegateId: delegateId, msgType: msgType, ecdh: cpEcdhSpkiB64, ts: ts, sig: sigB64, pub: cpPubSpkiB64 }, extraFields)) } @@ -622,7 +657,7 @@ if (!cpSigningKey || !cpPubSpkiB64) return var nonce = crypto.randomUUID() var ts = Date.now() - var payload = JSON.stringify({ url: '', nonce: nonce, ecdh: '', ts: ts }) + var payload = JSON.stringify({ version: 1, sender: delegateId || '', recipient: pairedProfile?.dashboardId || '', url: '', nonce: nonce, ecdh: '', ts: ts, msgType: 'fill-done', uuid: '', vaultUuid: '', query: '' }) crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, cpSigningKey, new TextEncoder().encode(payload) ).then(function(sigBytes) { @@ -632,7 +667,7 @@ ws.onopen = function() { ws.send(JSON.stringify({ type: 'publish', channel: 'portpass-autofill', replyTo: nonce, - msgType: 'fill-done', url: '', ecdh: '', ts: ts, sig: sigB64, pub: cpPubSpkiB64 + delegateId: delegateId, msgType: 'fill-done', url: '', ecdh: '', ts: ts, sig: sigB64, pub: cpPubSpkiB64 })) } ws.onmessage = function() { ws.close() } @@ -644,7 +679,7 @@ if (!cpSigningKey || !cpPubSpkiB64) return var saveNonce = crypto.randomUUID() var ts = Date.now() - var payload = JSON.stringify({ url: saveUrl, nonce: saveNonce, ecdh: '', ts: ts }) + var payload = JSON.stringify({ version: 1, sender: delegateId || '', recipient: pairedProfile?.dashboardId || '', url: saveUrl, nonce: saveNonce, ecdh: '', ts: ts, msgType: 'save-url', uuid: uuid || '', vaultUuid: vaultUuid || '', query: '' }) crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, cpSigningKey, new TextEncoder().encode(payload) ).then(function(sigBytes) { @@ -654,7 +689,7 @@ ws.onopen = function() { ws.send(JSON.stringify({ type: 'publish', channel: 'portpass-autofill', replyTo: saveNonce, - msgType: 'save-url', uuid: uuid, vaultUuid: vaultUuid, + delegateId: delegateId, msgType: 'save-url', uuid: uuid, vaultUuid: vaultUuid, url: saveUrl, ecdh: '', ts: ts, sig: sigB64, pub: cpPubSpkiB64 })) } @@ -667,6 +702,602 @@ $transport.saveUrl(uuid, vaultUuid || null) } + function loadPairedProfile(id) { + return new Promise(function(resolve, reject) { + var req = indexedDB.open('keyval-store') + req.onerror = function() { reject(req.error || new Error('IndexedDB unavailable')) } + req.onsuccess = function() { + var db = req.result + try { + var tx = db.transaction('keyval', 'readonly') + var store = tx.objectStore('keyval') + var gr = store.get('paired-autofill-profiles-v1') + gr.onerror = function() { reject(gr.error || new Error('Pairing storage unavailable')) } + gr.onsuccess = function() { + var all = gr.result || {} + var delegateKey = id || all.defaultDelegateId + resolve(delegateKey ? (all[delegateKey] || null) : null) + } + } catch(e) { reject(e) } + } + }) + } + + function savePairedProfile(profile) { + return new Promise(function(resolve, reject) { + var req = indexedDB.open('keyval-store') + req.onerror = function() { reject(req.error || new Error('IndexedDB unavailable')) } + req.onupgradeneeded = function() { + var db = req.result + if (!db.objectStoreNames.contains('keyval')) db.createObjectStore('keyval') + } + req.onsuccess = function() { + var db = req.result + try { + var tx = db.transaction('keyval', 'readwrite') + var store = tx.objectStore('keyval') + var gr = store.get('paired-autofill-profiles-v1') + gr.onerror = function() { reject(gr.error || new Error('Pairing storage unavailable')) } + gr.onsuccess = function() { + var all = gr.result || {} + all[profile.delegateId] = profile + all.defaultDelegateId = profile.delegateId + var pr = store.put(all, 'paired-autofill-profiles-v1') + pr.onerror = function() { reject(pr.error || new Error('Pairing storage unavailable')) } + pr.onsuccess = function() { resolve(profile) } + } + } catch(e) { reject(e) } + } + }) + } + + function pairBase32(bytes) { + var out = '', bits = '' + for (var i = 0; i < bytes.length; i++) { + bits += bytes[i].toString(2).padStart(8, '0') + while (bits.length >= 5) { + out += PAIR_BASE32[parseInt(bits.slice(0, 5), 2)] + bits = bits.slice(5) + } + } + if (bits.length > 0) out += PAIR_BASE32[parseInt(bits.padEnd(5, '0'), 2)] + return out + } + + function pairBytesToB64(bytes) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(bytes))) + } + + function pairB64url(s) { + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + } + + async function identityFromPairingPublicKey(publicKeySpki) { + var digest = new Uint8Array(await crypto.subtle.digest('SHA-256', publicKeySpki)) + var fp = pairBase32(digest.slice(0, 16)) + return { + delegateId: 'afp1_' + fp, + displayCode: fp.slice(-8).replace(/(.{4})/g, '$1-').replace(/-$/, '').toUpperCase(), + } + } + + function pairingTokenForProfile(profile, name) { + var now = Date.now() + var token = { + v: 1, + type: 'portpass-autofill-pairing', + delegateId: profile.delegateId, + displayCode: profile.displayCode, + name: name || '', + publicKeyB64: profile.publicKeyB64, + relayUrl: profile.relayUrl || '', + pairingId: profile.pairingId, + created: profile.created, + expires: now + 10 * 60 * 1000, + } + return 'ppair1_' + pairB64url(JSON.stringify(token)) + } + + async function createPairingProfile(relayUrl) { + var keyPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify'] + ) + var spki = await crypto.subtle.exportKey('spki', keyPair.publicKey) + var identity = await identityFromPairingPublicKey(spki) + var profile = { + delegateId: identity.delegateId, + displayCode: identity.displayCode, + dashboardId: 'local-dashboard', + relayUrl: relayUrl || '', + pairingId: crypto.randomUUID(), + created: Date.now(), + signingKey: keyPair.privateKey, + publicKey: Array.from(new Uint8Array(spki)), + publicKeyB64: pairBytesToB64(spki), + } + return savePairedProfile(profile) + } + + function makeDelegateBookmarkletUrl(portpassUrl, delegateId, relayUrl) { + var origin = new URL(portpassUrl).origin + return 'javascript:' + encodeURIComponent( + '(' + DELEGATE_BOOKMARKLET_IIFE.toString() + ')(' + + JSON.stringify(portpassUrl) + ',' + + JSON.stringify(origin) + ',' + + JSON.stringify(delegateId) + ',' + + JSON.stringify(relayUrl) + ')' + ) + } + + function DELEGATE_BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN, DELEGATE_ID, RELAY_URL) { + 'use strict' + if (window.__ppRunning) return + window.__ppRunning = true + var activeEl = isUsableInput(document.activeElement) ? document.activeElement : null + var isSecure = window.location.protocol === 'https:' || window.location.hostname === 'localhost' + var currentCanonical = canonicalURL(window.location.href) + var saveUrl = window.location.origin + window.location.pathname + var AUTOFILL_URL = PORTPASS_URL + 'autofill.html' + var pp = null + var startEl = null + var cleanedUp = false + + function cleanup() { + if (cleanedUp) return + cleanedUp = true + document.removeEventListener('click', onFieldClick, true) + window.removeEventListener('message', onPopupMessage) + window.__ppRunning = false + } + function onFieldClick(e) { + var target = e.target + if (!isUsableInput(target)) return + e.preventDefault() + startEl = target + try { pp.postMessage({ type: 'field-clicked' }, PORTPASS_ORIGIN) } catch(_) {} + } + function onPopupMessage(e) { + if (!pp || e.source !== pp) return + var msg = e.data + if (!msg) return + if (msg.type === 'fill') { + document.removeEventListener('click', onFieldClick, true) + var el = startEl || activeEl + executeAutotype(el, msg.autotype, msg.fields).then(function() { + try { pp.postMessage({ type: 'fill-done' }, PORTPASS_ORIGIN) } catch(_) {} + try { pp.focus() } catch(_) {} + cleanup() + }) + } else if (msg.type === 'cancel') cleanup() + } + ;(async function run() { + try { + var ppW = 380, ppH = 480 + var ppLeft = screen.width - ppW - 24 + pp = window.open(AUTOFILL_URL, 'portpass_autofill', 'popup=yes,width=' + ppW + ',height=' + ppH + ',left=' + ppLeft + ',top=24') + if (!pp) { cleanup(); return } + var readyMsg + try { readyMsg = await recv(pp, ['ready', 'error'], 10000) } + catch (_) { try { pp.close() } catch (_2) {}; cleanup(); return } + if (readyMsg.type === 'error') { cleanup(); return } + pp.postMessage({ + type: 'init', + url: currentCanonical, + saveUrl: saveUrl, + isSecure: isSecure, + delegateId: DELEGATE_ID, + relayUrl: RELAY_URL, + hasActiveField: !!activeEl, + }, PORTPASS_ORIGIN) + window.addEventListener('message', onPopupMessage) + document.addEventListener('click', onFieldClick, true) + var closeCheck = setInterval(function() { + if (pp && pp.closed) { clearInterval(closeCheck); cleanup() } + }, 500) + } catch (e) { cleanup() } + })() + function recv(target, types, timeout) { + timeout = timeout || 5000 + return new Promise(function(resolve, reject) { + var t = setTimeout(function() { + window.removeEventListener('message', handler) + reject('timeout') + }, timeout) + function handler(e) { + if (e.source !== target) return + if (types.indexOf(e.data && e.data.type) >= 0) { + clearTimeout(t) + window.removeEventListener('message', handler) + resolve(e.data) + } + } + window.addEventListener('message', handler) + }) + } + function canonicalURL(href) { + var s = href || '' + var pfxs = ['https://', 'http://'] + for (var i = 0; i < pfxs.length; i++) { + if (s.toLowerCase().indexOf(pfxs[i]) === 0) { s = s.slice(pfxs[i].length); break } + } + var h = s.indexOf('#'); if (h >= 0) s = s.slice(0, h) + var q = s.indexOf('?'); if (q >= 0) s = s.slice(0, q) + s = s.toLowerCase() + var sl = s.indexOf('/') + if (sl >= 0) s = s.slice(0, sl).replace(/^www\./, '') + s.slice(sl) + else s = s.replace(/^www\./, '') + return s.replace(/\/+$/, '') + } + function parseAutotype(seq) { + var tokens = [], lit = '', i = 0 + while (i < seq.length) { + if (seq[i] !== '\\') { lit += seq[i]; i++; continue } + var code = seq[i + 1] + if (!code) break + if (code === '\\') { lit += '\\'; i += 2 } + else if (code === 'f') { + if (lit) { tokens.push({ type: 'lit', text: lit }); lit = '' } + var d = seq[i + 2] + if (d && /^[1-9]$/.test(d)) { tokens.push({ type: 'f', n: parseInt(d) }); i += 3 } + else { tokens.push({ type: 'f', n: 1 }); i += 2 } + } else if (code === 'w' || code === 'W') { + if (lit) { tokens.push({ type: 'lit', text: lit }); lit = '' } + var j = i + 2, count = 0 + while (j < seq.length && count < 3 && /^[0-9]$/.test(seq[j])) { j++; count++ } + tokens.push({ type: 'delay', ms: (parseInt(seq.slice(i + 2, j)) || 0) * (code === 'W' ? 1000 : 1) }); i = j + } else if ('uptmn2s'.indexOf(code) >= 0) { + if (lit) { tokens.push({ type: 'lit', text: lit }); lit = '' } + tokens.push({ type: 'code', code: code }); i += 2 + } else i += 2 + } + if (lit) tokens.push({ type: 'lit', text: lit }) + return tokens + } + function isUsableInput(el) { + if (!el) return false + var tag = el.tagName + if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false + if (el.disabled || el.type === 'hidden') return false + if (['submit', 'button', 'reset', 'image', 'checkbox', 'radio'].indexOf(el.type) >= 0) return false + var s = getComputedStyle(el) + return s.display !== 'none' && s.visibility !== 'hidden' + } + function fillField(el, value) { + var proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype + var setter = Object.getOwnPropertyDescriptor(proto, 'value') + if (setter && setter.set) setter.set.call(el, value) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + } + function focusableList() { + var q = 'input:not([disabled]):not([type=hidden]):not([type=submit]):not([type=button]):not([type=reset]):not([type=image]):not([type=checkbox]):not([type=radio]),textarea:not([disabled])' + var all = Array.from(document.querySelectorAll(q)).filter(function(e) { + var s = getComputedStyle(e) + return e.tabIndex >= 0 && s.display !== 'none' && s.visibility !== 'hidden' + }) + return all.filter(function(e) { return e.tabIndex > 0 }).sort(function(a, b) { return a.tabIndex - b.tabIndex }).concat(all.filter(function(e) { return e.tabIndex === 0 })) + } + function nextFocusable(el) { + var sorted = focusableList() + var i = sorted.indexOf(el) + return i >= 0 ? sorted[i + 1] || null : null + } + function prevFocusable(el) { + var sorted = focusableList() + var i = sorted.indexOf(el) + return i > 0 ? sorted[i - 1] : null + } + async function executeAutotype(startEl, sequence, fields) { + var tokens = parseAutotype(sequence) + var el = startEl + for (var i = 0; i < tokens.length; i++) { + var tok = tokens[i] + if (tok.type === 'delay') await new Promise(function(r) { setTimeout(r, tok.ms) }) + else if (tok.type === 'lit' || tok.type === 'f') { + if (el) fillField(el, tok.type === 'f' ? (fields['f' + tok.n] || '') : tok.text) + } else { + var code = tok.code + if (code === 'u' || code === 'p' || code === 'm' || code === '2') { + if (el) fillField(el, fields[code] || '') + } else if (code === 't') { + var next = nextFocusable(el) + if (next) { if (el) el.dispatchEvent(new Event('blur', { bubbles: true })); next.focus(); el = next } + } else if (code === 's') { + var prev = prevFocusable(el) + if (prev) { if (el) el.dispatchEvent(new Event('blur', { bubbles: true })); prev.focus(); el = prev } + } else if (code === 'n') { + if (el) ['keydown', 'keypress', 'keyup'].forEach(function(evType) { + el.dispatchEvent(new KeyboardEvent(evType, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true })) + }) + var form = el && el.closest('form') + var submitBtn = (form && form.querySelector('[type=submit]')) || (form && form.querySelector('[default-button]')) + if (!submitBtn && form) { + var btns = Array.from(form.querySelectorAll('button:not([type=reset])')) + if (btns.length === 1) submitBtn = btns[0] + } + if (submitBtn) submitBtn.click() + else if (form) try { form.requestSubmit() } catch (_) { try { form.submit() } catch (_2) {} } + } + } + } + } + } + + function showPairingSetup() { + hideSearchUI() + $subheader.style.display = 'none' + $content.innerHTML = '' + + var wrap = document.createElement('div') + wrap.style.cssText = 'padding:26px 28px 34px;display:flex;flex-direction:column;gap:24px;max-width:760px' + + var title = document.createElement('div') + title.className = 'pp-waiting-title' + title.style.cssText = 'font-size:26px;line-height:1.15' + title.textContent = 'Pair this everyday browser' + wrap.appendChild(title) + + var help = document.createElement('div') + help.className = 'pp-waiting-sub' + help.style.cssText = 'text-align:left;font-size:16px;line-height:1.45;margin-top:-14px' + help.textContent = 'This creates a signing key that stays in this browser. Pair it with your vault profile so Portpass will trust autofill requests from here.' + wrap.appendChild(help) + + function step(num, heading, body) { + var row = document.createElement('div') + row.style.cssText = 'display:grid;grid-template-columns:40px 1fr;gap:14px;align-items:start' + var badge = document.createElement('div') + badge.textContent = String(num) + badge.style.cssText = 'width:30px;height:30px;border:1.5px solid var(--accent);border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--accent);font-weight:800;font-size:17px;margin-top:2px' + var col = document.createElement('div') + col.style.cssText = 'display:flex;flex-direction:column;gap:10px;min-width:0' + var h = document.createElement('div') + h.className = 'pp-waiting-title' + h.style.cssText = 'font-size:18px' + h.textContent = heading + col.appendChild(h) + if (body) { + var b = document.createElement('div') + b.className = 'pp-waiting-sub' + b.style.cssText = 'text-align:left;line-height:1.45' + b.textContent = body + col.appendChild(b) + } + row.appendChild(badge) + row.appendChild(col) + wrap.appendChild(row) + return col + } + + var nameStep = step(1, 'Name this bookmarklet', 'The label shown in your bookmarks bar.') + var name = document.createElement('input') + name.className = 'pp-search' + name.placeholder = 'Portpass Autofill' + name.value = 'Portpass Autofill' + nameStep.appendChild(name) + + var relayWrap = document.createElement('details') + relayWrap.style.cssText = 'color:var(--text-soft)' + var relaySummary = document.createElement('summary') + relaySummary.textContent = 'Advanced · relay endpoint' + relaySummary.style.cursor = 'pointer' + relayWrap.appendChild(relaySummary) + var relayBox = document.createElement('div') + relayBox.style.cssText = 'margin-top:12px;padding:16px;border:1px solid var(--border);border-radius:10px;background:var(--surface-2);display:flex;flex-direction:column;gap:10px' + relayWrap.appendChild(relayBox) + var relayLabel = document.createElement('label') + relayLabel.className = 'pp-waiting-sub' + relayLabel.style.cssText = 'text-align:left' + relayLabel.textContent = 'Relay endpoint' + relayBox.appendChild(relayLabel) + var relay = document.createElement('input') + relay.className = 'pp-search' + relay.placeholder = 'http://localhost:7577' + relay.value = 'http://localhost:7577' + relayBox.appendChild(relay) + var relayHint = document.createElement('div') + relayHint.className = 'pp-waiting-sub' + relayHint.style.cssText = 'text-align:left;line-height:1.45' + relayHint.textContent = 'Defaults to the local relay. Baked into the token and bookmarklet below — change it only if your relay runs elsewhere.' + relayBox.appendChild(relayHint) + nameStep.appendChild(relayWrap) + + var bookmarkletStep = step(2, 'Install the bookmarklet here', "It carries no key — it just opens this page to fill. Works once you've paired in the next step.") + var installGrid = document.createElement('div') + installGrid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:4px' + bookmarkletStep.appendChild(installGrid) + + var dragCol = document.createElement('div') + dragCol.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:12px;border:1.5px dashed var(--border);border-radius:10px;background:var(--surface-2);padding:22px 14px;opacity:0.55' + installGrid.appendChild(dragCol) + var dragLabel = document.createElement('div') + dragLabel.textContent = 'BOOKMARKS BAR VISIBLE' + dragLabel.style.cssText = 'font-size:11px;font-weight:800;letter-spacing:0.08em;color:var(--text-soft)' + dragCol.appendChild(dragLabel) + var chip = document.createElement('a') + chip.href = '#' + chip.draggable = false + chip.onclick = function(e) { e.preventDefault() } + chip.style.cssText = 'display:inline-flex;align-items:center;gap:8px;padding:9px 16px;background:var(--bg);border:1.5px dashed var(--border);border-radius:999px;color:var(--text);text-decoration:none;font-weight:800;cursor:default' + chip.innerHTML = ' Portpass Autofill' + dragCol.appendChild(chip) + var dragHint = document.createElement('div') + dragHint.className = 'pp-waiting-sub' + dragHint.textContent = 'Drag to your bookmarks bar' + dragCol.appendChild(dragHint) + + var copyCol = document.createElement('div') + copyCol.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:12px;border:1.5px solid var(--border);border-radius:10px;background:var(--surface-2);padding:22px 14px;opacity:0.55' + installGrid.appendChild(copyCol) + var copyLabel = document.createElement('div') + copyLabel.textContent = 'BAR HIDDEN' + copyLabel.style.cssText = dragLabel.style.cssText + copyCol.appendChild(copyLabel) + var copyLink = document.createElement('button') + copyLink.className = 'pp-footer-btn' + copyLink.disabled = true + copyLink.style.cssText = 'display:inline-flex;align-items:center;gap:8px;border:1.5px solid var(--accent);border-radius:8px;color:var(--accent);padding:9px 18px;font-weight:800' + copyLink.innerHTML = SVG_COPY + ' Copy link' + copyCol.appendChild(copyLink) + var copyHint = document.createElement('div') + copyHint.className = 'pp-waiting-sub' + copyHint.style.textAlign = 'center' + copyHint.textContent = 'Add a bookmark and paste the link' + copyCol.appendChild(copyHint) + + var tokenStep = step(3, 'Copy the pairing token to the vault profile', 'Paste it into Portpass in your vault profile and confirm this code matches. This is the last step here.') + var tokenRow = document.createElement('div') + tokenRow.style.cssText = 'display:flex;align-items:center;gap:14px;flex-wrap:wrap' + tokenStep.appendChild(tokenRow) + + var code = document.createElement('div') + code.textContent = '---- ----' + code.style.cssText = 'border:1.5px solid var(--accent);border-radius:8px;padding:7px 13px;color:var(--accent);font-family:ui-monospace,monospace;font-size:18px;font-weight:800;letter-spacing:0.04em' + tokenRow.appendChild(code) + + var copyToken = document.createElement('button') + copyToken.className = 'pp-footer-btn' + copyToken.disabled = true + copyToken.style.cssText = 'display:inline-flex;align-items:center;gap:8px;background:var(--accent);color:var(--accent-on);border:none;border-radius:8px;padding:10px 20px;font-weight:800' + copyToken.innerHTML = SVG_COPY + ' Copy token' + tokenRow.appendChild(copyToken) + + var tokenMeta = document.createElement('div') + tokenMeta.className = 'pp-waiting-sub' + tokenMeta.style.cssText = 'text-align:left;line-height:1.45' + tokenMeta.textContent = 'Stays valid while this page is open · 10 min after you copy' + tokenStep.appendChild(tokenMeta) + + var regen = document.createElement('button') + regen.className = 'pp-footer-btn' + regen.style.cssText = 'align-self:flex-start;color:var(--text-soft)' + regen.textContent = '↻ Regenerate' + tokenMeta.appendChild(document.createTextNode(' · ')) + tokenMeta.appendChild(regen) + + var rawToggle = document.createElement('button') + rawToggle.className = 'pp-footer-btn' + rawToggle.style.cssText = 'align-self:flex-start;color:var(--text-soft)' + rawToggle.textContent = '▶ Show raw token' + tokenStep.appendChild(rawToggle) + + var token = document.createElement('textarea') + token.className = 'pp-search' + token.rows = 5 + token.readOnly = true + token.style.cssText = 'display:none;resize:vertical;font-family:ui-monospace,monospace' + tokenStep.appendChild(token) + + var status = document.createElement('div') + status.className = 'pp-waiting-sub' + status.style.textAlign = 'left' + wrap.appendChild(status) + + var pairedProfile = null + var tokenValue = '' + var bookmarkletUrl = '' + var expiresAt = 0 + var expiryTimer = null + var regenTimer = null + + function setInstallEnabled(enabled) { + dragCol.style.opacity = enabled ? '1' : '0.55' + copyCol.style.opacity = enabled ? '1' : '0.55' + chip.draggable = enabled + chip.style.cursor = enabled ? 'grab' : 'default' + copyLink.disabled = !enabled + copyToken.disabled = !enabled + } + + function updateChipName() { + chip.querySelector('span').textContent = name.value.trim() || 'Portpass Autofill' + } + name.oninput = updateChipName + + function updateExpiry() { + var remaining = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)) + var m = Math.floor(remaining / 60) + var s = String(remaining % 60).padStart(2, '0') + tokenMeta.firstChild.nodeValue = remaining > 0 + ? 'Stays valid while this page is open · expires in ' + m + ':' + s + : 'Stays valid while this page is open · 10 min after you copy' + if (remaining <= 0 && expiryTimer) clearInterval(expiryTimer) + } + + function currentRelayUrl() { + return relay.value.trim() || 'http://localhost:7577' + } + + function setBusy(msg) { + status.textContent = msg || '' + setInstallEnabled(false) + copyToken.disabled = true + } + + function setProfile(profile) { + pairedProfile = profile + tokenValue = '' + token.value = '' + expiresAt = 0 + if (expiryTimer) clearInterval(expiryTimer) + tokenMeta.firstChild.nodeValue = 'Stays valid while this page is open · 10 min after you copy' + code.textContent = profile.displayCode + bookmarkletUrl = makeDelegateBookmarkletUrl(location.href.split('autofill.html')[0], profile.delegateId, currentRelayUrl()) + chip.href = bookmarkletUrl + setInstallEnabled(true) + status.textContent = '' + } + + function regenerateProfile(reason) { + setBusy(reason || 'Creating profile…') + createPairingProfile(currentRelayUrl()).then(setProfile).catch(function(e) { + status.textContent = e.message || 'Could not create pairing profile' + }) + } + + rawToggle.onclick = function() { + var open = token.style.display === 'none' + token.style.display = open ? '' : 'none' + rawToggle.textContent = open ? '▼ Hide raw token' : '▶ Show raw token' + } + + copyToken.onclick = function() { + if (!pairedProfile) return + tokenValue = pairingTokenForProfile(pairedProfile, '') + token.value = tokenValue + navigator.clipboard.writeText(tokenValue).then(function() { + copyToken.innerHTML = SVG_CHECK_SM + ' Copied' + expiresAt = Date.now() + 10 * 60 * 1000 + updateExpiry() + if (expiryTimer) clearInterval(expiryTimer) + expiryTimer = setInterval(updateExpiry, 1000) + setTimeout(function() { copyToken.innerHTML = SVG_COPY + ' Copy token' }, 1800) + }).catch(function() { + token.focus() + token.select() + }) + } + + regen.onclick = function() { regenerateProfile('Regenerating profile…') } + + relay.oninput = function() { + clearTimeout(regenTimer) + setBusy('Relay changed — regenerating token and bookmarklet…') + regenTimer = setTimeout(function() { regenerateProfile('Regenerating profile…') }, 500) + } + + copyLink.onclick = function() { + if (!bookmarkletUrl) return + navigator.clipboard.writeText(bookmarkletUrl).then(function() { + copyLink.innerHTML = SVG_CHECK_SM + ' Copied' + setTimeout(function() { copyLink.innerHTML = SVG_COPY + ' Copy link' }, 1800) + }) + } + + $content.appendChild(wrap) + regenerateProfile('Creating profile…') + } + // ── Record selection — shared entry point ────────────────────────────────── // Called when a picker row is clicked (or auto-advanced for single exact match). function selectRecord(rec, fromPhase) { @@ -1146,6 +1777,35 @@ setTimeout(function() { try { searchInput.focus() } catch(_) {} }, 50) } + function showNearMatchSummary(count) { + hideSearchUI() + $subheader.style.display = 'none' + $content.innerHTML = '' + + var notice = document.createElement('div') + notice.className = 'pp-notice' + notice.innerHTML = SVG_WARN + ' No exact password match for ' + var em = document.createElement('strong') + em.textContent = currentUrl + em.style.color = 'var(--text)' + notice.appendChild(em) + $content.appendChild(notice) + + var ph = document.createElement('div') + ph.className = 'pp-placeholder' + var line1 = document.createElement('div') + line1.textContent = count + ? count + ' near ' + (count === 1 ? 'match was' : 'matches were') + ' found.' + : 'No near matches were found.' + var line2 = document.createElement('button') + line2.className = 'pp-footer-btn' + line2.textContent = 'View in Portpass' + line2.onclick = function() { window.open(PORTPASS_URL, '_blank', 'noopener'); window.close() } + ph.appendChild(line1) + ph.appendChild(line2) + $content.appendChild(ph) + } + function showSearchResults(records) { var hdr = $content.querySelector('.pp-section-hdr') var placeholder = $content.querySelector('.pp-placeholder') @@ -1175,21 +1835,14 @@ var ecdhSpkiB64 = btoa(String.fromCharCode.apply(null, new Uint8Array(ecdhSpki))) nonce = crypto.randomUUID() - var sigB64 = null, pubSpkiB64 = null - if (cprivKey) { + var sigB64 = null, pubSpkiB64 = null, helloTs = Date.now() + if (pairedProfile && pairedProfile.signingKey) { try { - var signingKey = await crypto.subtle.importKey( - 'jwk', cprivKey, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'] - ) - var ecdsaPubKey = await crypto.subtle.importKey( - 'jwk', { kty: cprivKey.kty, crv: cprivKey.crv, x: cprivKey.x, y: cprivKey.y }, - { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'] - ) - var ecdsaSpki = await crypto.subtle.exportKey('spki', ecdsaPubKey) - pubSpkiB64 = btoa(String.fromCharCode.apply(null, new Uint8Array(ecdsaSpki))) + var signingKey = pairedProfile.signingKey + pubSpkiB64 = cpPubSpkiB64 var sigBytes = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, signingKey, - new TextEncoder().encode(JSON.stringify({ nonce: nonce, ecdhSpki: ecdhSpkiB64 })) + new TextEncoder().encode(JSON.stringify({ version: 1, sender: delegateId || '', recipient: pairedProfile.dashboardId || '', nonce: nonce, ecdhSpki: ecdhSpkiB64, ts: helloTs })) ) sigB64 = btoa(String.fromCharCode.apply(null, new Uint8Array(sigBytes))) } catch(_) {} @@ -1201,7 +1854,7 @@ if (msg.type === 'hello-response') onHelloResponse(msg) else if (msg.type === 'error') sendError(msg.message) } - var helloMsg = { type: 'hello', pubkey: pubJwk, ecdhSpki: ecdhSpkiB64, nonce: nonce } + var helloMsg = { type: 'hello', pubkey: pubJwk, ecdhSpki: ecdhSpkiB64, nonce: nonce, delegateId: delegateId, ts: helloTs } if (sigB64 && pubSpkiB64) { helloMsg.sig = sigB64; helloMsg.pub = pubSpkiB64 } ch.postMessage(helloMsg) } catch(_) { sendError('Key exchange failed') } diff --git a/pwa/src/App.svelte b/pwa/src/App.svelte index 4042d0b..9486c7b 100644 --- a/pwa/src/App.svelte +++ b/pwa/src/App.svelte @@ -41,6 +41,7 @@ const age = Date.now() - ts + if (age > 60000 || age < -5000) return const vaultUuid = get(selectedFile)?.uuid ?? '' if (!vaultUuid) return diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index a522118..ac7f7b5 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -13,7 +13,7 @@ import { addSecondaryCredential, removeSecondaryCredential } from './secondaryVaults.js' import { getSwitchboardUrl, getCrossProfileEnabled } from './delegates.js' import { isBiometricEnrolledForFile, unlockWithBiometric } from './biometric.js' - import { getDelegates, verifyDelegate, recordFill } from './delegates.js' + import { getDelegates, verifyDelegateById, recordFill } from './delegates.js' import Icon from './Icon.svelte' import RecordList from './RecordList.svelte' import RecordRead from './RecordRead.svelte' @@ -337,12 +337,26 @@ } // Encrypt data with the autofill popup's ECDH public key and send as a ws-relay reply. - async function sbEncryptReply(replyTo, ecdhSpkiB64Arg, data) { + async function sha256B64(bytes) { + const digest = await crypto.subtle.digest('SHA-256', bytes) + return btoa(String.fromCharCode(...new Uint8Array(digest))) + } + + async function sbEncryptReply(replyTo, ecdhSpkiB64Arg, data, meta = {}) { const ephPair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey']) const relayPub = await crypto.subtle.importKey('spki', Uint8Array.from(atob(ecdhSpkiB64Arg), c => c.charCodeAt(0)), { name: 'ECDH', namedCurve: 'P-256' }, false, []) const sk = await crypto.subtle.deriveKey({ name: 'ECDH', public: relayPub }, ephPair.privateKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt']) const iv = crypto.getRandomValues(new Uint8Array(12)) - const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, sk, new TextEncoder().encode(JSON.stringify(data))) + const envelope = { + v: 1, + requestId: replyTo, + requestHash: meta.requestHash || null, + delegateId: meta.delegateId || null, + recipient: meta.recipient || 'autofill-popup', + ts: Date.now(), + data, + } + const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, sk, new TextEncoder().encode(JSON.stringify(envelope))) const ephPubJwk = await crypto.subtle.exportKey('jwk', ephPair.publicKey) if (_sbWs) _sbWs.send(JSON.stringify({ type: 'reply', replyTo, @@ -382,6 +396,15 @@ let _sbWs = null let _sbConnecting = false + const sbSeenNonces = new Set() + const bcSeenNonces = new Set() + + function rememberNonce(seen, nonce) { + if (!nonce || seen.has(nonce)) return false + seen.add(nonce) + setTimeout(() => seen.delete(nonce), 120000) + return true + } function sbWsUrl() { // Accept both ws:// and http:// stored formats @@ -405,13 +428,16 @@ ws.onmessage = async (event) => { try { const msg = JSON.parse(event.data) + if (msg._switchboard_origin !== location.origin) return if (msg.type !== 'publish') return const age = Date.now() - msg.ts if (age > 60000 || age < -5000) return - const spkiBytes = Uint8Array.from(atob(msg.pub), c => c.charCodeAt(0)) const sigBytes = Uint8Array.from(atob(msg.sig), c => c.charCodeAt(0)) - const message = new TextEncoder().encode(JSON.stringify({ url: msg.url, nonce: msg.replyTo, ecdh: msg.ecdh, ts: msg.ts })) - const verified = await verifyDelegate(dbKey, spkiBytes, message, sigBytes) + if (!rememberNonce(sbSeenNonces, msg.replyTo)) return + const message = new TextEncoder().encode(JSON.stringify({ version: 1, sender: msg.delegateId || '', recipient: 'local-dashboard', url: msg.url || '', nonce: msg.replyTo, ecdh: msg.ecdh || '', ts: msg.ts, + msgType: msg.msgType || '', uuid: msg.uuid || '', vaultUuid: msg.vaultUuid || '', query: msg.query || '' })) + const requestHash = await sha256B64(message) + const verified = await verifyDelegateById(dbKey, msg.delegateId || '', message, sigBytes) if (!verified) return if (msg.msgType === 'fill-done') { await recordFill(dbKey, verified.id, 'relay') @@ -430,38 +456,33 @@ return } if (msg.msgType === 'search') { - const allVaults = [ - { uuid: dbKey, readonly: get(selectedFile)?.readonly || false }, - ...get(secondaryVaults).map(v => ({ uuid: v.uuid, readonly: v.readonly || false })), - ] - const results = [] - for (const { uuid: vaultUuid, readonly } of allVaults) { - try { - for (const recUuid of searchRecords(vaultUuid, msg.query || '', 0)) { - const rec = getRecordData(vaultUuid, recUuid) - results.push({ uuid: recUuid, vaultUuid: vaultUuid === dbKey ? null : vaultUuid, - title: rec.Title, existingUrl: rec.URL || '', matchType: 'search', readonly }) - } - } catch {} - } - try { await sbEncryptReply(msg.replyTo, msg.ecdh, results) } catch {} + try { await sbEncryptReply(msg.replyTo, msg.ecdh, [], { delegateId: verified.id, requestHash }) } catch {} return } if (msg.msgType === 'fill-uuid') { try { const rec = getRecordData(msg.vaultUuid || dbKey, msg.uuid) + if (canonicalURL(rec.URL || '') !== canonicalURL(msg.url || '')) { + if (_sbWs) _sbWs.send(JSON.stringify({ type: 'reply', replyTo: msg.replyTo, error: 'Credentials require an exact saved URL match' })) + return + } const rf = buildRecordFields(msg.uuid, msg.vaultUuid || null) await sbEncryptReply(msg.replyTo, msg.ecdh, [{ uuid: msg.uuid, vaultUuid: msg.vaultUuid || null, title: rec.Title, matchType: 'search', existingUrl: rec.URL || '', autotype: rf.autotype, sensitiveCodes: rf.sensitiveCodes, fields: rf.fields, - }]) + }], { delegateId: verified.id, requestHash }) } catch(e) { if (_sbWs) _sbWs.send(JSON.stringify({ type: 'reply', replyTo: msg.replyTo, error: e.message || 'Could not get credentials' })) } return } - await processAutofillIntent({ url: msg.url, nonce: msg.replyTo, ecdhSpkiB64: msg.ecdh }) + const records = autofillFindRecords(msg.url) + const exact = records.filter(r => r.matchType === 'exact') + const payload = exact.length + ? { records: exact, theme: localStorage.getItem('theme') || 'dark', accent: localStorage.getItem('accent') || 'amber' } + : { records: [], nearMatchCount: records.length, theme: localStorage.getItem('theme') || 'dark', accent: localStorage.getItem('accent') || 'amber' } + await sbEncryptReply(msg.replyTo, msg.ecdh, payload, { delegateId: verified.id, requestHash }) } catch(e) {} } let closed = false @@ -512,96 +533,6 @@ return '' } - $effect(() => { - if (!isPopup) return - - let sessionKey = null // AES-256-GCM key derived from ECDH; null until hello exchange - let helloInProgress = false // guard against duplicate hellos overwriting the session key - - async function handleMessage(event) { - if (!event.source) return - const msg = event.data - if (!msg?.type) return - - if (msg.type === 'hello') { - // Ignore a second hello while we're still processing the first one. - // Without this guard, a retry from the bookmarklet could overwrite sessionKey - // after the bookmarklet has already derived its key from the first response. - if (helloInProgress) return - helloInProgress = true - try { - const openerPub = await crypto.subtle.importKey( - 'jwk', msg.pubkey, - { name: 'ECDH', namedCurve: 'P-256' }, false, [] - ) - const pair = await crypto.subtle.generateKey( - { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveKey'] - ) - sessionKey = await crypto.subtle.deriveKey( - { name: 'ECDH', public: openerPub }, - pair.privateKey, - { name: 'AES-GCM', length: 256 }, false, ['encrypt'] - ) - const pubJwk = await crypto.subtle.exportKey('jwk', pair.publicKey) - event.source.postMessage({ type: 'hello', pubkey: pubJwk }, event.origin) - } catch { - sessionKey = null - event.source.postMessage({ type: 'error', message: 'Key exchange failed' }, event.origin) - } finally { - helloInProgress = false - } - return - } - - if (msg.type === 'query') { - if (!sessionKey) { - event.source.postMessage( - { type: 'error', message: 'No secure session — click the bookmarklet again' }, - event.origin - ) - return - } - - // URL search: return list of candidate records for the bookmarklet picker. - if (msg.url !== undefined) { - event.source.postMessage({ type: 'records', records: autofillFindRecords(msg.url) }, event.origin) - return - } - - // Targeted fetch: return encrypted credentials for the specified (or selected) record. - const uuid = msg.uuid || selectedUUID - const vaultUuid = msg.uuid ? (msg.vaultUuid || null) : selectedVaultUuid - if (!uuid) { - event.source.postMessage({ type: 'error', message: 'Open a record in Portpass first' }, event.origin) - return - } - try { - const result = await autofillEncryptRecord(sessionKey, uuid, vaultUuid) - event.source.postMessage({ type: 'record', ...result }, event.origin) - } catch (e) { - event.source.postMessage({ type: 'error', message: e.message }, event.origin) - } - return - } - - if (msg.type === 'save-url') { - if (!sessionKey) { - event.source.postMessage({ type: 'error', message: 'No secure session' }, event.origin) - return - } - try { - await autofillSaveURL(msg.uuid, msg.vaultUuid || null, msg.url) - event.source.postMessage({ type: 'url-saved' }, event.origin) - } catch (e) { - event.source.postMessage({ type: 'error', message: e.message }, event.origin) - } - } - } - - window.addEventListener('message', handleMessage) - return () => { window.removeEventListener('message', handleMessage); sessionKey = null } - }) - // BroadcastChannel handler — lets the autofill popup (opened by the bookmarklet) reach this // unlocked tab across browsing-context-group boundaries. Only the main (non-popup) tab // handles these messages so the popup's own dashboard (when unlocked directly) is unaffected. @@ -640,17 +571,22 @@ ch.postMessage({ type: 'error', message: 'Autofill request not authorized — reinstall the bookmarklet', nonce: msg.nonce }) return } - const spkiBytes = Uint8Array.from(atob(msg.pub), c => c.charCodeAt(0)) const sigBytes = Uint8Array.from(atob(msg.sig), c => c.charCodeAt(0)) - const sigMsg = new TextEncoder().encode(JSON.stringify({ nonce: msg.nonce, ecdhSpki: msg.ecdhSpki })) - const verified = await verifyDelegate(dbKey, spkiBytes, sigMsg, sigBytes) + const age = Date.now() - (msg.ts || 0) + if (age > 60000 || age < -5000 || !rememberNonce(bcSeenNonces, msg.nonce)) { + ch.postMessage({ type: 'error', message: 'Autofill request expired — click the bookmarklet again', nonce: msg.nonce }) + return + } + const sigMsg = new TextEncoder().encode(JSON.stringify({ version: 1, sender: msg.delegateId || '', recipient: 'local-dashboard', nonce: msg.nonce, ecdhSpki: msg.ecdhSpki, ts: msg.ts })) + const verified = await verifyDelegateById(dbKey, msg.delegateId || '', sigMsg, sigBytes) if (!verified) { ch.postMessage({ type: 'error', message: 'Autofill request not authorized', nonce: msg.nonce }) return } bcSessionDelegateId = verified.id + const ecdhSpkiBytes = Uint8Array.from(atob(msg.ecdhSpki), c => c.charCodeAt(0)) const openerPub = await crypto.subtle.importKey( - 'jwk', msg.pubkey, { name: 'ECDH', namedCurve: 'P-256' }, false, [] + 'spki', ecdhSpkiBytes, { name: 'ECDH', namedCurve: 'P-256' }, false, [] ) const pair = await crypto.subtle.generateKey( { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveKey'] @@ -835,9 +771,53 @@ } } + async function checkPrimaryConflict() { + const handle = $selectedFile?.handle + if (!handle || primaryModified === null) return true + try { + const file = await handle.getFile() + if (file.lastModified !== primaryModified) { + let realConflict = true + if (primaryHead) { + try { + const buf = await file.slice(72, 152).arrayBuffer() + realConflict = !sameHead(new Uint8Array(buf), primaryHead) + } catch {} + } + if (realConflict) { + if (!confirm('This vault was modified since it was loaded.\n\nSaving will overwrite those changes. Save anyway?')) return false + primaryModified = file.lastModified + } + } + } catch {} + return true + } + async function saveRecord(draft) { try { const targetVault = isNew ? (newRecordVaultUuid || dbKey) : (selectedVaultUuid || dbKey) + + if (targetVault === dbKey) { + if (!await checkPrimaryConflict()) return + } else { + const sv = get(secondaryVaults).find(v => v.uuid === targetVault) + if (sv?.handle && !sv.readonly && secondaryModified[targetVault] !== undefined) { + try { + const file = await sv.handle.getFile() + if (file.lastModified !== secondaryModified[targetVault]) { + let realConflict = true + if (secondaryHead[targetVault]) { + try { + const buf = await file.slice(72, 152).arrayBuffer() + realConflict = !sameHead(new Uint8Array(buf), secondaryHead[targetVault]) + } catch {} + } + if (realConflict && !confirm(`"${sv.name || sv.filename}" was modified since it was loaded.\n\nSaving will overwrite those changes. Save anyway?`)) return + } + } catch {} + } + } + const uuid = updateRecordFields(targetVault, isNew ? null : selectedUUID, draft) selectedUUID = uuid ?? selectedUUID record = getRecordData(targetVault, selectedUUID) @@ -860,21 +840,6 @@ : v )) selectedVaultUuid = targetVault - if (sv.handle && !sv.readonly && secondaryModified[targetVault] !== undefined) { - try { - const file = await sv.handle.getFile() - if (file.lastModified !== secondaryModified[targetVault]) { - let realConflict = true - if (secondaryHead[targetVault]) { - try { - const buf = await file.slice(72, 152).arrayBuffer() - realConflict = !sameHead(new Uint8Array(buf), secondaryHead[targetVault]) - } catch {} - } - if (realConflict && !confirm(`"${sv.name || sv.filename}" was modified since it was loaded.\n\nSaving will overwrite those changes. Save anyway?`)) return - } - } catch {} - } const data = saveDatabase(targetVault) if (sv.handle && !sv.readonly) { const w = await sv.handle.createWritable() @@ -915,19 +880,15 @@ pendingDeleteTimer = setTimeout(async () => { try { - wasmDeleteRecord(targetVault, pendingDeleteUUID) if (targetVault === dbKey) { + if (!await checkPrimaryConflict()) return + wasmDeleteRecord(targetVault, pendingDeleteUUID) dbItems.set(getDatabaseData(dbKey)) isDirty = true await saveFile(true) } else { const sv = get(secondaryVaults).find(v => v.uuid === targetVault) if (sv) { - const items = getDatabaseData(targetVault) - secondaryVaults.update(vs => vs.map(v => v.uuid === targetVault - ? { ...v, items: items.map(i => ({ ...i, vaultUuid: targetVault })) } - : v - )) if (sv.handle && !sv.readonly && secondaryModified[targetVault] !== undefined) { try { const file = await sv.handle.getFile() @@ -943,6 +904,12 @@ } } catch {} } + wasmDeleteRecord(targetVault, pendingDeleteUUID) + const items = getDatabaseData(targetVault) + secondaryVaults.update(vs => vs.map(v => v.uuid === targetVault + ? { ...v, items: items.map(i => ({ ...i, vaultUuid: targetVault })) } + : v + )) const data = saveDatabase(targetVault) if (sv.handle && !sv.readonly) { const w = await sv.handle.createWritable() @@ -1022,7 +989,7 @@ isDirty = false try { lastSave = getDatabaseInfo(dbKey)?.when ?? '' } catch {} if (!silent) showToast('Vault saved') - return + return true } } @@ -1037,7 +1004,7 @@ realConflict = !sameHead(new Uint8Array(buf), primaryHead) } catch {} } - if (realConflict && !confirm('This vault was modified since it was loaded.\n\nSaving will overwrite those changes. Save anyway?')) return + if (realConflict && !confirm('This vault was modified since it was loaded.\n\nSaving will overwrite those changes. Save anyway?')) return false } } catch {} } @@ -1052,8 +1019,10 @@ isDirty = false try { lastSave = getDatabaseInfo(dbKey)?.when ?? '' } catch {} if (!silent) showToast('Vault saved') + return true } catch (e) { if (e.name !== 'AbortError') showToast('Save failed: ' + e.message) + return false } } @@ -1153,7 +1122,7 @@ async function copyFieldViaWasm(recordVaultUuid, recordUuid, fieldname) { try { - const { hash } = copyFieldToClipboard(recordVaultUuid, recordUuid, fieldname, true) + const { hash } = await copyFieldToClipboard(recordVaultUuid, recordUuid, fieldname, true) const hashBytes = hexToBytes(hash) clipHash = hashBytes const token = ++sessionSerial @@ -1169,7 +1138,7 @@ async function copyCustomFieldViaWasm(recordVaultUuid, recordUuid, fieldname) { try { - const { hash } = copyCustomFieldToClipboard(recordVaultUuid, recordUuid, fieldname, true) + const { hash } = await copyCustomFieldToClipboard(recordVaultUuid, recordUuid, fieldname, true) const hashBytes = hexToBytes(hash) clipHash = hashBytes const token = ++sessionSerial @@ -1191,10 +1160,10 @@ return selectedVaultUuid || dbKey } - async function copyTOTPForUUID(uuid) { + async function copyTOTPForUUID(uuid, vaultUuidHint = null) { try { - const vaultUuid = vaultUuidForRecord(uuid) - wasmCopyTOTP(vaultUuid, uuid) + const vaultUuid = vaultUuidHint ?? vaultUuidForRecord(uuid) + await wasmCopyTOTP(vaultUuid, uuid) if (clearTimer) { clearTimeout(clearTimer); clearTimer = null } clipHash = null const token = ++sessionSerial @@ -1223,15 +1192,34 @@ async function saveDBFields(fields) { try { updateDBFields(dbKey, fields) - await saveFile(true) - dbName = fields.Name ?? dbName // fields uses PascalCase for the WASM write API - vaultDirty = false - showToast('Vault info saved') + if (await saveFile(true)) { + dbName = fields.Name ?? dbName // fields uses PascalCase for the WASM write API + vaultDirty = false + showToast('Vault info saved') + } } catch (e) { showToast('Failed to save vault info: ' + e.message) } } + async function saveSecondaryDBFields(uuid) { + const sv = get(secondaryVaults).find(v => v.uuid === uuid) + if (!sv || sv.readonly) return + try { + const data = saveDatabase(uuid) + if (sv.handle) { + const w = await sv.handle.createWritable() + await w.write(data) + await w.close() + secondaryHead[uuid] = data.slice(72, 152) + try { secondaryModified[uuid] = (await sv.handle.getFile()).lastModified } catch {} + showToast('Saved to ' + (sv.name || sv.filename), null, 2000) + } + } catch (e) { + if (e.name !== 'AbortError') showToast('Failed to save: ' + e.message) + } + } + function closeVaultSheet() { if (vaultDirty) { if (!confirm('Discard unsaved changes?')) return @@ -1244,6 +1232,12 @@ get(secondaryVaults).forEach(v => closeDatabase(v.uuid)) closeDatabase(dbKey) secondaryVaults.set([]) + dbItems.set([]) + selectedFile.set(null) + if (clearTimer) { clearTimeout(clearTimer); clearTimer = null } + clipHash = null + clipboardSession.set(null) + clipboardContext.set(null) onclosed() } @@ -1260,6 +1254,12 @@ get(secondaryVaults).forEach(v => closeDatabase(v.uuid)) closeDatabase(dbKey) secondaryVaults.set([]) + dbItems.set([]) + selectedFile.set(null) + if (clearTimer) { clearTimeout(clearTimer); clearTimer = null } + clipHash = null + clipboardSession.set(null) + clipboardContext.set(null) onclosed() } @@ -1485,7 +1485,7 @@ const next = flatList[0] if (next) { selectRecord(next.uuid, next.vaultUuid); searchInput?.blur() } } else { - const idx = flatList.findIndex(i => i.uuid === selectedUUID) + const idx = flatList.findIndex(i => i.uuid === selectedUUID && i.vaultUuid === selectedVaultUuid) if (idx === flatList.length - 1) { record = null; selectedUUID = null; searchInput?.focus() } else { @@ -1501,7 +1501,7 @@ const prev = flatList[flatList.length - 1] if (prev) { selectRecord(prev.uuid, prev.vaultUuid); searchInput?.blur() } } else { - const idx = flatList.findIndex(i => i.uuid === selectedUUID) + const idx = flatList.findIndex(i => i.uuid === selectedUUID && i.vaultUuid === selectedVaultUuid) if (idx === 0) { record = null; selectedUUID = null; searchInput?.focus() } else { @@ -1524,7 +1524,7 @@ if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); startEdit(); return } if (e.key === 'Enter' && !e.target.matches('button, a')) { e.preventDefault() - if (record.URL) window.open(absoluteUrl(record.URL), '_blank') + if (record.URL) window.open(absoluteUrl(record.URL), '_blank', 'noopener,noreferrer') return } if (e.ctrlKey && e.key === 'c') { @@ -1611,7 +1611,7 @@ {/if} - + - {/if} {#if contextMenu.rec.TwoFactorKey !== undefined} - {/if} {#each (contextMenu.rec.CustomFields ?? []).slice(0, 9) as cf, i} {/each} {/if} - +
+
+
+

Same-profile bookmarklet

+ SIMPLEST +
+

+ Portpass and the sites you log in to share one browser profile. Quickest to set up; the bookmarklet is exposed to any extensions in this profile. +

+ +
- - - {#if advancedOpen} -
-
- Enable cross-profile autofill -
- - +
+
+

Cross-profile pairing

+ MOST ISOLATED + + + {$crossProfileEnabled && $switchboardConnected ? 'Active' : 'Inactive'} + +
+

+ Keep Portpass in this clean vault profile and fill from your everyday browser. Best protection from extensions; needs a one-time pairing. +

+
+ 1 +
+
In your everyday browser OTHER PROFILE
+
Open {pairingPageUrl}, name and install the bookmarklet, then copy the pairing token.
- {#if $crossProfileEnabled} +
+ 2 +
+
Here, in the vault profile THIS PROFILE
+
Click Pair everyday profile, paste the token, and confirm the code matches. The relay turns on automatically.
+
+
+ +
+
+ + + {$crossProfileEnabled && $switchboardConnected ? 'Ready' : (!$crossProfileEnabled ? 'Relay disabled' : 'Relay not connected')} + · {$crossProfileEnabled ? 'relay on' : 'relay off'} · {totalRelayCount} {totalRelayCount === 1 ? 'use' : 'uses'}{lastRelayUsed ? ` · Last ${fmtRelative(lastRelayUsed)}` : ''} + +
+ +
+ {#if relayAdvancedOpen} +
+
+ Use local switchboard relay +
+ + +
+
+ {#if !delegates.length} +
+ Pair an everyday profile before enabling the relay. +
+ {/if} +
+ The pairing page shows an http:// URL. Portpass converts it to ws:// when opening the WebSocket relay. +
{#if switchboardUrlDirty}
{/if} -
- - - {$switchboardConnected ? 'Cross-profile autofill ready' : 'websocket relay not connected'} - -
-
- Count of cross-profile autofill uses: {totalRelayCount}{#if lastRelayUsed} · Last {fmtRelative(lastRelayUsed)}{/if} -
- {/if} -
- {/if} +
+ {/if} +
{/if} @@ -700,7 +808,7 @@ onkeydown={e => { if (e.key === 'Escape' && !newDelegateBusy) cancelOrSave() }}>