From 8ed1c37cbe57c3905a007d0c25e3d56ec1e308c1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 19 May 2026 21:21:50 +0200 Subject: [PATCH 01/99] Add autofill feature: bookmarklet, popup protocol, and autotype field Adds a bookmarklet-based autofill flow: a javascript: URL (installable from VaultSheet) opens Portpass as a popup, performs an ECDH key exchange, and returns AES-GCM-encrypted credentials to a page overlay that types them into the focused form field per a per-record autotype sequence. - pwsafe: read/write Autotype field (UUID 0x0025) alongside existing fields - RecordEdit/RecordRead: autotype sequence input with validation and read view - Dashboard: postMessage handler (hello/query protocol, gated on isPopup) - App: popup mode detection, locked-vault error handler, window.name for reuse - StartPage: "Unlock to use Autofill" hint when opened as popup - VaultSheet: AUTOFILL section with bookmarklet chip and copy-link button - bookmarklet.js: self-contained IIFE; sendRetry covers WASM load time - Tests: autofill.spec.ts, autofill_popup.spec.ts, bookmarklet.spec.ts, vault_sheet.spec.ts (bookmarklet UI tests) Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + SECURITY.md | 14 ++ cmd/wasm/main.go | 4 + pwa/src/App.svelte | 28 ++- pwa/src/lib/Dashboard.svelte | 110 ++++++++++- pwa/src/lib/RecordEdit.svelte | 49 ++++- pwa/src/lib/RecordRead.svelte | 19 +- pwa/src/lib/StartPage.svelte | 4 +- pwa/src/lib/VaultSheet.svelte | 98 ++++++++++ pwa/src/lib/bookmarklet.js | 237 +++++++++++++++++++++++ pwa/tests/autofill.spec.ts | 154 +++++++++++++++ pwa/tests/autofill_popup.spec.ts | 321 +++++++++++++++++++++++++++++++ pwa/tests/bookmarklet.spec.ts | 221 +++++++++++++++++++++ pwa/tests/vault_sheet.spec.ts | 63 ++++++ pwsafe/record_test.go | 48 ++++- 15 files changed, 1363 insertions(+), 10 deletions(-) create mode 100644 pwa/src/lib/bookmarklet.js create mode 100644 pwa/tests/autofill.spec.ts create mode 100644 pwa/tests/autofill_popup.spec.ts create mode 100644 pwa/tests/bookmarklet.spec.ts diff --git a/.gitignore b/.gitignore index 4886018..47fabce 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ test-results/ design-wip/ CLAUDE.md AUTOFILL.md +AUTOFILL-RESEARCH.md +AUTOCOPY.md LAZYSENSITIVE.md SUGGESTIONS.md .claude/ +/wasm diff --git a/SECURITY.md b/SECURITY.md index c91795e..e9a1cba 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -56,6 +56,8 @@ Extensions are installed per-profile. A profile with no extensions has no extens **Workflow:** Alt-tab to the Portpass window when you need a password, copy it, and paste it in your main browser. The 30-second clipboard autoclear limits the window during which a compromised extension could read it. +**Autofill and the dedicated profile.** The Autofill bookmarklet feature requires Portpass to run in the same browser profile as the pages you are filling — making the dedicated clean-profile approach above incompatible with Autofill. This is a deliberate opt-in tradeoff: you gain the convenience of autofill at the cost of exposing Portpass to any extensions installed in your main profile. Users who want the strongest extension isolation should continue using Portpass in a clean profile with manual copy and paste. + --- ## Vault file security @@ -89,12 +91,24 @@ When secondary vaults are linked to a primary vault, their master passwords are Portpass automatically clears the clipboard 30 seconds after you copy a password, reducing the window during which it can be read by another app. +### Clipboard sniffing is a universal risk for password managers + +Any password manager that uses the clipboard to transfer passwords — including native apps such as 1Password and Bitwarden — shares this exposure. A browser extension with `clipboardRead` permission can call `navigator.clipboard.readText()` at any time and capture whatever is currently in the clipboard, regardless of which app placed it there. This applies equally to passwords copied from Portpass, from a native password manager, or typed by hand and then cut. + +Portpass's 30-second autoclear and the Autocopy bookmarklet's immediate post-paste clear both reduce the exposure window, but neither can prevent an actively polling adversary from reading the clipboard before the clear fires. + +**Checking which extensions have clipboard access.** In Chrome, open `chrome://extensions/` → Details → Permissions for each extension individually. In Firefox, go to `about:addons` → click the extension → Permissions tab. Neither browser provides a consolidated view; you must check each extension one by one. Any extension you do not recognise and trust that lists clipboard read access should be treated as a risk. + ### Platform differences Clipboard access is restricted at the OS level on **iOS, Android, and Linux (Wayland)**. On these platforms, only the foreground app can read the clipboard, so copied passwords are well-protected from background processes. **macOS** restricts clipboard access for non-browser apps, but browser extensions running in the same profile can still read it. Use a dedicated browser profile with no extensions (see above). +### The better answer: passkeys + +Passkeys (WebAuthn) eliminate the clipboard and extension risks entirely — there is no password to copy, intercept, or sniff. Authentication is a cryptographic challenge/response that never leaves your device. If a site you use offers passkey login, using it is the strongest choice available. Portpass is for sites that still require a password; for everything else, prefer your platform's passkey manager (iCloud Keychain, Google Password Manager, Windows Hello, etc.). + **Windows and Linux (X11)** have no OS-level clipboard isolation which means any running process can read the clipboard at any time. Users on these platforms should be especially careful to use the dedicated browser profile mitigation, and be aware that other apps may be able to read a copied password before the clipboard gets cleared. --- diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index a3042f4..3a517bc 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -128,6 +128,7 @@ type recordView struct { Username string `json:"Username"` URL string `json:"URL"` Email string `json:"Email"` + Autotype string `json:"Autotype"` ModTime string `json:"ModTime"` Password *string `json:"Password"` Notes *string `json:"Notes"` @@ -185,6 +186,7 @@ func recordToView(rec pwsafe.Record) recordView { Username: rec.Username, URL: rec.URL, Email: rec.Email, + Autotype: rec.Autotype, ModTime: mt, Password: sensitiveString(rec.Password), Notes: sensitiveString(rec.Notes), @@ -306,6 +308,8 @@ func updateRecordFields(this js.Value, args []js.Value) interface{} { rec.Email = value case "Notes": rec.Notes = value + case "Autotype": + rec.Autotype = value case "TwoFactorKey": if value == "" { rec.TwoFactorKey = nil diff --git a/pwa/src/App.svelte b/pwa/src/App.svelte index ee4e9b6..e0b2499 100644 --- a/pwa/src/App.svelte +++ b/pwa/src/App.svelte @@ -10,6 +10,7 @@ let view = $state('start') // 'start' | 'dashboard' let hasBeenUnlocked = $state(false) let multipleInstances = $state(false) + let isPopup = $state(false) let theme = $state(localStorage.getItem('theme') || 'dark') let accent = $state(localStorage.getItem('accent') || 'amber') @@ -18,6 +19,23 @@ $effect(() => { localStorage.setItem('theme', theme) }) $effect(() => { localStorage.setItem('accent', accent) }) + // Respond to autofill queries when vault is locked (Dashboard handles unlocked case). + $effect(() => { + if (!isPopup) return + function handleLockedQuery(event) { + if (view !== 'start') return + const t = event.data?.type + if (t !== 'query' && t !== 'hello') return + if (!event.source) return + event.source.postMessage( + { type: 'error', message: 'Vault is locked — unlock Portpass first' }, + event.origin + ) + } + window.addEventListener('message', handleLockedQuery) + return () => window.removeEventListener('message', handleLockedQuery) + }) + const THEME_COLORS = { light: { bg: '#f6f3ee', text: '#1c1f24' }, dark: { bg: '#14161a', text: '#f1ede4' }, @@ -49,6 +67,11 @@ }) onMount(async () => { + isPopup = window.opener !== null + // Give this window a stable name so the bookmarklet's window.open() can find and + // focus the existing tab instead of always opening a new one. + if (!isPopup) window.name = 'portpass_autofill' + const mq = window.matchMedia('(min-width: 768px)') isDesktop = mq.matches mq.addEventListener('change', e => { isDesktop = e.matches }) @@ -86,9 +109,10 @@ Loading… {:else if view === 'start'} - { hasBeenUnlocked = true; view = 'dashboard' }} /> + { hasBeenUnlocked = true; view = 'dashboard' }} /> {:else} view = 'start'} {theme} {accent} @@ -97,7 +121,7 @@ onaccent={a => accent = a} /> {/if} - {#if multipleInstances} + {#if multipleInstances && !isPopup}
Portpass is already open in another tab — saving from both may cause conflicts.
diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index e5e9d51..e3b0367 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -7,6 +7,7 @@ updateRecordFields, updateDBFields, deleteRecord as wasmDeleteRecord, searchRecords, closeDatabase, loadVaultFile, copyFieldToClipboard, copyCustomFieldToClipboard, copyTOTP as wasmCopyTOTP, + getFieldValue, } from '../wasm.js' import { addSecondaryCredential, removeSecondaryCredential } from './secondaryVaults.js' import { isBiometricEnrolledForFile, unlockWithBiometric } from './biometric.js' @@ -16,7 +17,7 @@ import RecordEdit from './RecordEdit.svelte' import VaultSheet from './VaultSheet.svelte' - let { onclosed, theme, accent, isDesktop, ontheme, onaccent } = $props() + let { onclosed, isPopup = false, theme, accent, isDesktop, ontheme, onaccent } = $props() function focusOnMount(node) { setTimeout(() => node.focus(), 0) @@ -86,6 +87,113 @@ toast.set({ message, action, duration }) } + // Autofill postMessage handler — ECDH key exchange then encrypted query response. + function autofillValidateSequence(seq) { + if (!seq) return '' + let i = 0 + while (i < seq.length) { + if (seq[i] !== '\\') return `Unexpected character at position ${i + 1}` + if (i + 1 >= seq.length) return 'Sequence ends with \\' + const code = seq[i + 1] + if (!['u', 'p', 't', 'n'].includes(code)) return `Unknown code: \\${code}` + i += 2 + } + 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 + } + + if (!selectedUUID) { + event.source.postMessage( + { type: 'error', message: 'Open a record in Portpass first' }, + event.origin + ) + return + } + + // Fall back to the standard sequence when the record has no custom autotype. + const autotype = record?.Autotype || '\\u\\t\\p\\n' + + const parseErr = autofillValidateSequence(autotype) + if (parseErr) { + event.source.postMessage( + { type: 'error', message: `Could not parse autofill sequence: ${autotype}` }, + event.origin + ) + return + } + + const vaultUuid = selectedVaultUuid || dbKey + const fields = {} + if (autotype.includes('\\u')) fields.u = record.Username ?? '' + if (autotype.includes('\\p')) fields.p = getFieldValue(vaultUuid, selectedUUID, 'Password') ?? '' + + const iv = crypto.getRandomValues(new Uint8Array(12)) + const plaintext = new TextEncoder().encode(JSON.stringify(fields)) + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, sessionKey, plaintext) + + event.source.postMessage({ + type: 'record', + title: record.Title, + autotype, + iv: btoa(String.fromCharCode(...iv)), + ciphertext: btoa(String.fromCharCode(...new Uint8Array(ciphertext))), + }, event.origin) + } + } + + window.addEventListener('message', handleMessage) + return () => { window.removeEventListener('message', handleMessage); sessionKey = null } + }) + // Load a record by UUID. vaultUuid is null for primary vault records. function selectRecord(uuid, vaultUuid = null) { if (isEditing && editDirty) { diff --git a/pwa/src/lib/RecordEdit.svelte b/pwa/src/lib/RecordEdit.svelte index 7a85bad..7d23c60 100644 --- a/pwa/src/lib/RecordEdit.svelte +++ b/pwa/src/lib/RecordEdit.svelte @@ -62,10 +62,10 @@ // Destructure once — null sensitive values start as '' in the edit form const initRec = untrack(() => record ?? {}) - const { Title = '', Group = '', Username = '', URL = '', Email = '' } = initRec + const { Title = '', Group = '', Username = '', URL = '', Email = '', Autotype = '' } = initRec const Password = initRec.Password ?? '' const Notes = initRec.Notes ?? '' - let draft = $state({ Title, Group, Username, Password, URL, Email, Notes }) + let draft = $state({ Title, Group, Username, Password, URL, Email, Notes, Autotype }) // TOTP state — kept separate from draft; merged into save call let totpSecret = $state(untrack(() => base64ToBase32(record?.TwoFactorKey ?? ''))) @@ -172,7 +172,7 @@ let dirty = $derived(!record || Object.keys(draft).some(k => (record[k] ?? '') !== draft[k]) || totpChanged || customFieldsDirty) // null Value = withheld sensitive field (counts as valid — keep existing) let customFieldsValid = $derived(customFields.every(cf => cf.Name.trim() !== '' && (cf.Value !== '' || cf.Value === null))) - let canSave = $derived(dirty && !!draft.Title && (!!draft.Password || passwordWasWithheld) && !totpError && customFieldsValid) + let canSave = $derived(dirty && !!draft.Title && (!!draft.Password || passwordWasWithheld) && !totpError && customFieldsValid && !autotypeError) function buildSaveDraft() { const d = { ...draft } @@ -250,6 +250,21 @@ set('Password', pw) genOpen = false } + + function validateAutotype(seq) { + if (!seq) return '' + let i = 0 + while (i < seq.length) { + if (seq[i] !== '\\') return `Unexpected character at position ${i + 1}` + if (i + 1 >= seq.length) return 'Sequence ends with \\' + const code = seq[i + 1] + if (!['u', 'p', 't', 'n'].includes(code)) return `Unknown code: \\${code}` + i += 2 + } + return '' + } + + let autotypeError = $derived(validateAutotype(draft.Autotype)) {#if genOpen} @@ -493,6 +508,21 @@ oninput={e => set('Notes', e.target.value)}> +
+ Autofill sequence + set('Autotype', e.target.value)} + autocomplete="off" spellcheck="false"/> + {#if autotypeError} +
{autotypeError}
+ {:else if draft.Autotype} +
\u = username · \p = password · \t = Tab · \n = Enter
+ {:else} +
Leave blank to use default: \u\t\p\n
+ {/if} +
+ {#if !isNew && ondelete}
{/if} + {#if record.Autotype} +
+
Autofill sequence
+
{record.Autotype}
+
+ {/if} + {#if record.ModTime && new Date(record.ModTime).getTime() > 0}
Modified {relTime(record.ModTime)}
{/if} @@ -540,4 +547,14 @@ text-overflow: ellipsis; white-space: nowrap; } + + .record-autotype { + padding: 12px 0 4px; + } + + .autotype-value { + font-size: 14px; + color: var(--text-soft); + padding: 2px 0; + } diff --git a/pwa/src/lib/StartPage.svelte b/pwa/src/lib/StartPage.svelte index 5dcd294..6979587 100644 --- a/pwa/src/lib/StartPage.svelte +++ b/pwa/src/lib/StartPage.svelte @@ -11,7 +11,7 @@ import { getRecentHandles, pushRecentHandle } from './recentHandles.js' import Icon from './Icon.svelte' - let { onopened, autoBiometric = true } = $props() + let { onopened, autoBiometric = true, isPopup = false } = $props() function focusOnMount(node, condition = true) { if (condition) setTimeout(() => node.focus(), 0) @@ -263,7 +263,7 @@
{fileHandle?.name ?? 'Vault'}
- {busy ? 'Unlocking…' : 'Vault is locked'} + {busy ? 'Unlocking…' : isPopup ? 'Unlock to use Autofill' : 'Vault is locked'}
{#if busy} diff --git a/pwa/src/lib/VaultSheet.svelte b/pwa/src/lib/VaultSheet.svelte index 1673a61..8aa723a 100644 --- a/pwa/src/lib/VaultSheet.svelte +++ b/pwa/src/lib/VaultSheet.svelte @@ -4,6 +4,7 @@ import { getDatabaseInfo, openDatabase, updateDBFields } from '../wasm.js' import { selectedFile, dbItems, secondaryVaults } from '../store.js' import { isBiometricSupported, isBiometricEnrolled, enrollBiometric, clearBiometric } from './biometric.js' + import { makeBookmarkletUrl } from './bookmarklet.js' import Icon from './Icon.svelte' let { isDesktop, onback, onlock, onlockall, onlocksecondary, onunlockadditional, ondbsave, ondirtychange, theme, accent, ontheme, onaccent } = $props() @@ -147,6 +148,19 @@ selectedDetailVault = null } + // ── Autofill bookmarklet ──────────────────────────────────────────────────── + const bookmarkletUrl = makeBookmarkletUrl(window.location.origin + import.meta.env.BASE_URL) + let copied = $state(false) + let copyTimer = null + + function copyBookmarklet() { + navigator.clipboard.writeText(bookmarkletUrl).then(() => { + copied = true + clearTimeout(copyTimer) + copyTimer = setTimeout(() => { copied = false }, 2000) + }) + } + // ── Helpers ──────────────────────────────────────────────────────────────── const appVersion = (__APP_VERSION__.match(/^v?\d+\.\d+\.\d+/) ?? [__APP_VERSION__])[0] @@ -280,6 +294,34 @@ + +
+
AUTOFILL
+

+ Open a password, switch to the login page, then click the bookmark. +

+ +

Drag to bookmarks bar · Copy if bar is hidden

+
+
APPEARANCE
@@ -876,4 +918,60 @@ } .about-url:hover { color: var(--accent); } + + /* ── Autofill bookmarklet ────────────────────────────────────────────────── */ + .vs-autofill-help { + font-size: 14px; + margin: 0 0 14px; + line-height: 1.5; + } + + .vs-bookmarklet-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + .vs-bookmarklet-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: var(--surface-2); + border: 1.5px dashed var(--border-strong); + border-radius: var(--r-pill); + font-size: 14px; + font-weight: 500; + color: var(--text); + text-decoration: none; + cursor: grab; + user-select: none; + transition: border-color 0.15s, background 0.15s; + } + + .vs-bookmarklet-chip:hover { + border-color: var(--accent); + background: var(--surface); + color: var(--accent); + } + + .vs-bookmarklet-chip:active { cursor: grabbing; } + + .vs-copy-btn { + background: none; + border: none; + cursor: pointer; + font-size: 14px; + color: var(--accent); + padding: 4px 2px; + font-weight: 500; + flex-shrink: 0; + } + .vs-copy-btn:hover { text-decoration: underline; } + + .vs-bookmarklet-hint { + font-size: 12px; + margin: 8px 0 0; + } diff --git a/pwa/src/lib/bookmarklet.js b/pwa/src/lib/bookmarklet.js new file mode 100644 index 0000000..3dbd22e --- /dev/null +++ b/pwa/src/lib/bookmarklet.js @@ -0,0 +1,237 @@ +// Portpass autofill bookmarklet. +// makeBookmarkletUrl(portpassUrl) returns the javascript: URL for the bookmarks bar link. + +export function makeBookmarkletUrl(portpassUrl) { + const origin = new URL(portpassUrl).origin + return 'javascript:' + encodeURIComponent(buildCode(portpassUrl, origin)) +} + +function buildCode(portpassUrl, portpassOrigin) { + return `(${BOOKMARKLET_IIFE.toString()}) + (${JSON.stringify(portpassUrl)},${JSON.stringify(portpassOrigin)})` +} + +// Self-contained IIFE. Receives (portpassUrl, portpassOrigin) as parameters so +// makeBookmarkletUrl can embed them at install time via JSON.stringify. +function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { + 'use strict' + + // Prevent concurrent runs (e.g. double-click). + if (window.__ppRunning) return + window.__ppRunning = true + + ;(async function run() { + try { + // 1. Open or focus the Portpass tab/window. + const pp = window.open(PORTPASS_URL, 'portpass_autofill') + if (!pp) { showError('Portpass could not open — allow popups for this site'); return } + + // 2. ECDH key exchange (with retry so WASM loading time is covered). + const pair = await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveKey'] + ) + const ourPub = await crypto.subtle.exportKey('jwk', pair.publicKey) + + let helloReply + try { + helloReply = await sendRetry(pp, { type: 'hello', pubkey: ourPub }, PORTPASS_ORIGIN) + } catch (msg) { + showError(typeof msg === 'string' ? msg : 'Portpass did not respond — make sure it is open and unlocked') + return + } + if (helloReply.type === 'error') { showError(helloReply.message); return } + + const ppPub = await crypto.subtle.importKey( + 'jwk', helloReply.pubkey, { name: 'ECDH', namedCurve: 'P-256' }, false, [] + ) + const sessionKey = await crypto.subtle.deriveKey( + { name: 'ECDH', public: ppPub }, pair.privateKey, + { name: 'AES-GCM', length: 256 }, false, ['decrypt'] + ) + + // 3. Query for the currently open record. + pp.postMessage({ type: 'query' }, PORTPASS_ORIGIN) + let qReply + try { qReply = await recv(pp, ['record', 'error']) } + catch (_) { showError('No response to query — try again'); return } + if (qReply.type === 'error') { showError(qReply.message); return } + + // 4. Decrypt the credentials. + const iv = Uint8Array.from(atob(qReply.iv), c => c.charCodeAt(0)) + const ct = Uint8Array.from(atob(qReply.ciphertext), c => c.charCodeAt(0)) + const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, sessionKey, ct) + const fields = JSON.parse(new TextDecoder().decode(pt)) + + // 5. Show the two-step overlay. + showOverlay(qReply.title, qReply.autotype, fields) + } catch (e) { + showError(e.message || String(e)) + } finally { + window.__ppRunning = false + } + })() + + // Send msg to target and wait for a matching reply type, retrying every second + // until timeout (to survive WASM load time on a freshly opened Portpass). + function sendRetry(target, msg, origin, timeout, interval) { + timeout = timeout || 15000 + interval = interval || 1000 + return new Promise(function(resolve, reject) { + var start = Date.now(), timer + function handler(e) { + if (e.source !== target) return + var t = e.data && e.data.type + if (t === msg.type || t === 'error') { + clearInterval(timer) + window.removeEventListener('message', handler) + resolve(e.data) + } + } + window.addEventListener('message', handler) + function attempt() { + if (Date.now() - start > timeout) { + clearInterval(timer) + window.removeEventListener('message', handler) + reject('Portpass did not respond — make sure it is open and unlocked') + return + } + try { target.postMessage(msg, origin) } catch (_) {} + } + attempt() + timer = setInterval(attempt, interval) + }) + } + + // Wait for the next message from target matching one of the given types. + 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) + }) + } + + // Fill a field via the native value setter so React/Vue/Angular frameworks notice. + 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 })) + } + + // Return the next text-entry field after el, respecting tabindex order. + // Deliberately excludes buttons, links, and non-text inputs so that \t skips + // UI controls (e.g. a "show password" button) that appear between form fields. + function nextFocusable(el) { + 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' + }) + // Elements with tabindex > 0 first (ascending), then tabindex=0 in DOM order. + var pos = all.filter(function(e) { return e.tabIndex > 0 }) + .sort(function(a, b) { return a.tabIndex - b.tabIndex }) + var zero = all.filter(function(e) { return e.tabIndex === 0 }) + var sorted = pos.concat(zero) + var i = sorted.indexOf(el) + return i >= 0 ? sorted[i + 1] || null : null + } + + // Execute the autotype sequence from startEl. + function executeAutotype(startEl, sequence, fields) { + var el = startEl + for (var i = 0; i < sequence.length; i += 2) { + var code = sequence[i + 1] // sequence[i] === '\\' + if (code === 'u' || code === 'p') { + 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 === 'n') { + var form = el && el.closest('form') + if (form) try { form.requestSubmit() } catch (_) { form.submit() } + } + } + } + + function mkOverlay() { + var div = document.createElement('div') + div.id = '__pp' + div.style.cssText = [ + 'position:fixed', 'top:16px', 'right:16px', 'z-index:2147483647', + 'background:#1a1d21', 'color:#f1ede4', 'border-radius:8px', + 'padding:12px 16px', 'font-family:system-ui,-apple-system,sans-serif', + 'font-size:14px', 'max-width:280px', + 'box-shadow:0 4px 20px rgba(0,0,0,.5)', + ].join(';') + var close = document.createElement('button') + close.textContent = '×' + close.style.cssText = 'position:absolute;top:6px;right:10px;background:none;border:none;color:#666;font-size:20px;cursor:pointer;padding:0;line-height:1' + close.onclick = removeOverlay + div.appendChild(close) + return div + } + + function showOverlay(title, autotype, fields) { + removeOverlay() + var div = mkOverlay() + + var titleEl = document.createElement('div') + titleEl.style.cssText = 'font-weight:600;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:240px' + titleEl.textContent = title + + var hint = document.createElement('div') + hint.style.cssText = 'font-size:12px;color:#aaa;margin-top:4px' + hint.textContent = 'Click the field to start from' + + div.appendChild(titleEl) + div.appendChild(hint) + document.body.appendChild(div) + + function onFieldClick(e) { + var el = e.target + var tag = el && el.tagName + if (tag !== 'INPUT' && tag !== 'TEXTAREA') return + if (el.type === 'hidden') return + e.preventDefault() + document.removeEventListener('click', onFieldClick, true) + removeOverlay() + executeAutotype(el, autotype, fields) + } + document.addEventListener('click', onFieldClick, true) + } + + function showError(msg) { + removeOverlay() + var div = mkOverlay() + var err = document.createElement('div') + err.style.cssText = 'color:#e06c75;padding-right:20px' + err.textContent = msg + div.appendChild(err) + document.body.appendChild(div) + setTimeout(removeOverlay, 8000) + } + + function removeOverlay() { + var el = document.getElementById('__pp') + if (el) el.parentNode.removeChild(el) + } +} diff --git a/pwa/tests/autofill.spec.ts b/pwa/tests/autofill.spec.ts new file mode 100644 index 0000000..ee6b312 --- /dev/null +++ b/pwa/tests/autofill.spec.ts @@ -0,0 +1,154 @@ +import { test, expect } from '@playwright/test' +import { createVault } from './helpers' + +// Helper: create a new record with an autofill sequence and open its detail view. +async function createRecordWithAutotype(page: any, autotype: string) { + await page.getByRole('button', { name: 'New', exact: true }).click() + await page.getByPlaceholder('e.g. Bank of America').fill('Autofill Test') + await page.locator('input.mono').first().fill('testpassword') + await page.locator('.autotype-input').fill(autotype) + await page.getByRole('button', { name: 'Save' }).click() + await expect(page.locator('.record-row', { hasText: 'Autofill Test' })).toBeVisible() + await page.locator('.record-row', { hasText: 'Autofill Test' }).click() +} + +test.describe('Autofill sequence — edit form', () => { + + test('"Autofill sequence" field label visible in edit form', async ({ page }) => { + await createVault(page) + await page.getByRole('button', { name: 'New', exact: true }).click() + await expect(page.locator('.field-label', { hasText: 'Autofill sequence' })).toBeVisible() + }) + + test('autofill input has placeholder \\u\\t\\p\\n', async ({ page }) => { + await createVault(page) + await page.getByRole('button', { name: 'New', exact: true }).click() + await expect(page.locator('.autotype-input')).toHaveAttribute('placeholder', '\\u\\t\\p\\n') + }) + + test('valid sequence enables Save and shows hint', async ({ page }) => { + await createVault(page) + await page.getByRole('button', { name: 'New', exact: true }).click() + await page.getByPlaceholder('e.g. Bank of America').fill('Test') + await page.locator('input.mono').first().fill('pass') + await page.locator('.autotype-input').fill('\\u\\t\\p\\n') + await expect(page.locator('.autotype-hint')).toBeVisible() + await expect(page.getByRole('button', { name: 'Save' })).not.toBeDisabled() + }) + + test('unknown code blocks Save and shows error', async ({ page }) => { + await createVault(page) + await page.getByRole('button', { name: 'New', exact: true }).click() + await page.getByPlaceholder('e.g. Bank of America').fill('Test') + await page.locator('input.mono').first().fill('pass') + await page.locator('.autotype-input').fill('\\u\\x\\p') + await expect(page.locator('.autotype-error')).toBeVisible() + await expect(page.locator('.autotype-error')).toContainText('\\x') + await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + test('trailing backslash blocks Save and shows error', async ({ page }) => { + await createVault(page) + await page.getByRole('button', { name: 'New', exact: true }).click() + await page.getByPlaceholder('e.g. Bank of America').fill('Test') + await page.locator('input.mono').first().fill('pass') + await page.locator('.autotype-input').fill('\\u\\') + await expect(page.locator('.autotype-error')).toBeVisible() + await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + test('literal character in sequence blocks Save and shows error', async ({ page }) => { + await createVault(page) + await page.getByRole('button', { name: 'New', exact: true }).click() + await page.getByPlaceholder('e.g. Bank of America').fill('Test') + await page.locator('input.mono').first().fill('pass') + await page.locator('.autotype-input').fill('abc') + await expect(page.locator('.autotype-error')).toBeVisible() + await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + test('empty sequence shows default hint and does not block save', async ({ page }) => { + await createVault(page) + await page.getByRole('button', { name: 'New', exact: true }).click() + await page.getByPlaceholder('e.g. Bank of America').fill('Test') + await page.locator('input.mono').first().fill('pass') + await expect(page.locator('.autotype-error')).toHaveCount(0) + await expect(page.locator('.autotype-hint')).toContainText('Leave blank to use default') + await expect(page.getByRole('button', { name: 'Save' })).not.toBeDisabled() + }) + + test('correcting invalid sequence clears the error', async ({ page }) => { + await createVault(page) + await page.getByRole('button', { name: 'New', exact: true }).click() + await page.getByPlaceholder('e.g. Bank of America').fill('Test') + await page.locator('input.mono').first().fill('pass') + await page.locator('.autotype-input').fill('\\q') + await expect(page.locator('.autotype-error')).toBeVisible() + await page.locator('.autotype-input').fill('\\u\\p') + await expect(page.locator('.autotype-error')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Save' })).not.toBeDisabled() + }) + +}) + +test.describe('Autofill sequence — read view', () => { + + test('sequence shown in read view after save', async ({ page }) => { + await createVault(page) + await createRecordWithAutotype(page, '\\u\\t\\p\\n') + await expect(page.locator('.copy-row-label', { hasText: 'Autofill sequence' })).toBeVisible() + await expect(page.locator('.autotype-value')).toHaveText('\\u\\t\\p\\n') + }) + + test('autofill section absent when sequence is empty', async ({ page }) => { + await createVault(page) + await page.getByRole('button', { name: 'New', exact: true }).click() + await page.getByPlaceholder('e.g. Bank of America').fill('No Autofill') + await page.locator('input.mono').first().fill('testpassword') + await page.getByRole('button', { name: 'Save' }).click() + await page.locator('.record-row', { hasText: 'No Autofill' }).click() + await expect(page.locator('.autotype-value')).toHaveCount(0) + }) + + test('autofill section absent after clearing sequence on edit', async ({ page }) => { + await createVault(page) + await createRecordWithAutotype(page, '\\u\\t\\p\\n') + await expect(page.locator('.autotype-value')).toBeVisible() + await page.getByRole('button', { name: 'Edit' }).click() + await page.locator('.autotype-input').fill('') + await page.getByRole('button', { name: 'Save' }).click() + await expect(page.locator('.autotype-value')).toHaveCount(0) + }) + +}) + +test.describe('Autofill sequence — round-trip persistence', () => { + + test('autotype value preserved when re-opening edit form', async ({ page }) => { + await createVault(page) + await createRecordWithAutotype(page, '\\u\\t\\p\\n') + await page.getByRole('button', { name: 'Edit' }).click() + await expect(page.locator('.autotype-input')).toHaveValue('\\u\\t\\p\\n') + }) + + test('updated sequence reflected in read view', async ({ page }) => { + await createVault(page) + await createRecordWithAutotype(page, '\\u\\t\\p\\n') + await page.getByRole('button', { name: 'Edit' }).click() + await page.locator('.autotype-input').fill('\\u\\t\\p') + await page.getByRole('button', { name: 'Save' }).click() + await expect(page.locator('.autotype-value')).toHaveText('\\u\\t\\p') + }) + + test('each valid token combination passes validation', async ({ page }) => { + await createVault(page) + await page.getByRole('button', { name: 'New', exact: true }).click() + await page.getByPlaceholder('e.g. Bank of America').fill('Test') + await page.locator('input.mono').first().fill('pass') + for (const seq of ['\\u', '\\p', '\\t', '\\n', '\\u\\t\\p\\n', '\\u\\p\\u\\p']) { + await page.locator('.autotype-input').fill(seq) + await expect(page.locator('.autotype-error')).toHaveCount(0) + } + }) + +}) diff --git a/pwa/tests/autofill_popup.spec.ts b/pwa/tests/autofill_popup.spec.ts new file mode 100644 index 0000000..42b8ab9 --- /dev/null +++ b/pwa/tests/autofill_popup.spec.ts @@ -0,0 +1,321 @@ +import { test, expect, BrowserContext, Page } from '@playwright/test' + +const PORTPASS_URL = 'http://localhost:5173/portpass/' +const PORTPASS_ORIGIN = 'http://localhost:5173' + +// Opens a Portpass popup from an opener page that is at the Portpass origin but does NOT +// run the Portpass app (which would set window.name = 'portpass_autofill' and cause +// window.open() to focus the existing tab instead of creating a new popup). +async function openPortpassPopup(context: BrowserContext): Promise<{ opener: Page, popup: Page }> { + const opener = await context.newPage() + // Serve a minimal launcher page at the Portpass origin so postMessage targetOrigin works. + await opener.route('/portpass/launcher', route => + route.fulfill({ contentType: 'text/html', body: 'launcher' }) + ) + await opener.goto('http://localhost:5173/portpass/launcher') + + const [popup] = await Promise.all([ + context.waitForEvent('page'), + opener.evaluate((url) => { + ;(window as any).portpassWin = window.open(url, 'portpass_autofill') + }, PORTPASS_URL), + ]) + + return { opener, popup } +} + +// Creates a new vault in the popup. +async function createVaultInPopup(popup: Page) { + await popup.getByRole('button', { name: 'Create one' }).click() + await popup.getByPlaceholder('Master password').fill('testpassword') + await popup.getByRole('button', { name: 'Create vault' }).click() + await expect(popup.getByPlaceholder('Search vault')).toBeVisible({ timeout: 10000 }) +} + +// Performs the ECDH key exchange from the opener side and returns the derived AES +// CryptoKey so subsequent calls can decrypt record responses. +async function doKeyExchange(opener: Page): Promise { + // The CryptoKey is stored in window._autofillSessionKey on the opener page. + await opener.evaluate(async (origin) => { + const win = (window as any).portpassWin + + const pair = await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey'] + ) + const pubJwk = await crypto.subtle.exportKey('jwk', pair.publicKey) + + const response = await new Promise((resolve) => { + window.addEventListener('message', (e) => { + if (e.source === win && e.data?.type) resolve(e.data) + }, { once: true }) + win.postMessage({ type: 'hello', pubkey: pubJwk }, origin) + }) + + if (response.type !== 'hello') { + ;(window as any)._autofillError = response + return + } + + const portpassPub = await crypto.subtle.importKey( + 'jwk', response.pubkey, + { name: 'ECDH', namedCurve: 'P-256' }, false, [] + ) + ;(window as any)._autofillSessionKey = await crypto.subtle.deriveKey( + { name: 'ECDH', public: portpassPub }, + pair.privateKey, + { name: 'AES-GCM', length: 256 }, false, ['decrypt'] + ) + }, PORTPASS_ORIGIN) +} + +// Sends a query, then decrypts the record response using the session key stored in +// window._autofillSessionKey. Returns the full response with decrypted fields. +async function sendQuery(opener: Page): Promise { + return opener.evaluate(async (origin) => { + const win = (window as any).portpassWin + const sessionKey = (window as any)._autofillSessionKey + + const raw = await new Promise((resolve) => { + window.addEventListener('message', (e) => { + if (e.source === win && e.data?.type) resolve(e.data) + }, { once: true }) + win.postMessage({ type: 'query' }, origin) + setTimeout(() => resolve({ timeout: true }), 5000) + }) + + if (raw.type !== 'record' || !sessionKey) return raw + + // Decrypt the fields blob. + const iv = Uint8Array.from(atob(raw.iv), c => c.charCodeAt(0)) + const ct = Uint8Array.from(atob(raw.ciphertext), c => c.charCodeAt(0)) + const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, sessionKey, ct) + const fields = JSON.parse(new TextDecoder().decode(pt)) + return { ...raw, fields } + }, PORTPASS_ORIGIN) +} + +// Sends a query WITHOUT a prior key exchange (raw, no decryption step). +async function sendRawQuery(opener: Page): Promise { + return opener.evaluate(async (origin) => { + const win = (window as any).portpassWin + return new Promise((resolve) => { + window.addEventListener('message', (e) => { + if (e.source === win && e.data?.type) resolve(e.data) + }, { once: true }) + win.postMessage({ type: 'query' }, origin) + setTimeout(() => resolve({ timeout: true }), 5000) + }) + }, PORTPASS_ORIGIN) +} + +test.describe('Autofill popup mode — query protocol', () => { + + test.beforeEach(async ({ context }) => { + await context.addInitScript(() => { + if ((window as any).PublicKeyCredential) { + (window.PublicKeyCredential as any).isUserVerifyingPlatformAuthenticatorAvailable = async () => false + } + ;(window as any).showSaveFilePicker = async () => ({ + name: 'new.psafe3', + createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }), + }) + }) + }) + + test('hello while vault is locked returns error', async ({ context }) => { + const { opener, popup } = await openPortpassPopup(context) + await popup.waitForSelector('button:text("Open vault file"), button:text("Create one")', { timeout: 10000 }) + + await opener.evaluate(async (origin) => { + const win = (window as any).portpassWin + const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey']) + const pubJwk = await crypto.subtle.exportKey('jwk', pair.publicKey) + const response = await new Promise((resolve) => { + window.addEventListener('message', (e) => { if (e.source === win) resolve(e.data) }, { once: true }) + win.postMessage({ type: 'hello', pubkey: pubJwk }, origin) + }) + ;(window as any)._helloResponse = response + }, PORTPASS_ORIGIN) + + const resp = await opener.evaluate(() => (window as any)._helloResponse) + expect(resp.type).toBe('error') + expect(resp.message).toContain('Vault is locked') + }) + + test('query without prior key exchange returns error', async ({ context }) => { + const { opener, popup } = await openPortpassPopup(context) + await createVaultInPopup(popup) + + const response = await sendRawQuery(opener) + expect(response.type).toBe('error') + expect(response.message).toContain('No secure session') + }) + + test('unlocked vault with no record selected returns error after key exchange', async ({ context }) => { + const { opener, popup } = await openPortpassPopup(context) + await createVaultInPopup(popup) + await doKeyExchange(opener) + + const response = await sendQuery(opener) + expect(response.type).toBe('error') + expect(response.message).toContain('Open a record') + }) + + test('record without autotype uses default sequence \\u\\t\\p\\n', async ({ context }) => { + const { opener, popup } = await openPortpassPopup(context) + await createVaultInPopup(popup) + + await popup.getByRole('button', { name: 'New', exact: true }).click() + await popup.getByPlaceholder('e.g. Bank of America').fill('No Autotype Site') + await popup.locator('input.mono').first().fill('secret') + // Leave autotype empty — bookmarklet should default to \u\t\p\n. + await popup.getByRole('button', { name: 'Save' }).click() + await popup.locator('.record-row', { hasText: 'No Autotype Site' }).click() + + await doKeyExchange(opener) + const response = await sendQuery(opener) + expect(response.type).toBe('record') + expect(response.autotype).toBe('\\u\\t\\p\\n') + }) + + test('record response contains iv and ciphertext, not plaintext fields', async ({ context }) => { + const { opener, popup } = await openPortpassPopup(context) + await createVaultInPopup(popup) + + await popup.getByRole('button', { name: 'New', exact: true }).click() + await popup.getByPlaceholder('e.g. Bank of America').fill('My Bank') + await popup.locator('input.mono').first().fill('hunter2') + await popup.locator('input.input').nth(2).fill('alice') + await popup.locator('.autotype-input').fill('\\u\\t\\p\\n') + await popup.getByRole('button', { name: 'Save' }).click() + await popup.locator('.record-row', { hasText: 'My Bank' }).click() + + await doKeyExchange(opener) + + // Get the raw (not-decrypted) response to verify ciphertext is present. + const raw = await opener.evaluate(async (origin) => { + const win = (window as any).portpassWin + return new Promise((resolve) => { + window.addEventListener('message', (e) => { + if (e.source === win && e.data?.type) resolve(e.data) + }, { once: true }) + win.postMessage({ type: 'query' }, origin) + setTimeout(() => resolve({ timeout: true }), 5000) + }) + }, PORTPASS_ORIGIN) + + expect(raw.type).toBe('record') + expect(raw.title).toBe('My Bank') + expect(raw.autotype).toBe('\\u\\t\\p\\n') + expect(raw.iv).toBeDefined() + expect(raw.ciphertext).toBeDefined() + // No plaintext fields in transit + expect(raw.fields).toBeUndefined() + }) + + test('decrypted response contains correct credentials', async ({ context }) => { + const { opener, popup } = await openPortpassPopup(context) + await createVaultInPopup(popup) + + await popup.getByRole('button', { name: 'New', exact: true }).click() + await popup.getByPlaceholder('e.g. Bank of America').fill('My Bank') + await popup.locator('input.mono').first().fill('hunter2') + await popup.locator('input.input').nth(2).fill('alice') + await popup.locator('.autotype-input').fill('\\u\\t\\p\\n') + await popup.getByRole('button', { name: 'Save' }).click() + await popup.locator('.record-row', { hasText: 'My Bank' }).click() + + await doKeyExchange(opener) + const response = await sendQuery(opener) + + expect(response.type).toBe('record') + expect(response.title).toBe('My Bank') + expect(response.autotype).toBe('\\u\\t\\p\\n') + expect(response.fields.u).toBe('alice') + expect(response.fields.p).toBe('hunter2') + }) + + test('two sessions derive independent keys', async ({ context }) => { + const { opener, popup } = await openPortpassPopup(context) + await createVaultInPopup(popup) + + await popup.getByRole('button', { name: 'New', exact: true }).click() + await popup.getByPlaceholder('e.g. Bank of America').fill('Site') + await popup.locator('input.mono').first().fill('pass') + await popup.locator('.autotype-input').fill('\\u\\p') + await popup.getByRole('button', { name: 'Save' }).click() + await popup.locator('.record-row', { hasText: 'Site' }).click() + + // First session. + await doKeyExchange(opener) + const r1 = await opener.evaluate(async (origin) => { + const win = (window as any).portpassWin + return new Promise((resolve) => { + window.addEventListener('message', (e) => { if (e.source === win && e.data?.type) resolve(e.data) }, { once: true }) + win.postMessage({ type: 'query' }, origin) + }) + }, PORTPASS_ORIGIN) + + // Second session — new hello, new key pair on both sides. + await doKeyExchange(opener) + const r2 = await opener.evaluate(async (origin) => { + const win = (window as any).portpassWin + return new Promise((resolve) => { + window.addEventListener('message', (e) => { if (e.source === win && e.data?.type) resolve(e.data) }, { once: true }) + win.postMessage({ type: 'query' }, origin) + }) + }, PORTPASS_ORIGIN) + + // IVs are random per encryption — very high probability they differ. + expect(r1.iv).not.toBe(r2.iv) + }) + + test('switching records updates the decrypted query response', async ({ context }) => { + const { opener, popup } = await openPortpassPopup(context) + await createVaultInPopup(popup) + + for (const title of ['Site A', 'Site B']) { + await popup.getByRole('button', { name: 'New', exact: true }).click() + await popup.getByPlaceholder('e.g. Bank of America').fill(title) + await popup.locator('input.mono').first().fill('pass') + await popup.locator('.autotype-input').fill('\\u\\p') + await popup.getByRole('button', { name: 'Save' }).click() + } + + await doKeyExchange(opener) + + await popup.locator('.record-row', { hasText: 'Site A' }).click() + const responseA = await sendQuery(opener) + expect(responseA.title).toBe('Site A') + + await popup.locator('.record-row', { hasText: 'Site B' }).click() + const responseB = await sendQuery(opener) + expect(responseB.title).toBe('Site B') + }) + +}) + +test.describe('Autofill popup mode — UI', () => { + + test('multi-instance warning is suppressed when opened as popup', async ({ context }) => { + await context.addInitScript(() => { + if ((window as any).PublicKeyCredential) { + (window.PublicKeyCredential as any).isUserVerifyingPlatformAuthenticatorAvailable = async () => false + } + ;(window as any).showSaveFilePicker = async () => ({ + name: 'new.psafe3', + createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }), + }) + }) + const { popup } = await openPortpassPopup(context) + await popup.waitForSelector('button:text("Open vault file"), button:text("Create one")', { timeout: 10000 }) + await expect(popup.locator('.multi-instance-warning')).toHaveCount(0) + }) + + // NOTE: "Unlock to use Autofill" text on the StartPage unlock screen requires a real + // FileSystemFileHandle (functions survive IDB structured-clone). Test-environment mock + // handles lose their methods on IDB round-trip, so the StartPage stays at landing mode + // after lock. Verify this text manually: open Portpass as a popup from a login page, + // lock the vault, and confirm the sub-text reads "Unlock to use Autofill". + +}) diff --git a/pwa/tests/bookmarklet.spec.ts b/pwa/tests/bookmarklet.spec.ts new file mode 100644 index 0000000..abc7a8e --- /dev/null +++ b/pwa/tests/bookmarklet.spec.ts @@ -0,0 +1,221 @@ +import { test, expect, BrowserContext, Page } from '@playwright/test' +import { makeBookmarkletUrl } from '../src/lib/bookmarklet.js' + +const PORTPASS_URL = 'http://localhost:5173/portpass/' +const PORTPASS_ORIGIN = 'http://localhost:5173' +const LOGIN_PATH = '/login-test' +const LOGIN_URL = PORTPASS_ORIGIN + LOGIN_PATH + +const LOGIN_FORM_HTML = ` +
+ + + +
+ +` + +// Stand up the login page and a Portpass popup from it so window.opener is set. +// Returns { loginPage, portpassPopup }. +async function setupAutofillTest(context: BrowserContext): Promise<{ login: Page, portpass: Page }> { + await context.addInitScript(() => { + if ((window as any).PublicKeyCredential) { + (window.PublicKeyCredential as any).isUserVerifyingPlatformAuthenticatorAvailable = async () => false + } + ;(window as any).showSaveFilePicker = async () => ({ + name: 'new.psafe3', + createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }), + }) + }) + + // Serve the login form at LOGIN_PATH. + const login = await context.newPage() + await login.route(LOGIN_PATH, route => + route.fulfill({ contentType: 'text/html', body: LOGIN_FORM_HTML }) + ) + await login.goto(LOGIN_URL) + + // Open Portpass from the login page so window.opener is established. + const [portpass] = await Promise.all([ + context.waitForEvent('page'), + login.evaluate((url) => { + ;(window as any).portpassWin = window.open(url, 'portpass_autofill') + }, PORTPASS_URL), + ]) + + // Create a vault in the popup. + await portpass.getByRole('button', { name: 'Create one' }).click() + await portpass.getByPlaceholder('Master password').fill('testpassword') + await portpass.getByRole('button', { name: 'Create vault' }).click() + await expect(portpass.getByPlaceholder('Search vault')).toBeVisible({ timeout: 10000 }) + + return { login, portpass } +} + +// Create a record in portpass and open its detail view. +async function createRecord(portpass: Page, opts: { + title: string, username?: string, password?: string, autotype: string +}) { + await portpass.getByRole('button', { name: 'New', exact: true }).click() + await portpass.getByPlaceholder('e.g. Bank of America').fill(opts.title) + await portpass.locator('input.mono').first().fill(opts.password ?? 'secret') + if (opts.username) { + await portpass.locator('input.input').nth(2).fill(opts.username) + } + await portpass.locator('.autotype-input').fill(opts.autotype) + await portpass.getByRole('button', { name: 'Save' }).click() + await portpass.locator('.record-row', { hasText: opts.title }).click() +} + +// Run the bookmarklet on the login page and wait for the overlay to appear. +async function activateBookmarklet(login: Page) { + const code = makeBookmarkletUrl(PORTPASS_URL) + .replace('javascript:', '') // strip the scheme — evaluate runs the code directly + await login.evaluate(new Function(decodeURIComponent(code)) as any) + await expect(login.locator('#__pp')).toBeVisible({ timeout: 12000 }) + // Give the browser one animation frame so the click listener registered inside showOverlay() + // is guaranteed to be active before the test clicks a field. + await login.evaluate(() => new Promise(r => requestAnimationFrame(r))) +} + +test.setTimeout(25000) + +test.describe('Bookmarklet — overlay and autofill', () => { + + test('overlay shows record title and "Click the field to start from"', async ({ context }) => { + const { login, portpass } = await setupAutofillTest(context) + await createRecord(portpass, { title: 'My Bank', autotype: '\\u\\t\\p\\n' }) + + await activateBookmarklet(login) + await expect(login.locator('#__pp')).toContainText('My Bank') + await expect(login.locator('#__pp')).toContainText('Click the field to start from') + }) + + test('\\u\\t\\p fills username, tabs to password, fills password', async ({ context }) => { + const { login, portpass } = await setupAutofillTest(context) + await createRecord(portpass, { + title: 'My Bank', username: 'alice', password: 'hunter2', autotype: '\\u\\t\\p', + }) + + await activateBookmarklet(login) + await login.locator('#user').click() + // Overlay is removed by onFieldClick — its absence confirms autotype ran. + await expect(login.locator('#__pp')).not.toBeVisible({ timeout: 3000 }) + + await expect(login.locator('#user')).toHaveValue('alice') + await expect(login.locator('#pass')).toHaveValue('hunter2') + }) + + test('\\u\\t\\p\\n fills both fields and submits the form', async ({ context }) => { + const { login, portpass } = await setupAutofillTest(context) + await createRecord(portpass, { + title: 'My Bank', username: 'alice', password: 'hunter2', autotype: '\\u\\t\\p\\n', + }) + + // Detect form submission. + let submitted = false + await login.exposeFunction('__ppSubmitted', () => { submitted = true }) + await login.evaluate(() => { + document.getElementById('f')!.addEventListener('submit', () => (window as any).__ppSubmitted()) + }) + + await activateBookmarklet(login) + await login.locator('#user').click() + + await expect(login.locator('#user')).toHaveValue('alice') + await expect(login.locator('#pass')).toHaveValue('hunter2') + expect(submitted).toBe(true) + }) + + test('\\u only fills username, does not touch password', async ({ context }) => { + const { login, portpass } = await setupAutofillTest(context) + await createRecord(portpass, { + title: 'Site', username: 'bob', password: 'secret', autotype: '\\u', + }) + + await activateBookmarklet(login) + await login.locator('#user').click() + + await expect(login.locator('#user')).toHaveValue('bob') + await expect(login.locator('#pass')).toHaveValue('') + }) + + test('\\p only fills password field (starting from password input)', async ({ context }) => { + const { login, portpass } = await setupAutofillTest(context) + await createRecord(portpass, { + title: 'Site', password: 'mypassword', autotype: '\\p', + }) + + await activateBookmarklet(login) + await login.locator('#pass').click() + + await expect(login.locator('#pass')).toHaveValue('mypassword') + await expect(login.locator('#user')).toHaveValue('') + }) + + test('\\t skips non-input elements (e.g. show-password button) to reach password field', async ({ context }) => { + // Form with a button between username and password — simulates real-world sites. + const formWithButton = ` +
+ + + + +
+ +` + + const { login: _login, portpass } = await setupAutofillTest(context) + + // Override the login page content with the button-in-the-middle form. + const login = _login + await login.route('/login-button-test', route => + route.fulfill({ contentType: 'text/html', body: formWithButton }) + ) + await login.goto('http://localhost:5173/login-button-test') + + await createRecord(portpass, { + title: 'Button Site', username: 'alice', password: 'secret', autotype: '\\u\\t\\p', + }) + + await activateBookmarklet(login) + await login.locator('#user').click() + + await expect(login.locator('#user')).toHaveValue('alice') + // The "Show" button was skipped; password field should be filled. + await expect(login.locator('#pass')).toHaveValue('secret') + }) + + test('dismiss button removes the overlay', async ({ context }) => { + const { login, portpass } = await setupAutofillTest(context) + await createRecord(portpass, { title: 'Site', autotype: '\\u\\p' }) + + await activateBookmarklet(login) + await login.locator('#__pp button').click() + await expect(login.locator('#__pp')).toHaveCount(0) + }) + + test('error shown when no record is selected in Portpass', async ({ context }) => { + const { login, portpass } = await setupAutofillTest(context) + // Vault is open but no record is selected. + + await activateBookmarklet(login) + await expect(login.locator('#__pp')).toContainText('Open a record') + }) + + test('record without autotype sequence uses the default and shows overlay', async ({ context }) => { + const { login, portpass } = await setupAutofillTest(context) + await portpass.getByRole('button', { name: 'New', exact: true }).click() + await portpass.getByPlaceholder('e.g. Bank of America').fill('No Autotype') + await portpass.locator('input.mono').first().fill('pass') + // Leave autotype empty — should default to \u\t\p\n. + await portpass.getByRole('button', { name: 'Save' }).click() + await portpass.locator('.record-row', { hasText: 'No Autotype' }).click() + + await activateBookmarklet(login) + // Overlay should show the record name, not an error. + await expect(login.locator('#__pp')).toContainText('No Autotype') + await expect(login.locator('#__pp')).toContainText('Click the field to start from') + }) + +}) diff --git a/pwa/tests/vault_sheet.spec.ts b/pwa/tests/vault_sheet.spec.ts index 3a1630d..bc6006d 100644 --- a/pwa/tests/vault_sheet.spec.ts +++ b/pwa/tests/vault_sheet.spec.ts @@ -105,6 +105,69 @@ test.describe('VaultSheet per-vault editing', () => { }) +test.describe('VaultSheet autofill installation UI', () => { + + test('AUTOFILL section is visible on main settings page', async ({ page }) => { + await openVault(page) + await page.locator('.vault-pill').click() + await expect(page.locator('.vault-section-title', { hasText: 'AUTOFILL' })).toBeVisible() + }) + + test('bookmarklet chip has a javascript: href', async ({ page }) => { + await openVault(page) + await page.locator('.vault-pill').click() + const chip = page.locator('.vs-bookmarklet-chip') + await expect(chip).toBeVisible() + const href = await chip.getAttribute('href') + expect(href).toMatch(/^javascript:/) + }) + + test('bookmarklet href contains the Portpass origin', async ({ page }) => { + await openVault(page) + await page.locator('.vault-pill').click() + const href = await page.locator('.vs-bookmarklet-chip').getAttribute('href') + // The URL is encodeURIComponent'd inside the javascript: href; decode before asserting. + expect(decodeURIComponent(href ?? '')).toContain('localhost:5173') + }) + + test('clicking the chip does not navigate away', async ({ page }) => { + await openVault(page) + await page.locator('.vault-pill').click() + await page.locator('.vs-bookmarklet-chip').click() + // Still on the vault sheet — settings section still visible + await expect(page.locator('.vault-section-title', { hasText: 'AUTOFILL' })).toBeVisible() + }) + + test('Copy link button copies the javascript: URL to clipboard', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await openVault(page) + await page.locator('.vault-pill').click() + await page.locator('.vs-copy-btn').click() + // Button shows "Copied!" feedback + await expect(page.locator('.vs-copy-btn')).toHaveText('Copied!') + // Clipboard contains a javascript: URL + const text = await page.evaluate(() => navigator.clipboard.readText()) + expect(text).toMatch(/^javascript:/) + }) + + test('Copy link button reverts to "Copy link" after 2 seconds', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await openVault(page) + await page.locator('.vault-pill').click() + await page.locator('.vs-copy-btn').click() + await expect(page.locator('.vs-copy-btn')).toHaveText('Copied!') + await expect(page.locator('.vs-copy-btn')).toHaveText('Copy link', { timeout: 3000 }) + }) + + test('bookmarklet is not visible on per-vault detail page', async ({ page }) => { + await openVault(page) + await page.locator('.vault-pill').click() + await page.locator('.vault-card').first().click() + await expect(page.locator('.vs-bookmarklet-chip')).not.toBeVisible() + }) + +}) + test.describe('VaultSheet read-only vault', () => { test('read-only notice shown in per-vault detail for read-only secondary', async ({ page }) => { diff --git a/pwsafe/record_test.go b/pwsafe/record_test.go index 0201d7b..a16822e 100644 --- a/pwsafe/record_test.go +++ b/pwsafe/record_test.go @@ -2,6 +2,7 @@ package pwsafe import ( "encoding/binary" + "os" "testing" "github.com/stretchr/testify/assert" @@ -118,9 +119,54 @@ func TestRecord_OwnSymbolsForPassword(t *testing.T) { t.Run("UTF-8 Symbols", func(t *testing.T) { r := &Record{} symbols := "§±¿×÷" - + err := r.setField(recordOwnSymbolsForPassword, []byte(symbols)) assert.NoError(t, err) assert.Equal(t, symbols, r.OwnSymbolsForPassword) }) } + +func TestRecord_Autotype(t *testing.T) { + t.Run("setField stores autotype string", func(t *testing.T) { + r := &Record{} + err := r.setField(recordAutotype, []byte(`\u\t\p\n`)) + assert.NoError(t, err) + assert.Equal(t, `\u\t\p\n`, r.Autotype) + }) + + t.Run("empty autotype marshals without error", func(t *testing.T) { + r := Record{Title: "Test", Password: "pass", Autotype: ""} + _, _, err := r.marshal() + assert.NoError(t, err) + }) + + t.Run("autotype survives vault file write-read cycle", func(t *testing.T) { + db := NewV3("test", "password") + rec := Record{Title: "Site", Password: "pass", Autotype: `\u\t\p\n`} + uuid := db.SetRecord(rec) + + savePath := "./test_dbs/autotype_test.dat" + err := WritePWSafeFile(db, savePath) + defer os.Remove(savePath) + assert.NoError(t, err) + + loaded, err := OpenPWSafeFile(savePath, "password") + assert.NoError(t, err) + assert.Equal(t, `\u\t\p\n`, loaded.Records[uuid].Autotype) + }) + + t.Run("record equality checks autotype field", func(t *testing.T) { + r1 := Record{Title: "Test", Autotype: `\u\t\p\n`} + r2 := Record{Title: "Test", Autotype: `\u\t\p\n`} + r3 := Record{Title: "Test", Autotype: `\u\p`} + + equal, err := recordEqual(r1, r2) + assert.NoError(t, err) + assert.True(t, equal) + + equal, err = recordEqual(r1, r3) + assert.False(t, equal) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Autotype") + }) +} From a3ab4a841b32a42b3762ade5c8bac54c310cb6ec Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 19 May 2026 21:36:26 +0200 Subject: [PATCH 02/99] Fix autofill: relay popup through BroadcastChannel to avoid re-unlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit window.open() with a named target cannot cross browsing-context-group boundaries in modern browsers, so every bookmarklet click opened a fresh locked popup requiring re-unlock. Fix: when a popup detects an already-open unlocked Portpass tab via a BroadcastChannel ping, it enters relay mode — skipping WASM and the vault UI entirely, and instead bridging the bookmarklet's postMessage protocol to the main tab over BroadcastChannel. The main tab's Dashboard listens for relay-ping/relay-hello/relay-query and responds in kind. The relay popup auto-closes after forwarding the record or an error. Falls back to the original unlock-in-popup flow when no main tab responds within 300 ms (e.g. Portpass not yet open). Co-Authored-By: Claude Sonnet 4.6 --- pwa/src/App.svelte | 72 +++++++++++++++++++++++++- pwa/src/lib/Dashboard.svelte | 87 ++++++++++++++++++++++++++++++++ pwa/tests/autofill_popup.spec.ts | 81 +++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 2 deletions(-) diff --git a/pwa/src/App.svelte b/pwa/src/App.svelte index e0b2499..fbfb2a7 100644 --- a/pwa/src/App.svelte +++ b/pwa/src/App.svelte @@ -11,6 +11,7 @@ let hasBeenUnlocked = $state(false) let multipleInstances = $state(false) let isPopup = $state(false) + let relayMode = $state(false) let theme = $state(localStorage.getItem('theme') || 'dark') let accent = $state(localStorage.getItem('accent') || 'amber') @@ -20,8 +21,9 @@ $effect(() => { localStorage.setItem('accent', accent) }) // Respond to autofill queries when vault is locked (Dashboard handles unlocked case). + // Suppressed in relay mode — the relay bridge handles all messaging instead. $effect(() => { - if (!isPopup) return + if (!isPopup || relayMode) return function handleLockedQuery(event) { if (view !== 'start') return const t = event.data?.type @@ -66,6 +68,63 @@ .catch(() => {}) }) + // Ping an already-open unlocked Portpass tab via BroadcastChannel. + // Returns true and sets up a relay bridge if found; false otherwise. + async function tryRelay() { + const nonce = crypto.randomUUID() + const ch = new BroadcastChannel('portpass-autofill') + + const found = await new Promise(resolve => { + const t = setTimeout(() => { ch.close(); resolve(false) }, 300) + ch.onmessage = e => { + if (e.data?.type === 'relay-pong' && e.data?.nonce === nonce) { + clearTimeout(t) + resolve(true) + } + } + ch.postMessage({ type: 'relay-ping', nonce }) + }) + + if (!found) return false + + let bookmarkletSource = null + let bookmarkletOrigin = null + let relayNonce = null + + window.addEventListener('message', event => { + if (!event.source) return + const msg = event.data + if (!msg?.type) return + if (msg.type === 'hello') { + bookmarkletSource = event.source + bookmarkletOrigin = event.origin + relayNonce = crypto.randomUUID() + ch.postMessage({ type: 'relay-hello', pubkey: msg.pubkey, nonce: relayNonce }) + } else if (msg.type === 'query' && relayNonce) { + ch.postMessage({ type: 'relay-query', nonce: relayNonce }) + } + }) + + ch.onmessage = e => { + const msg = e.data + if (!bookmarkletSource || msg?.nonce !== relayNonce) return + if (msg.type === 'relay-hello-response') { + bookmarkletSource.postMessage({ type: 'hello', pubkey: msg.pubkey }, bookmarkletOrigin) + } else if (msg.type === 'relay-record') { + bookmarkletSource.postMessage({ + type: 'record', title: msg.title, autotype: msg.autotype, + iv: msg.iv, ciphertext: msg.ciphertext, + }, bookmarkletOrigin) + setTimeout(() => window.close(), 100) + } else if (msg.type === 'relay-error') { + bookmarkletSource.postMessage({ type: 'error', message: msg.message }, bookmarkletOrigin) + setTimeout(() => window.close(), 100) + } + } + + return true + } + onMount(async () => { isPopup = window.opener !== null // Give this window a stable name so the bookmarklet's window.open() can find and @@ -76,6 +135,13 @@ isDesktop = mq.matches mq.addEventListener('change', e => { isDesktop = e.matches }) + // In popup mode, try to relay through an already-open unlocked Portpass tab. + // If relay succeeds, skip WASM loading — the popup is just a bridge. + if (isPopup) { + const relayed = await tryRelay() + if (relayed) { relayMode = true; return } + } + if (navigator.locks) { const held = await new Promise(resolve => { navigator.locks.request('portpass-singleton', { ifAvailable: true }, lock => { @@ -100,7 +166,9 @@ class="vault-app theme-{theme} accent-{accent}" class:is-desktop={isDesktop && view === 'dashboard'} > - {#if wasmError} + {#if relayMode} + + {:else if wasmError}
Failed to load engine: {wasmError}
diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index e3b0367..f88bffa 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -194,6 +194,93 @@ return () => { window.removeEventListener('message', handleMessage); sessionKey = null } }) + // BroadcastChannel relay — lets a relay popup (opened by the bookmarklet) reach this + // unlocked tab across browsing-context-group boundaries. Only the main (non-popup) tab + // acts as relay source so the popup's own dashboard (when unlocked directly) is unaffected. + $effect(() => { + if (isPopup) return + + const ch = new BroadcastChannel('portpass-autofill') + let relaySessionKey = null + let relayHelloInProgress = false + + ch.onmessage = async event => { + const msg = event.data + if (!msg?.type) return + + if (msg.type === 'relay-ping') { + ch.postMessage({ type: 'relay-pong', nonce: msg.nonce }) + return + } + + if (msg.type === 'relay-hello') { + if (relayHelloInProgress) return + relayHelloInProgress = 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'] + ) + relaySessionKey = 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) + ch.postMessage({ type: 'relay-hello-response', pubkey: pubJwk, nonce: msg.nonce }) + } catch { + relaySessionKey = null + ch.postMessage({ type: 'relay-error', message: 'Key exchange failed', nonce: msg.nonce }) + } finally { + relayHelloInProgress = false + } + return + } + + if (msg.type === 'relay-query') { + if (!relaySessionKey) { + ch.postMessage({ type: 'relay-error', message: 'No secure session — click the bookmarklet again', nonce: msg.nonce }) + return + } + if (!selectedUUID) { + ch.postMessage({ type: 'relay-error', message: 'Open a record in Portpass first', nonce: msg.nonce }) + return + } + + const autotype = record?.Autotype || '\\u\\t\\p\\n' + const parseErr = autofillValidateSequence(autotype) + if (parseErr) { + ch.postMessage({ type: 'relay-error', message: `Could not parse autofill sequence: ${autotype}`, nonce: msg.nonce }) + return + } + + const vaultUuid = selectedVaultUuid || dbKey + const fields = {} + if (autotype.includes('\\u')) fields.u = record.Username ?? '' + if (autotype.includes('\\p')) fields.p = getFieldValue(vaultUuid, selectedUUID, 'Password') ?? '' + + try { + const iv = crypto.getRandomValues(new Uint8Array(12)) + const plaintext = new TextEncoder().encode(JSON.stringify(fields)) + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, relaySessionKey, plaintext) + ch.postMessage({ + type: 'relay-record', + title: record.Title, + autotype, + iv: btoa(String.fromCharCode(...iv)), + ciphertext: btoa(String.fromCharCode(...new Uint8Array(ciphertext))), + nonce: msg.nonce, + }) + } catch { + ch.postMessage({ type: 'relay-error', message: 'Encryption failed', nonce: msg.nonce }) + } + } + } + + return () => ch.close() + }) + // Load a record by UUID. vaultUuid is null for primary vault records. function selectRecord(uuid, vaultUuid = null) { if (isEditing && editDirty) { diff --git a/pwa/tests/autofill_popup.spec.ts b/pwa/tests/autofill_popup.spec.ts index 42b8ab9..8ce533d 100644 --- a/pwa/tests/autofill_popup.spec.ts +++ b/pwa/tests/autofill_popup.spec.ts @@ -319,3 +319,84 @@ test.describe('Autofill popup mode — UI', () => { // lock the vault, and confirm the sub-text reads "Unlock to use Autofill". }) + +// --------------------------------------------------------------------------- +// Relay mode: popup bridges between bookmarklet and an already-open main tab +// --------------------------------------------------------------------------- + +test.describe('Autofill relay mode', () => { + + test.beforeEach(async ({ context }) => { + await context.addInitScript(() => { + if ((window as any).PublicKeyCredential) { + (window.PublicKeyCredential as any).isUserVerifyingPlatformAuthenticatorAvailable = async () => false + } + ;(window as any).showSaveFilePicker = async () => ({ + name: 'new.psafe3', + createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }), + }) + }) + }) + + // Opens a main Portpass tab (non-popup), unlocks a new vault, and returns the page. + async function openMainTab(context: BrowserContext): Promise { + const main = await context.newPage() + await main.goto(PORTPASS_URL) + await main.getByRole('button', { name: 'Create one' }).click() + await main.getByPlaceholder('Master password').fill('testpassword') + await main.getByRole('button', { name: 'Create vault' }).click() + await expect(main.getByPlaceholder('Search vault')).toBeVisible({ timeout: 10000 }) + return main + } + + test('relay popup does not show unlock UI when main tab is unlocked', async ({ context }) => { + await openMainTab(context) + const { popup } = await openPortpassPopup(context) + // 300 ms relay ping + buffer; main tab responds instantly so relay succeeds quickly + await popup.waitForTimeout(500) + await expect(popup.getByRole('button', { name: 'Open vault file' })).toHaveCount(0) + await expect(popup.getByRole('button', { name: 'Create one' })).toHaveCount(0) + }) + + test('relay delivers correct credentials from the main tab', async ({ context }) => { + const main = await openMainTab(context) + await main.getByRole('button', { name: 'New', exact: true }).click() + await main.getByPlaceholder('e.g. Bank of America').fill('Relay Test') + await main.locator('input.mono').first().fill('hunter2') + await main.locator('input.input').nth(2).fill('alice') + await main.locator('.autotype-input').fill('\\u\\t\\p\\n') + await main.getByRole('button', { name: 'Save' }).click() + await main.locator('.record-row', { hasText: 'Relay Test' }).click() + + const { opener, popup } = await openPortpassPopup(context) + await popup.waitForTimeout(500) // Wait for relay mode to activate + + await doKeyExchange(opener) + const response = await sendQuery(opener) + + expect(response.type).toBe('record') + expect(response.title).toBe('Relay Test') + expect(response.autotype).toBe('\\u\\t\\p\\n') + expect(response.fields.u).toBe('alice') + expect(response.fields.p).toBe('hunter2') + }) + + test('relay returns error when main tab has no record selected', async ({ context }) => { + await openMainTab(context) // Unlocked, no record selected + const { opener, popup } = await openPortpassPopup(context) + await popup.waitForTimeout(500) + + await doKeyExchange(opener) + const response = await sendQuery(opener) + + expect(response.type).toBe('error') + expect(response.message).toContain('Open a record') + }) + + test('relay popup falls back to unlock UI when no main tab is open', async ({ context }) => { + const { popup } = await openPortpassPopup(context) + // Relay ping times out (300 ms), then WASM loads and unlock UI appears + await popup.waitForSelector('button:text("Create one")', { timeout: 10000 }) + }) + +}) From f0b43a7951354ce55ae83af80feaa0cc8472a4c9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 May 2026 08:31:30 +0200 Subject: [PATCH 03/99] Autofill: relay.html becomes popup picker UI, eliminating tab-switch flashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bookmarklet previously opened relay.html twice as a full tab (once to fetch matching records, once to fetch credentials), causing two disruptive tab switches per autofill. Now relay.html opens once as a small popup window (popup=yes) and handles the entire flow itself: ECDH key exchange with Dashboard, URL search, picker UI, credential decryption, and fill delivery. The bookmarklet is reduced to: open popup → wait for ready → send init → wait for fill → execute autotype. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wasm/main.go | 8 +- pwa/public/relay.html | 336 ++++++++++++++++++++++++++++++++++ pwa/src/App.svelte | 22 ++- pwa/src/lib/Dashboard.svelte | 229 +++++++++++++++++------ pwa/src/lib/RecordList.svelte | 2 +- pwa/src/lib/bookmarklet.js | 267 +++++++++++++-------------- pwa/src/wasm.js | 5 +- pwa/tests/bookmarklet.spec.ts | 58 +++--- pwsafe/db.go | 57 +++++- pwsafe/db_test.go | 50 +++++ 10 files changed, 804 insertions(+), 230 deletions(-) create mode 100644 pwa/public/relay.html diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index 3a517bc..12b45a8 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -95,6 +95,7 @@ func getDBData(this js.Value, args []js.Value) interface{} { UUID string `json:"uuid"` Title string `json:"title"` Group string `json:"group"` + URL string `json:"url"` HasTOTP bool `json:"hasTOTP"` } @@ -104,6 +105,7 @@ func getDBData(this js.Value, args []js.Value) interface{} { UUID: uuidHex, Title: rec.Title, Group: rec.Group, + URL: rec.URL, HasTOTP: len(rec.TwoFactorKey) > 0, }) } @@ -443,11 +445,11 @@ func searchRecords(this js.Value, args []js.Value) interface{} { return "database not open" } if len(args) != 3 { - return "invalid arguments: expected (vaultUuid, query, namesOnly)" + return "invalid arguments: expected (vaultUuid, query, mode)" } query := args[1].String() - namesOnly := args[2].Bool() - uuids := db.Search(query, namesOnly) + mode := args[2].Int() // 0=all fields, 1=names only, 2=URL exact match + uuids := db.Search(query, mode) jsonData, err := json.Marshal(uuids) if err != nil { return fmt.Sprintf("json marshal error: %s", err) diff --git a/pwa/public/relay.html b/pwa/public/relay.html new file mode 100644 index 0000000..a29a715 --- /dev/null +++ b/pwa/public/relay.html @@ -0,0 +1,336 @@ + + + + +Portpass autofill + + + + +
+ +
Connecting to Portpass…
+ + +
+ + + diff --git a/pwa/src/App.svelte b/pwa/src/App.svelte index fbfb2a7..f18b3e6 100644 --- a/pwa/src/App.svelte +++ b/pwa/src/App.svelte @@ -95,13 +95,22 @@ if (!event.source) return const msg = event.data if (!msg?.type) return + if (msg.type === 'hello') { bookmarkletSource = event.source bookmarkletOrigin = event.origin relayNonce = crypto.randomUUID() ch.postMessage({ type: 'relay-hello', pubkey: msg.pubkey, nonce: relayNonce }) } else if (msg.type === 'query' && relayNonce) { - ch.postMessage({ type: 'relay-query', nonce: relayNonce }) + // Cross-validate the sent URL's hostname against the browser-provided event.origin. + if (msg.url !== undefined) { + const sentHost = msg.url.split('/')[0] + const evHost = new URL(event.origin).host.replace(/^www\./, '').toLowerCase() + if (sentHost !== evHost) return + } + ch.postMessage({ type: 'relay-query', url: msg.url, uuid: msg.uuid, vaultUuid: msg.vaultUuid, nonce: relayNonce }) + } else if (msg.type === 'save-url' && relayNonce) { + ch.postMessage({ type: 'relay-save-url', uuid: msg.uuid, vaultUuid: msg.vaultUuid, url: msg.url, nonce: relayNonce }) } }) @@ -110,12 +119,16 @@ if (!bookmarkletSource || msg?.nonce !== relayNonce) return if (msg.type === 'relay-hello-response') { bookmarkletSource.postMessage({ type: 'hello', pubkey: msg.pubkey }, bookmarkletOrigin) + } else if (msg.type === 'relay-records') { + bookmarkletSource.postMessage({ type: 'records', records: msg.records }, bookmarkletOrigin) } else if (msg.type === 'relay-record') { bookmarkletSource.postMessage({ type: 'record', title: msg.title, autotype: msg.autotype, iv: msg.iv, ciphertext: msg.ciphertext, }, bookmarkletOrigin) setTimeout(() => window.close(), 100) + } else if (msg.type === 'relay-url-saved') { + bookmarkletSource.postMessage({ type: 'url-saved' }, bookmarkletOrigin) } else if (msg.type === 'relay-error') { bookmarkletSource.postMessage({ type: 'error', message: msg.message }, bookmarkletOrigin) setTimeout(() => window.close(), 100) @@ -127,9 +140,6 @@ onMount(async () => { isPopup = window.opener !== null - // Give this window a stable name so the bookmarklet's window.open() can find and - // focus the existing tab instead of always opening a new one. - if (!isPopup) window.name = 'portpass_autofill' const mq = window.matchMedia('(min-width: 768px)') isDesktop = mq.matches @@ -167,7 +177,9 @@ class:is-desktop={isDesktop && view === 'dashboard'} > {#if relayMode} - +
+ Portpass autofill in progress
This tab will close automatically +
{:else if wasmError}
Failed to load engine: {wasmError} diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index f88bffa..973d48e 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -87,6 +87,126 @@ toast.set({ message, action, duration }) } + // --------------------------------------------------------------------------- + // Autofill utilities + // --------------------------------------------------------------------------- + + // Mirror of Go's CanonicalURL: strip scheme, www., query, fragment, trailing slash. + function canonicalURL(href) { + let s = href || '' + for (const p of ['https://', 'http://']) { + if (s.toLowerCase().startsWith(p)) { s = s.slice(p.length); break } + } + const hash = s.indexOf('#'); if (hash >= 0) s = s.slice(0, hash) + const qs = s.indexOf('?'); if (qs >= 0) s = s.slice(0, qs) + s = s.toLowerCase() + const slash = s.indexOf('/') + if (slash >= 0) s = s.slice(0, slash).replace(/^www\./, '') + s.slice(slash) + else s = s.replace(/^www\./, '') + return s.replace(/\/+$/, '') + } + + function levenshtein(a, b) { + const m = a.length, n = b.length + const dp = Array.from({length: m + 1}, (_, i) => + Array.from({length: n + 1}, (_, j) => i ? (j ? 0 : i) : j)) + for (let i = 1; i <= m; i++) + for (let j = 1; j <= n; j++) + dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] + : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + return dp[m][n] + } + + // Search all open vaults for URL-matching records. Returns a list suitable for + // the bookmarklet picker: exact URL matches first, then Levenshtein suggestions + // (≤5 distance, up to 5 results). Each entry: { uuid, vaultUuid, title, existingUrl, isCurrent }. + function autofillFindRecords(queryUrl) { + const canonical = canonicalURL(queryUrl) + const allVaults = [ + { uuid: dbKey, items: get(dbItems) }, + ...get(secondaryVaults).map(v => ({ uuid: v.uuid, items: v.items || [] })), + ] + + // Exact URL match (mode 2) + const exact = [] + for (const { uuid: vaultUuid } of allVaults) { + try { + for (const uuid of searchRecords(vaultUuid, canonical, 2)) { + const rec = getRecordData(vaultUuid, uuid) + exact.push({ uuid, vaultUuid: vaultUuid === dbKey ? null : vaultUuid, + title: rec.Title, existingUrl: rec.URL, isCurrent: uuid === selectedUUID }) + } + } catch {} + } + if (exact.length) return exact + + // Fuzzy fallback: Levenshtein on hostname, up to 5 suggestions ≤ distance 5 + const queryHost = canonical.split('/')[0] + const candidates = [] + + if (selectedUUID && record) { + candidates.push({ uuid: selectedUUID, vaultUuid: selectedVaultUuid, + title: record.Title, existingUrl: record.URL, isCurrent: true, _d: -1 }) + } + + for (const { uuid: vaultUuid, items } of allVaults) { + for (const item of items) { + if (item.uuid === selectedUUID) continue + const itemHost = canonicalURL(item.url || '').split('/')[0] + if (!itemHost) continue + const d = levenshtein(queryHost, itemHost) + if (d <= 5) + candidates.push({ uuid: item.uuid, vaultUuid: vaultUuid === dbKey ? null : vaultUuid, + title: item.title, existingUrl: item.url, isCurrent: false, _d: d }) + } + } + + return candidates.sort((a, b) => a._d - b._d).slice(0, 5) + .map(({ _d, ...rest }) => rest) + } + + // Encrypt credentials for a specific record using the given AES-GCM session key. + async function autofillEncryptRecord(sessionKey, uuid, vaultUuid) { + const v = vaultUuid || dbKey + const rec = getRecordData(v, uuid) + const autotype = rec.Autotype || '\\u\\t\\p\\n' + const parseErr = autofillValidateSequence(autotype) + if (parseErr) throw new Error(`Could not parse autofill sequence: ${autotype}`) + + const fields = {} + if (autotype.includes('\\u')) fields.u = rec.Username ?? '' + if (autotype.includes('\\p')) fields.p = getFieldValue(v, uuid, 'Password') ?? '' + + const iv = crypto.getRandomValues(new Uint8Array(12)) + const pt = new TextEncoder().encode(JSON.stringify(fields)) + const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, sessionKey, pt) + return { + title: rec.Title, autotype, + iv: btoa(String.fromCharCode(...iv)), + ciphertext: btoa(String.fromCharCode(...new Uint8Array(ct))), + } + } + + // Update a record's URL field and save the vault to disk. + async function autofillSaveURL(uuid, vaultUuid, newUrl) { + const v = vaultUuid || dbKey + updateRecordFields(v, uuid, { URL: newUrl }) + if (!vaultUuid) { + dbItems.set(getDatabaseData(dbKey)) + isDirty = true + await saveFile(true) + } else { + const sv = get(secondaryVaults).find(s => s.uuid === vaultUuid) + if (!sv) throw new Error('Vault not found') + const items = getDatabaseData(v) + secondaryVaults.update(vs => vs.map(s => s.uuid === vaultUuid + ? { ...s, items: items.map(i => ({ ...i, vaultUuid })) } : s)) + const data = saveDatabase(v) + const w = await sv.handle.createWritable() + await w.write(data); await w.close() + } + } + // Autofill postMessage handler — ECDH key exchange then encrypted query response. function autofillValidateSequence(seq) { if (!seq) return '' @@ -151,42 +271,39 @@ return } - if (!selectedUUID) { - event.source.postMessage( - { type: 'error', message: 'Open a record in Portpass first' }, - event.origin - ) + // 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 } - // Fall back to the standard sequence when the record has no custom autotype. - const autotype = record?.Autotype || '\\u\\t\\p\\n' - - const parseErr = autofillValidateSequence(autotype) - if (parseErr) { - event.source.postMessage( - { type: 'error', message: `Could not parse autofill sequence: ${autotype}` }, - event.origin - ) + // 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 + } - const vaultUuid = selectedVaultUuid || dbKey - const fields = {} - if (autotype.includes('\\u')) fields.u = record.Username ?? '' - if (autotype.includes('\\p')) fields.p = getFieldValue(vaultUuid, selectedUUID, 'Password') ?? '' - - const iv = crypto.getRandomValues(new Uint8Array(12)) - const plaintext = new TextEncoder().encode(JSON.stringify(fields)) - const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, sessionKey, plaintext) - - event.source.postMessage({ - type: 'record', - title: record.Title, - autotype, - iv: btoa(String.fromCharCode(...iv)), - ciphertext: btoa(String.fromCharCode(...new Uint8Array(ciphertext))), - }, event.origin) + 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) + } } } @@ -243,37 +360,39 @@ ch.postMessage({ type: 'relay-error', message: 'No secure session — click the bookmarklet again', nonce: msg.nonce }) return } - if (!selectedUUID) { - ch.postMessage({ type: 'relay-error', message: 'Open a record in Portpass first', nonce: msg.nonce }) + + // URL search + if (msg.url !== undefined) { + ch.postMessage({ type: 'relay-records', records: autofillFindRecords(msg.url), nonce: msg.nonce }) return } - const autotype = record?.Autotype || '\\u\\t\\p\\n' - const parseErr = autofillValidateSequence(autotype) - if (parseErr) { - ch.postMessage({ type: 'relay-error', message: `Could not parse autofill sequence: ${autotype}`, nonce: msg.nonce }) + // Targeted credential fetch + const uuid = msg.uuid || selectedUUID + const vaultUuid = msg.uuid ? (msg.vaultUuid || null) : selectedVaultUuid + if (!uuid) { + ch.postMessage({ type: 'relay-error', message: 'Open a record in Portpass first', nonce: msg.nonce }) return } + try { + const result = await autofillEncryptRecord(relaySessionKey, uuid, vaultUuid) + ch.postMessage({ type: 'relay-record', ...result, nonce: msg.nonce }) + } catch (e) { + ch.postMessage({ type: 'relay-error', message: e.message, nonce: msg.nonce }) + } + return + } - const vaultUuid = selectedVaultUuid || dbKey - const fields = {} - if (autotype.includes('\\u')) fields.u = record.Username ?? '' - if (autotype.includes('\\p')) fields.p = getFieldValue(vaultUuid, selectedUUID, 'Password') ?? '' - + if (msg.type === 'relay-save-url') { + if (!relaySessionKey) { + ch.postMessage({ type: 'relay-error', message: 'No secure session', nonce: msg.nonce }) + return + } try { - const iv = crypto.getRandomValues(new Uint8Array(12)) - const plaintext = new TextEncoder().encode(JSON.stringify(fields)) - const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, relaySessionKey, plaintext) - ch.postMessage({ - type: 'relay-record', - title: record.Title, - autotype, - iv: btoa(String.fromCharCode(...iv)), - ciphertext: btoa(String.fromCharCode(...new Uint8Array(ciphertext))), - nonce: msg.nonce, - }) - } catch { - ch.postMessage({ type: 'relay-error', message: 'Encryption failed', nonce: msg.nonce }) + await autofillSaveURL(msg.uuid, msg.vaultUuid || null, msg.url) + ch.postMessage({ type: 'relay-url-saved', nonce: msg.nonce }) + } catch (e) { + ch.postMessage({ type: 'relay-error', message: e.message, nonce: msg.nonce }) } } } @@ -774,7 +893,7 @@ if (pendingDeleteUUID) list = list.filter(i => i.uuid !== pendingDeleteUUID) if (query.trim()) { try { - const matched = new Set(searchRecords(vaultUuid ?? dbKey, query, false)) + const matched = new Set(searchRecords(vaultUuid ?? dbKey, query, 0)) list = list.filter(i => matched.has(i.uuid)) } catch {} } diff --git a/pwa/src/lib/RecordList.svelte b/pwa/src/lib/RecordList.svelte index 150250f..658412a 100644 --- a/pwa/src/lib/RecordList.svelte +++ b/pwa/src/lib/RecordList.svelte @@ -169,7 +169,7 @@ if (excludeUUID) list = list.filter(i => i.uuid !== excludeUUID) if (q.trim() && vaultUuid) { try { - const matched = new Set(searchRecords(vaultUuid, q, false)) + const matched = new Set(searchRecords(vaultUuid, q, 0)) list = list.filter(i => matched.has(i.uuid)) } catch { /* vault temporarily unavailable — show unfiltered */ } } diff --git a/pwa/src/lib/bookmarklet.js b/pwa/src/lib/bookmarklet.js index 3dbd22e..28612b1 100644 --- a/pwa/src/lib/bookmarklet.js +++ b/pwa/src/lib/bookmarklet.js @@ -16,54 +16,46 @@ function buildCode(portpassUrl, portpassOrigin) { function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { 'use strict' - // Prevent concurrent runs (e.g. double-click). if (window.__ppRunning) return window.__ppRunning = true + // Capture focused element before window.open() can blur it. + var activeEl = document.activeElement + + var isSecure = window.location.protocol === 'https:' || window.location.hostname === 'localhost' + var currentCanonical = canonicalURL(window.location.href) + var RELAY_URL = PORTPASS_URL + 'relay.html' + ;(async function run() { try { - // 1. Open or focus the Portpass tab/window. - const pp = window.open(PORTPASS_URL, 'portpass_autofill') + // Open relay.html as a small popup window. relay.html handles the picker UI, + // ECDH key exchange with Dashboard, and sends a fill command back to this page. + var pp = window.open(RELAY_URL, '_blank', 'popup=yes,width=360,height=480') if (!pp) { showError('Portpass could not open — allow popups for this site'); return } - // 2. ECDH key exchange (with retry so WASM loading time is covered). - const pair = await crypto.subtle.generateKey( - { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveKey'] - ) - const ourPub = await crypto.subtle.exportKey('jwk', pair.publicKey) - - let helloReply - try { - helloReply = await sendRetry(pp, { type: 'hello', pubkey: ourPub }, PORTPASS_ORIGIN) - } catch (msg) { - showError(typeof msg === 'string' ? msg : 'Portpass did not respond — make sure it is open and unlocked') + // Wait for relay to finish connecting to Dashboard and doing key exchange. + var readyMsg + try { readyMsg = await recv(pp, ['ready', 'error'], 8000) } + catch (_) { + try { pp.close() } catch (_2) {} + showError('Portpass did not respond — make sure it is open and unlocked') return } - if (helloReply.type === 'error') { showError(helloReply.message); return } - - const ppPub = await crypto.subtle.importKey( - 'jwk', helloReply.pubkey, { name: 'ECDH', namedCurve: 'P-256' }, false, [] - ) - const sessionKey = await crypto.subtle.deriveKey( - { name: 'ECDH', public: ppPub }, pair.privateKey, - { name: 'AES-GCM', length: 256 }, false, ['decrypt'] - ) - - // 3. Query for the currently open record. - pp.postMessage({ type: 'query' }, PORTPASS_ORIGIN) - let qReply - try { qReply = await recv(pp, ['record', 'error']) } - catch (_) { showError('No response to query — try again'); return } - if (qReply.type === 'error') { showError(qReply.message); return } - - // 4. Decrypt the credentials. - const iv = Uint8Array.from(atob(qReply.iv), c => c.charCodeAt(0)) - const ct = Uint8Array.from(atob(qReply.ciphertext), c => c.charCodeAt(0)) - const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, sessionKey, ct) - const fields = JSON.parse(new TextDecoder().decode(pt)) - - // 5. Show the two-step overlay. - showOverlay(qReply.title, qReply.autotype, fields) + if (readyMsg.type === 'error') { showError(readyMsg.message); return } + + // Send the current page URL so relay can search for matching records. + pp.postMessage({ type: 'init', url: currentCanonical, isSecure: isSecure }, PORTPASS_ORIGIN) + + // Wait for fill command or error. User may take time to pick a record. + var result + try { result = await recv(pp, ['fill', 'error'], 60000) } + catch (_) { try { pp.close() } catch (_2) {} ; return } + if (result.type === 'error') { showError(result.message); return } + + // Execute the autofill sequence on the login page. + var startEl = isUsableInput(activeEl) ? activeEl : null + showFillOverlay(result.title, result.autotype, result.fields, startEl) + } catch (e) { showError(e.message || String(e)) } finally { @@ -71,38 +63,78 @@ function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { } })() - // Send msg to target and wait for a matching reply type, retrying every second - // until timeout (to survive WASM load time on a freshly opened Portpass). - function sendRetry(target, msg, origin, timeout, interval) { - timeout = timeout || 15000 - interval = interval || 1000 - return new Promise(function(resolve, reject) { - var start = Date.now(), timer - function handler(e) { - if (e.source !== target) return - var t = e.data && e.data.type - if (t === msg.type || t === 'error') { - clearInterval(timer) - window.removeEventListener('message', handler) - resolve(e.data) - } - } - window.addEventListener('message', handler) - function attempt() { - if (Date.now() - start > timeout) { - clearInterval(timer) - window.removeEventListener('message', handler) - reject('Portpass did not respond — make sure it is open and unlocked') - return - } - try { target.postMessage(msg, origin) } catch (_) {} + // ── Overlay helpers ────────────────────────────────────────────────────── + + function mkOverlay() { + var div = document.createElement('div') + div.id = '__pp' + div.style.cssText = [ + 'position:fixed', 'top:16px', 'right:16px', 'z-index:2147483647', + 'background:#1a1d21', 'color:#f1ede4', 'border-radius:8px', + 'padding:12px 16px', 'font-family:system-ui,-apple-system,sans-serif', + 'font-size:14px', 'max-width:320px', + 'box-shadow:0 4px 20px rgba(0,0,0,.5)', + ].join(';') + var close = document.createElement('button') + close.textContent = '×' + close.style.cssText = 'position:absolute;top:6px;right:10px;background:none;border:none;color:#888;font-size:20px;cursor:pointer;padding:0;line-height:1' + close.onclick = removeOverlay + div.appendChild(close) + return div + } + + function showFillOverlay(title, autotype, fields, startEl) { + removeOverlay() + var div = mkOverlay() + + var titleEl = document.createElement('div') + titleEl.style.cssText = 'font-weight:600;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px' + titleEl.textContent = title + + var hint = document.createElement('div') + hint.style.cssText = 'font-size:12px;color:#aaa;margin-top:4px' + hint.textContent = startEl ? 'Autofilling…' : 'Click the field to start from' + + div.appendChild(titleEl) + div.appendChild(hint) + document.body.appendChild(div) + + if (startEl) { + executeAutotype(startEl, autotype, fields) + setTimeout(removeOverlay, 1200) + } else { + function onFieldClick(e) { + var el = e.target + var tag = el && el.tagName + if (tag !== 'INPUT' && tag !== 'TEXTAREA') return + if (el.type === 'hidden') return + e.preventDefault() + document.removeEventListener('click', onFieldClick, true) + removeOverlay() + executeAutotype(el, autotype, fields) } - attempt() - timer = setInterval(attempt, interval) - }) + document.addEventListener('click', onFieldClick, true) + } } - // Wait for the next message from target matching one of the given types. + function showError(msg) { + removeOverlay() + var div = mkOverlay() + var err = document.createElement('div') + err.style.cssText = 'color:#e06c75;padding-right:20px' + err.textContent = msg + div.appendChild(err) + document.body.appendChild(div) + setTimeout(removeOverlay, 8000) + } + + function removeOverlay() { + var el = document.getElementById('__pp') + if (el) el.parentNode.removeChild(el) + } + + // ── Messaging helpers ──────────────────────────────────────────────────── + function recv(target, types, timeout) { timeout = timeout || 5000 return new Promise(function(resolve, reject) { @@ -122,7 +154,34 @@ function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { }) } - // Fill a field via the native value setter so React/Vue/Angular frameworks notice. + // ── DOM / autotype helpers ─────────────────────────────────────────────── + + 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 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 + var bad = ['submit', 'button', 'reset', 'image', 'checkbox', 'radio'] + if (bad.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') @@ -131,9 +190,6 @@ function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { el.dispatchEvent(new Event('change', { bubbles: true })) } - // Return the next text-entry field after el, respecting tabindex order. - // Deliberately excludes buttons, links, and non-text inputs so that \t skips - // UI controls (e.g. a "show password" button) that appear between form fields. function nextFocusable(el) { 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]),' + @@ -142,7 +198,6 @@ function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { var s = getComputedStyle(e) return e.tabIndex >= 0 && s.display !== 'none' && s.visibility !== 'hidden' }) - // Elements with tabindex > 0 first (ascending), then tabindex=0 in DOM order. var pos = all.filter(function(e) { return e.tabIndex > 0 }) .sort(function(a, b) { return a.tabIndex - b.tabIndex }) var zero = all.filter(function(e) { return e.tabIndex === 0 }) @@ -151,11 +206,10 @@ function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { return i >= 0 ? sorted[i + 1] || null : null } - // Execute the autotype sequence from startEl. function executeAutotype(startEl, sequence, fields) { var el = startEl for (var i = 0; i < sequence.length; i += 2) { - var code = sequence[i + 1] // sequence[i] === '\\' + var code = sequence[i + 1] if (code === 'u' || code === 'p') { if (el) fillField(el, fields[code] || '') } else if (code === 't') { @@ -171,67 +225,4 @@ function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { } } } - - function mkOverlay() { - var div = document.createElement('div') - div.id = '__pp' - div.style.cssText = [ - 'position:fixed', 'top:16px', 'right:16px', 'z-index:2147483647', - 'background:#1a1d21', 'color:#f1ede4', 'border-radius:8px', - 'padding:12px 16px', 'font-family:system-ui,-apple-system,sans-serif', - 'font-size:14px', 'max-width:280px', - 'box-shadow:0 4px 20px rgba(0,0,0,.5)', - ].join(';') - var close = document.createElement('button') - close.textContent = '×' - close.style.cssText = 'position:absolute;top:6px;right:10px;background:none;border:none;color:#666;font-size:20px;cursor:pointer;padding:0;line-height:1' - close.onclick = removeOverlay - div.appendChild(close) - return div - } - - function showOverlay(title, autotype, fields) { - removeOverlay() - var div = mkOverlay() - - var titleEl = document.createElement('div') - titleEl.style.cssText = 'font-weight:600;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:240px' - titleEl.textContent = title - - var hint = document.createElement('div') - hint.style.cssText = 'font-size:12px;color:#aaa;margin-top:4px' - hint.textContent = 'Click the field to start from' - - div.appendChild(titleEl) - div.appendChild(hint) - document.body.appendChild(div) - - function onFieldClick(e) { - var el = e.target - var tag = el && el.tagName - if (tag !== 'INPUT' && tag !== 'TEXTAREA') return - if (el.type === 'hidden') return - e.preventDefault() - document.removeEventListener('click', onFieldClick, true) - removeOverlay() - executeAutotype(el, autotype, fields) - } - document.addEventListener('click', onFieldClick, true) - } - - function showError(msg) { - removeOverlay() - var div = mkOverlay() - var err = document.createElement('div') - err.style.cssText = 'color:#e06c75;padding-right:20px' - err.textContent = msg - div.appendChild(err) - document.body.appendChild(div) - setTimeout(removeOverlay, 8000) - } - - function removeOverlay() { - var el = document.getElementById('__pp') - if (el) el.parentNode.removeChild(el) - } } diff --git a/pwa/src/wasm.js b/pwa/src/wasm.js index c1dafb2..b423b38 100644 --- a/pwa/src/wasm.js +++ b/pwa/src/wasm.js @@ -106,8 +106,9 @@ export function deleteRecord(vaultUuid, recordUuid) { if (err) throw new Error(err) } -export function searchRecords(vaultUuid, query, namesOnly) { - return parseOrThrow(window.searchRecords(vaultUuid, query, namesOnly)) +// mode: 0 = all fields, 1 = names/group only, 2 = URL exact match +export function searchRecords(vaultUuid, query, mode) { + return parseOrThrow(window.searchRecords(vaultUuid, query, mode)) } export function getAutocompleteSuggestion(vaultUuid, field, prefix) { diff --git a/pwa/tests/bookmarklet.spec.ts b/pwa/tests/bookmarklet.spec.ts index abc7a8e..312bd71 100644 --- a/pwa/tests/bookmarklet.spec.ts +++ b/pwa/tests/bookmarklet.spec.ts @@ -15,8 +15,8 @@ const LOGIN_FORM_HTML = ` ` -// Stand up the login page and a Portpass popup from it so window.opener is set. -// Returns { loginPage, portpassPopup }. +// Opens a main (non-popup) Portpass tab with an unlocked vault, then a separate login +// page. The bookmarklet will open a relay popup that bridges to the main Portpass tab. async function setupAutofillTest(context: BrowserContext): Promise<{ login: Page, portpass: Page }> { await context.addInitScript(() => { if ((window as any).PublicKeyCredential) { @@ -28,27 +28,21 @@ async function setupAutofillTest(context: BrowserContext): Promise<{ login: Page }) }) - // Serve the login form at LOGIN_PATH. + // Main Portpass tab (non-popup) — the relay popup will bridge to this. + const portpass = await context.newPage() + await portpass.goto(PORTPASS_URL) + await portpass.getByRole('button', { name: 'Create one' }).click() + await portpass.getByPlaceholder('Master password').fill('testpassword') + await portpass.getByRole('button', { name: 'Create vault' }).click() + await expect(portpass.getByPlaceholder('Search vault')).toBeVisible({ timeout: 10000 }) + + // Login form page — the bookmarklet runs here. const login = await context.newPage() await login.route(LOGIN_PATH, route => route.fulfill({ contentType: 'text/html', body: LOGIN_FORM_HTML }) ) await login.goto(LOGIN_URL) - // Open Portpass from the login page so window.opener is established. - const [portpass] = await Promise.all([ - context.waitForEvent('page'), - login.evaluate((url) => { - ;(window as any).portpassWin = window.open(url, 'portpass_autofill') - }, PORTPASS_URL), - ]) - - // Create a vault in the popup. - await portpass.getByRole('button', { name: 'Create one' }).click() - await portpass.getByPlaceholder('Master password').fill('testpassword') - await portpass.getByRole('button', { name: 'Create vault' }).click() - await expect(portpass.getByPlaceholder('Search vault')).toBeVisible({ timeout: 10000 }) - return { login, portpass } } @@ -67,14 +61,36 @@ async function createRecord(portpass: Page, opts: { await portpass.locator('.record-row', { hasText: opts.title }).click() } -// Run the bookmarklet on the login page and wait for the overlay to appear. +// Run the bookmarklet on the login page. Opens the relay popup, drives the picker +// (selecting the first record and clicking Autofill), and waits for the popup to close. +// For error cases the popup closes automatically; the caller then checks login page state. async function activateBookmarklet(login: Page) { + const context = login.context() + const popupPromise = context.waitForEvent('page') + const code = makeBookmarkletUrl(PORTPASS_URL) .replace('javascript:', '') // strip the scheme — evaluate runs the code directly await login.evaluate(new Function(decodeURIComponent(code)) as any) - await expect(login.locator('#__pp')).toBeVisible({ timeout: 12000 }) - // Give the browser one animation frame so the click listener registered inside showOverlay() - // is guaranteed to be active before the test clicks a field. + + const relay = await popupPromise + await relay.waitForLoadState('domcontentloaded') + + // Wait for either the picker (radio buttons) or the popup closing (error case). + const radios = relay.locator('input[type="radio"]') + const which = await Promise.race([ + radios.first().waitFor({ timeout: 10000 }).then(() => 'picker').catch(() => 'timeout'), + relay.waitForEvent('close', { timeout: 10000 }).then(() => 'closed').catch(() => 'timeout'), + ]) + + if (which === 'picker') { + await radios.first().click() + await relay.locator('#autofill-btn').click() + // Wait for relay to close after delivering the fill command. + await relay.waitForEvent('close', { timeout: 8000 }).catch(() => {}) + } + + // Give the browser one animation frame so any fill overlay or error overlay on the + // login page is guaranteed to be mounted before the test makes assertions. await login.evaluate(() => new Promise(r => requestAnimationFrame(r))) } diff --git a/pwsafe/db.go b/pwsafe/db.go index 26e05bc..8b8090c 100644 --- a/pwsafe/db.go +++ b/pwsafe/db.go @@ -87,10 +87,52 @@ func (db V3) ListByGroup(group string) []string { return entries } -// Search returns titles of records matching all whitespace-separated terms in query. -// When namesOnly is true only title and group are searched; otherwise username, -// URL, and notes are included. Password is never searched. -func (db V3) Search(query string, namesOnly bool) []string { +// CanonicalURL returns a normalised form suitable for exact-match URL search: +// scheme stripped, "www." prefix stripped, lowercased, query string and fragment +// removed, trailing slash removed. +// E.g. "https://www.Bank.com/Login/?ref=1#top" → "bank.com/login" +func CanonicalURL(rawURL string) string { + s := rawURL + for _, pfx := range []string{"https://", "http://"} { + if len(s) >= len(pfx) && strings.ToLower(s[:len(pfx)]) == pfx { + s = s[len(pfx):] + break + } + } + if i := strings.IndexByte(s, '#'); i >= 0 { + s = s[:i] + } + if i := strings.IndexByte(s, '?'); i >= 0 { + s = s[:i] + } + s = strings.ToLower(s) + if slash := strings.IndexByte(s, '/'); slash >= 0 { + s = strings.TrimPrefix(s[:slash], "www.") + s[slash:] + } else { + s = strings.TrimPrefix(s, "www.") + } + return strings.TrimRight(s, "/") +} + +// Search returns UUIDs of records matching query. +// +// - mode 0 (all fields): title, group, username, URL, notes, email, and +// non-sensitive custom field names and values. +// - mode 1 (names only): title and group. +// - mode 2 (URL exact): records whose URL field canonicalises to the same +// value as the query. Password is never searched in any mode. +func (db V3) Search(query string, mode int) []string { + if mode == 2 { + canonical := CanonicalURL(query) + var results []string + for key, rec := range db.Records { + if CanonicalURL(rec.URL) == canonical { + results = append(results, key) + } + } + return results + } + terms := strings.Fields(strings.ToLower(query)) if len(terms) == 0 { return db.List() @@ -98,10 +140,15 @@ func (db V3) Search(query string, namesOnly bool) []string { var results []string for key, rec := range db.Records { var hay string - if namesOnly { + if mode == 1 { hay = strings.ToLower(rec.Title + "\n" + rec.Group) } else { hay = strings.ToLower(rec.Title + "\n" + rec.Group + "\n" + rec.Username + "\n" + rec.URL + "\n" + rec.Notes + "\n" + rec.Email) + for _, cf := range rec.CustomFields { + if !cf.Sensitive { + hay += "\n" + strings.ToLower(cf.Name) + "\n" + strings.ToLower(cf.Value) + } + } } match := true for _, t := range terms { diff --git a/pwsafe/db_test.go b/pwsafe/db_test.go index aceaf72..ebd015f 100644 --- a/pwsafe/db_test.go +++ b/pwsafe/db_test.go @@ -42,6 +42,56 @@ func TestInvalidFile(t *testing.T) { assert.NotNil(t, err) } +func TestCanonicalURL(t *testing.T) { + cases := [][2]string{ + {"https://www.example.com/login/", "example.com/login"}, + {"http://www.example.com/login/", "example.com/login"}, + {"https://example.com/login", "example.com/login"}, + {"https://www.Example.COM/Login/?ref=1#top", "example.com/login"}, + {"example.com", "example.com"}, + {"example.com/path", "example.com/path"}, + {"https://sub.example.com/a/b/", "sub.example.com/a/b"}, + {"", ""}, + } + for _, c := range cases { + assert.Equal(t, c[1], CanonicalURL(c[0]), "input: %q", c[0]) + } +} + +func TestSearchModeURL(t *testing.T) { + db := NewV3("test", "pw") + db.SetRecord(Record{Title: "Bank", URL: "https://www.bank.com/login/"}) + db.SetRecord(Record{Title: "Other", URL: "https://other.com"}) + + hits := db.Search("bank.com/login", 2) + assert.Len(t, hits, 1) + + hits = db.Search("bank.com", 2) + assert.Len(t, hits, 0, "path mismatch should not match") + + hits = db.Search("https://www.bank.com/login/", 2) + assert.Len(t, hits, 1, "full URL query should match") +} + +func TestSearchModeAllIncludesCustomFields(t *testing.T) { + db := NewV3("test", "pw") + db.SetRecord(Record{ + Title: "Site", + CustomFields: []CustomField{ + {Name: "accountId", Value: "12345", Sensitive: false}, + {Name: "secret", Value: "hidden", Sensitive: true}, + }, + }) + hits := db.Search("accountId", 0) + assert.Len(t, hits, 1, "non-sensitive custom field name should be searched") + + hits = db.Search("12345", 0) + assert.Len(t, hits, 1, "non-sensitive custom field value should be searched") + + hits = db.Search("hidden", 0) + assert.Len(t, hits, 0, "sensitive custom field value must not be searched") +} + func TestSetRecordTimes(t *testing.T) { db := NewV3("test", "password") record := Record{Title: "Test Record", Password: "password"} From c0fdc2d9107369c5b1030b9da89a760839a250b6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 May 2026 08:38:21 +0200 Subject: [PATCH 04/99] Autofill: single-click record row to autofill, remove radio+button step Co-Authored-By: Claude Sonnet 4.6 --- pwa/public/relay.html | 103 ++++++++++++---------------------- pwa/tests/bookmarklet.spec.ts | 9 ++- 2 files changed, 40 insertions(+), 72 deletions(-) diff --git a/pwa/public/relay.html b/pwa/public/relay.html index a29a715..f9da866 100644 --- a/pwa/public/relay.html +++ b/pwa/public/relay.html @@ -29,12 +29,17 @@ flex-direction: column; gap: 6px; } -.rec-row { padding: 8px 10px; border-radius: 6px; background: #252830 } -label.rec-main { +.rec-row { + padding: 8px 10px; + border-radius: 6px; + background: #252830; + cursor: pointer; +} +.rec-row:hover { background: #2e3240 } +.rec-title-row { display: flex; align-items: baseline; gap: 8px; - cursor: pointer; } .rec-title { flex: 1; @@ -44,26 +49,13 @@ } .rec-badge { font-size: 11px; color: #8db8ff; flex-shrink: 0 } label.rec-replace { - display: none; + display: flex; align-items: baseline; gap: 6px; cursor: pointer; - margin: 4px 0 0 20px; + margin: 6px 0 0 0; } -label.rec-replace.visible { display: flex } label.rec-replace span { font-size: 11px; color: #aaa } -#footer { padding-top: 4px } -#autofill-btn { - width: 100%; - padding: 8px 14px; - background: #c79a3b; - border: none; - border-radius: 6px; - color: #fff; - font-size: 14px; - cursor: pointer; -} -#autofill-btn:disabled { opacity: 0.4; cursor: default } .err { color: #e06c75; font-size: 13px } @@ -72,9 +64,6 @@
Connecting to Portpass…
-
{#if genOpen} @@ -516,8 +558,10 @@ autocomplete="off" spellcheck="false"/> {#if autotypeError}
{autotypeError}
+ {:else if autotypeWarning} +
{autotypeWarning}
{:else if draft.Autotype} -
\u = username · \p = password · \t = Tab · \n = Enter
+
\u username · \p password · \m email · \2 OTP · \fN custom field N · \t Tab · \s Shift-Tab · \n Enter · \wNNN wait NNNms · \WNNN wait NNNs · \\ = \
{:else}
Leave blank to use default: \u\t\p\n
{/if} @@ -726,6 +770,13 @@ padding: 0 2px; } + .autotype-warning { + font-size: 12px; + color: var(--accent); + margin-top: 4px; + padding: 0 2px; + } + .autotype-hint { font-size: 12px; margin-top: 4px; diff --git a/pwa/src/lib/RecordRead.svelte b/pwa/src/lib/RecordRead.svelte index d30bbe1..fde7df5 100644 --- a/pwa/src/lib/RecordRead.svelte +++ b/pwa/src/lib/RecordRead.svelte @@ -215,6 +215,31 @@ return relTime(new Date(ts * 1000).toISOString()) } + function warnAutotype(seq) { + if (!seq) return '' + const supported = new Set(['u', 'p', 't', 'n', 'm', '2', 's', '\\', 'f', 'w', 'W']) + const unknown = new Set() + let i = 0 + while (i < seq.length) { + if (seq[i] !== '\\') { i++; continue } + if (i + 1 >= seq.length) break + const code = seq[i + 1] + if (code === 'f') { + const d = seq[i + 2] + d !== undefined && /^[0-9]$/.test(d) ? (i += 3) : (i += 2) + } else if (code === 'w' || code === 'W') { + let j = i + 2, count = 0 + while (j < seq.length && count < 3 && /^[0-9]$/.test(seq[j])) { j++; count++ } + i = count ? j : i + 2 + } else { + if (!supported.has(code)) unknown.add('\\' + code) + i += 2 + } + } + if (!unknown.size) return '' + return `Portpass will skip unsupported code${unknown.size > 1 ? 's' : ''}: ${[...unknown].join(', ')}` + } + function parseHistory(raw) { if (!raw || raw.length < 5) return [] const count = parseInt(raw.slice(3, 5), 16) @@ -472,6 +497,9 @@
Autofill sequence
{record.Autotype}
+ {#if warnAutotype(record.Autotype)} +
{warnAutotype(record.Autotype)}
+ {/if}
{/if} @@ -557,4 +585,10 @@ color: var(--text-soft); padding: 2px 0; } + + .autotype-warning { + font-size: 12px; + color: var(--accent); + margin-top: 4px; + } diff --git a/pwa/src/lib/bookmarklet.js b/pwa/src/lib/bookmarklet.js index 93ba475..b5199ca 100644 --- a/pwa/src/lib/bookmarklet.js +++ b/pwa/src/lib/bookmarklet.js @@ -109,8 +109,7 @@ function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { document.body.appendChild(div) if (startEl) { - executeAutotype(startEl, autotype, fields) - setTimeout(removeOverlay, 1200) + executeAutotype(startEl, autotype, fields).then(removeOverlay) } else { function onFieldClick(e) { var el = e.target @@ -180,6 +179,40 @@ function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { return s.replace(/\/+$/, '') } + function parseAutotype(seq) { + var tokens = [] + var lit = '' + var i = 0 + while (i < seq.length) { + if (seq[i] !== '\\') { lit += seq[i]; i++; continue } + var code = seq[i + 1] + if (!code) break + if (code === '\\') { + // Literal backslash — accumulate into lit without flushing, so adjacent + // literals stay in one token and don't overwrite each other in fillField. + 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++ } + var ms = (parseInt(seq.slice(i + 2, j)) || 0) * (code === 'W' ? 1000 : 1) + tokens.push({ type: 'delay', ms: ms }); 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 // unknown code — skip without flushing lit so surrounding literals merge + } + } + if (lit) tokens.push({ type: 'lit', text: lit }) + return tokens + } + function isUsableInput(el) { if (!el) return false var tag = el.tagName @@ -199,7 +232,7 @@ function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { el.dispatchEvent(new Event('change', { bubbles: true })) } - function nextFocusable(el) { + 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])' @@ -210,27 +243,44 @@ function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { var pos = all.filter(function(e) { return e.tabIndex > 0 }) .sort(function(a, b) { return a.tabIndex - b.tabIndex }) var zero = all.filter(function(e) { return e.tabIndex === 0 }) - var sorted = pos.concat(zero) + return pos.concat(zero) + } + + function nextFocusable(el) { + var sorted = focusableList() var i = sorted.indexOf(el) return i >= 0 ? sorted[i + 1] || null : null } - function executeAutotype(startEl, sequence, fields) { + 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 < sequence.length; i += 2) { - var code = sequence[i + 1] - if (code === 'u' || code === 'p') { - 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 + 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') { + var form = el && el.closest('form') + if (form) try { form.requestSubmit() } catch (_) { form.submit() } } - } else if (code === 'n') { - var form = el && el.closest('form') - if (form) try { form.requestSubmit() } catch (_) { form.submit() } } } } diff --git a/pwa/tests/autofill.spec.ts b/pwa/tests/autofill.spec.ts index ee6b312..cb3060d 100644 --- a/pwa/tests/autofill.spec.ts +++ b/pwa/tests/autofill.spec.ts @@ -36,15 +36,15 @@ test.describe('Autofill sequence — edit form', () => { await expect(page.getByRole('button', { name: 'Save' })).not.toBeDisabled() }) - test('unknown code blocks Save and shows error', async ({ page }) => { + test('unknown code shows warning but does not block Save', async ({ page }) => { await createVault(page) await page.getByRole('button', { name: 'New', exact: true }).click() await page.getByPlaceholder('e.g. Bank of America').fill('Test') await page.locator('input.mono').first().fill('pass') await page.locator('.autotype-input').fill('\\u\\x\\p') - await expect(page.locator('.autotype-error')).toBeVisible() - await expect(page.locator('.autotype-error')).toContainText('\\x') - await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + await expect(page.locator('.autotype-warning')).toBeVisible() + await expect(page.locator('.autotype-warning')).toContainText('\\x') + await expect(page.getByRole('button', { name: 'Save' })).not.toBeDisabled() }) test('trailing backslash blocks Save and shows error', async ({ page }) => { @@ -57,16 +57,37 @@ test.describe('Autofill sequence — edit form', () => { await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() }) - test('literal character in sequence blocks Save and shows error', async ({ page }) => { + test('\\f0 blocks Save and shows error', async ({ page }) => { await createVault(page) await page.getByRole('button', { name: 'New', exact: true }).click() await page.getByPlaceholder('e.g. Bank of America').fill('Test') await page.locator('input.mono').first().fill('pass') - await page.locator('.autotype-input').fill('abc') + await page.locator('.autotype-input').fill('\\f0') await expect(page.locator('.autotype-error')).toBeVisible() + await expect(page.locator('.autotype-error')).toContainText('\\f0') await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() }) + test('\\w with no digits blocks Save and shows error', async ({ page }) => { + await createVault(page) + await page.getByRole('button', { name: 'New', exact: true }).click() + await page.getByPlaceholder('e.g. Bank of America').fill('Test') + await page.locator('input.mono').first().fill('pass') + await page.locator('.autotype-input').fill('\\w') + await expect(page.locator('.autotype-error')).toBeVisible() + await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + test('literal text in sequence is valid', async ({ page }) => { + await createVault(page) + await page.getByRole('button', { name: 'New', exact: true }).click() + await page.getByPlaceholder('e.g. Bank of America').fill('Test') + await page.locator('input.mono').first().fill('pass') + await page.locator('.autotype-input').fill('\\u\\tabc123\\t\\p') + await expect(page.locator('.autotype-error')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Save' })).not.toBeDisabled() + }) + test('empty sequence shows default hint and does not block save', async ({ page }) => { await createVault(page) await page.getByRole('button', { name: 'New', exact: true }).click() @@ -77,12 +98,12 @@ test.describe('Autofill sequence — edit form', () => { await expect(page.getByRole('button', { name: 'Save' })).not.toBeDisabled() }) - test('correcting invalid sequence clears the error', async ({ page }) => { + test('correcting a structural error clears it', async ({ page }) => { await createVault(page) await page.getByRole('button', { name: 'New', exact: true }).click() await page.getByPlaceholder('e.g. Bank of America').fill('Test') await page.locator('input.mono').first().fill('pass') - await page.locator('.autotype-input').fill('\\q') + await page.locator('.autotype-input').fill('\\f0') await expect(page.locator('.autotype-error')).toBeVisible() await page.locator('.autotype-input').fill('\\u\\p') await expect(page.locator('.autotype-error')).toHaveCount(0) @@ -145,7 +166,12 @@ test.describe('Autofill sequence — round-trip persistence', () => { await page.getByRole('button', { name: 'New', exact: true }).click() await page.getByPlaceholder('e.g. Bank of America').fill('Test') await page.locator('input.mono').first().fill('pass') - for (const seq of ['\\u', '\\p', '\\t', '\\n', '\\u\\t\\p\\n', '\\u\\p\\u\\p']) { + for (const seq of [ + '\\u', '\\p', '\\t', '\\n', '\\m', '\\2', '\\s', '\\\\', + '\\f', '\\f1', '\\f9', + '\\w1', '\\w100', '\\w999', '\\W1', '\\W999', + '\\u\\t\\p\\n', 'abc', '\\u\\tabc123\\t\\p', + ]) { await page.locator('.autotype-input').fill(seq) await expect(page.locator('.autotype-error')).toHaveCount(0) } From 67a4bc1bb03f37b48dd1969720b1cc8d0dff2373 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 May 2026 09:42:33 +0200 Subject: [PATCH 08/99] Autofill edit form: always show code legend alongside error or warning Co-Authored-By: Claude Sonnet 4.6 --- pwa/src/lib/RecordEdit.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pwa/src/lib/RecordEdit.svelte b/pwa/src/lib/RecordEdit.svelte index a33d4f2..1989471 100644 --- a/pwa/src/lib/RecordEdit.svelte +++ b/pwa/src/lib/RecordEdit.svelte @@ -560,7 +560,8 @@
{autotypeError}
{:else if autotypeWarning}
{autotypeWarning}
- {:else if draft.Autotype} + {/if} + {#if draft.Autotype}
\u username · \p password · \m email · \2 OTP · \fN custom field N · \t Tab · \s Shift-Tab · \n Enter · \wNNN wait NNNms · \WNNN wait NNNs · \\ = \
{:else}
Leave blank to use default: \u\t\p\n
From 6f0b7d73820aaacd75f7f99efc59e9ddd40d150d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 May 2026 09:56:09 +0200 Subject: [PATCH 09/99] Autofill UI: add Portpass logo to relay popup, fill overlay, and bookmarklet chip - relay.html: Pkey favicon + brand bar (logo + "Portpass" heading) - bookmarklet fill overlay: brand bar with 16px logo above record title - VaultSheet bookmarklet chip: replace bookmark SVG with Pkey icon Co-Authored-By: Claude Sonnet 4.6 --- pwa/public/relay.html | 16 ++++++++++++++++ pwa/src/lib/VaultSheet.svelte | 4 +--- pwa/src/lib/bookmarklet.js | 12 ++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/pwa/public/relay.html b/pwa/public/relay.html index 75e4594..fcb1098 100644 --- a/pwa/public/relay.html +++ b/pwa/public/relay.html @@ -4,6 +4,7 @@ Portpass autofill + diff --git a/pwa/src/lib/bookmarklet.js b/pwa/src/lib/bookmarklet.js index f0ada35..08d7b3b 100644 --- a/pwa/src/lib/bookmarklet.js +++ b/pwa/src/lib/bookmarklet.js @@ -1,19 +1,18 @@ -// Portpass autofill bookmarklet. -// makeBookmarkletUrl(portpassUrl) returns the javascript: URL for the bookmarks bar link. +// Portpass autofill bookmarklet — delegate (cross-profile) variant. +// makeDelegateBookmarkletUrl(portpassUrl, privKeyJwk) returns the javascript: URL +// for a named delegate. privKeyJwk is a Web Crypto JWK export of the ECDSA P-256 private key. -export function makeBookmarkletUrl(portpassUrl) { +export function makeDelegateBookmarkletUrl(portpassUrl, privKeyJwk) { const origin = new URL(portpassUrl).origin - return 'javascript:' + encodeURIComponent(buildCode(portpassUrl, origin)) + return 'javascript:' + encodeURIComponent( + `(${DELEGATE_BOOKMARKLET_IIFE.toString()})(${JSON.stringify(portpassUrl)},${JSON.stringify(origin)},${JSON.stringify(privKeyJwk)})` + ) } -function buildCode(portpassUrl, portpassOrigin) { - return `(${BOOKMARKLET_IIFE.toString()}) - (${JSON.stringify(portpassUrl)},${JSON.stringify(portpassOrigin)})` -} - -// Self-contained IIFE. Receives (portpassUrl, portpassOrigin) as parameters so -// makeBookmarkletUrl can embed them at install time via JSON.stringify. -function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { +// Self-contained IIFE embedded in the javascript: URL. +// PORTPASS_URL and PORTPASS_ORIGIN are baked in at install time via JSON.stringify. +// PRIV_KEY_JWK is the ECDSA P-256 private key; passed to relay.html which signs the request. +function DELEGATE_BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN, PRIV_KEY_JWK) { 'use strict' if (window.__ppRunning) return @@ -29,25 +28,30 @@ function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { ;(async function run() { try { - // Open relay.html as a small popup window. relay.html handles the picker UI, - // ECDH key exchange with Dashboard, and sends a fill command back to this page. var pp = window.open(RELAY_URL, '_blank', 'popup=yes,width=360,height=480') if (!pp) { showError('Portpass could not open — allow popups for this site'); return } - // Wait for relay to finish connecting to Dashboard and doing key exchange. + // Wait for relay.html to signal it is ready to receive the init message. var readyMsg - try { readyMsg = await recv(pp, ['ready', 'error'], 8000) } + try { readyMsg = await recv(pp, ['ready', 'error'], 10000) } catch (_) { try { pp.close() } catch (_2) {} - showError('Portpass did not respond — make sure it is open and unlocked') + showError('Portpass autofill did not start — make sure portpass-relay is running') return } if (readyMsg.type === 'error') { showError(readyMsg.message); return } - // Send the current page URL so relay can search for matching records. - pp.postMessage({ type: 'init', url: currentCanonical, saveUrl: saveUrl, isSecure: isSecure }, PORTPASS_ORIGIN) - - // Wait for fill command or error. Also watch for the user closing the popup. + // Send URL + private signing key to relay.html with strict targetOrigin. + // relay.html signs the request, fires web+portpass://, and polls the relay server. + pp.postMessage({ + type: 'init', + url: currentCanonical, + saveUrl: saveUrl, + isSecure: isSecure, + privKey: PRIV_KEY_JWK, + }, PORTPASS_ORIGIN) + + // Wait for fill command (relay decrypted and forwarded credentials) or error. var result try { result = await Promise.race([ @@ -61,7 +65,6 @@ function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { } catch (_) { return } if (result.type === 'error') { showError(result.message); return } - // Execute the autofill sequence on the login page. var startEl = isUsableInput(activeEl) ? activeEl : null showFillOverlay(result.title, result.autotype, result.fields, startEl) @@ -200,8 +203,6 @@ function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { var code = seq[i + 1] if (!code) break if (code === '\\') { - // Literal backslash — accumulate into lit without flushing, so adjacent - // literals stay in one token and don't overwrite each other in fillField. lit += '\\'; i += 2 } else if (code === 'f') { if (lit) { tokens.push({ type: 'lit', text: lit }); lit = '' } @@ -218,7 +219,7 @@ function BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN) { if (lit) { tokens.push({ type: 'lit', text: lit }); lit = '' } tokens.push({ type: 'code', code: code }); i += 2 } else { - i += 2 // unknown code — skip without flushing lit so surrounding literals merge + i += 2 } } if (lit) tokens.push({ type: 'lit', text: lit }) diff --git a/pwa/src/lib/delegates.js b/pwa/src/lib/delegates.js new file mode 100644 index 0000000..448fcbb --- /dev/null +++ b/pwa/src/lib/delegates.js @@ -0,0 +1,68 @@ +import { get, set, del } from 'idb-keyval' + +const STORAGE_KEY = 'delegates-v1' + +async function load() { + return (await get(STORAGE_KEY)) ?? {} +} + +async function save(all) { + if (Object.keys(all).length === 0) await del(STORAGE_KEY) + else await set(STORAGE_KEY, all) +} + +export async function getDelegates(vaultUuid) { + if (!vaultUuid) return [] + const all = await load() + return all[vaultUuid] ?? [] +} + +export async function addDelegate(vaultUuid, name, publicKeySpki) { + const all = await load() + const delegate = { + id: crypto.randomUUID(), + name, + publicKey: Array.from(new Uint8Array(publicKeySpki)), + created: Date.now(), + useCount: 0, + lastUsed: null, + } + all[vaultUuid] = [delegate, ...(all[vaultUuid] ?? [])] + await save(all) + return delegate +} + +export async function revokeDelegate(vaultUuid, delegateId) { + const all = await load() + const list = (all[vaultUuid] ?? []).filter(d => d.id !== delegateId) + if (list.length === 0) delete all[vaultUuid] + else all[vaultUuid] = list + await save(all) +} + +// Verify a signature against registered delegates. On success, increments useCount/lastUsed +// and returns the matching delegate. Returns null if no delegate matches or signature is invalid. +export async function verifyAndUpdate(vaultUuid, spkiBytes, message, signatureBytes) { + const all = await load() + const list = all[vaultUuid] ?? [] + for (const d of list) { + const stored = new Uint8Array(d.publicKey) + if (stored.length !== spkiBytes.length) continue + if (!stored.every((b, i) => b === spkiBytes[i])) continue + try { + const key = await crypto.subtle.importKey( + 'spki', spkiBytes, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'] + ) + const valid = await crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, key, signatureBytes, message + ) + if (valid) { + d.useCount++ + d.lastUsed = Date.now() + await save(all) + return d + } + } catch { continue } + } + return null +} diff --git a/pwa/vite.config.js b/pwa/vite.config.js index a897de4..61d00fa 100644 --- a/pwa/vite.config.js +++ b/pwa/vite.config.js @@ -50,7 +50,20 @@ export default defineConfig({ type: 'image/svg+xml', purpose: 'any' } - ] + ], + protocol_handlers: [ + { + protocol: 'web+portpass', + url: '/portpass/?intent=%s' + } + ], + launch_handler: { + client_mode: 'focus-existing' + } + }, + devOptions: { + enabled: true, + type: 'module', }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg,wasm,gz}'], From 4a57fb18dd25cfb1da8380cadbc33a1cf75cc289 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 May 2026 21:31:58 +0200 Subject: [PATCH 12/99] =?UTF-8?q?Autofill:=20relay.html=20dual-mode,=20sig?= =?UTF-8?q?nature=20verification,=20credential=20drop=20(steps=202?= =?UTF-8?q?=E2=80=934)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit relay.html: - Mode detection now based on BC ping result, not privKey presence; after init arrives, waits 200ms for a BC pong — same-profile if pong comes, cross-profile if it times out (privKey always present for delegate bookmarklets) - Cross-profile path: import ECDSA privKey, derive pubkey SPKI from JWK, sign {url,nonce,ecdh,ts}, fire web+portpass:// via anchor click, poll 127.0.0.1:7677/pick/{nonce}, ECDH-decrypt blob, show picker - Same-profile path: unchanged BC flow; onHelloResponse sends query with currentUrl directly (pendingSameProfileUrl buffer removed) - processRelayBlob handles {error} blobs from Portpass App.svelte (handleIntent): - Parses web+portpass://autofill?url=&sig=&pub=&ecdh=&nonce=&ts= params - Rejects if vault locked, params missing, or ts outside ±60s/5s window - Verifies ECDSA signature via verifyAndUpdate (increments useCount/lastUsed) - On success sets pendingIntent state, passed as prop to Dashboard Dashboard.svelte: - New intent/onclearintent props - buildRecordFields: extracts plain credential fields for cross-profile blob - processAutofillIntent: finds matching records, ECDH-encrypts array for relay.html's pubkey, POSTs {ephPub,iv,ciphertext} blob to relay server; POSTs {error} blob on failure so relay.html shows error immediately Co-Authored-By: Claude Sonnet 4.6 --- pwa/public/relay.html | 340 ++++++++++++++++++++++++++--------- pwa/src/App.svelte | 41 ++++- pwa/src/lib/Dashboard.svelte | 105 ++++++++++- 3 files changed, 400 insertions(+), 86 deletions(-) diff --git a/pwa/public/relay.html b/pwa/public/relay.html index 37bf8fa..2a6da3c 100644 --- a/pwa/public/relay.html +++ b/pwa/public/relay.html @@ -85,15 +85,30 @@ ;(function() { 'use strict' + // ── Shared state ────────────────────────────────────────────────────────── var nonce = crypto.randomUUID() - var relayNonce = null var ch = new BroadcastChannel('portpass-autofill') - var sessionKey = null - var keyPair = null var bookmarkletOrigin = null var isSecure = false var currentUrl = '' var saveUrl = '' + + // ── Mode detection state ────────────────────────────────────────────────── + var cprivKey = null // private key from init message; used by cross-profile path + var bcPongArrived = false + var modeDecided = false + var modeTimer = null + + // ── Cross-profile state ─────────────────────────────────────────────────── + // ECDH key pair generated at startup; public key sent to Portpass in the + // web+portpass:// URL so Portpass can encrypt the credential blob for us. + var cpEcdhKeyPair = null + var cpEcdhSpkiB64 = null + + // ── Same-profile state ──────────────────────────────────────────────────── + var relayNonce = null + var sessionKey = null + var keyPair = null var selectedUuid = null var selectedVaultUuid = null @@ -101,21 +116,223 @@ var $status = document.getElementById('status') var $records = document.getElementById('records') - // 1. Ping Dashboard via BroadcastChannel to confirm it is open and unlocked. - var pingTimer = setTimeout(function() { - ch.close() - sendError('Portpass is not open — open and unlock Portpass first') - }, 5000) + // ── Startup: generate ECDH key pair, signal ready ───────────────────────── + // 'ready' is sent as soon as the key pair is available, before the BC ping + // completes. Mode is decided in onOpenerMessage once both init and pong state are known. + crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey'] + ).then(function(kp) { + cpEcdhKeyPair = kp + return crypto.subtle.exportKey('spki', kp.publicKey) + }).then(function(spki) { + cpEcdhSpkiB64 = btoa(String.fromCharCode.apply(null, new Uint8Array(spki))) + if (window.opener) window.opener.postMessage({ type: 'ready' }, '*') + window.addEventListener('message', onOpenerMessage) + }).catch(function() { sendError('Startup failed') }) + // ── Ping Dashboard via BroadcastChannel (runs concurrently with ECDH above) ─ + // If Portpass responds, same-profile mode is used; otherwise cross-profile. + // Mode is finalised in onOpenerMessage once init arrives. ch.onmessage = function(e) { if (e.data && e.data.type === 'relay-pong' && e.data.nonce === nonce) { - clearTimeout(pingTimer) - doHello() + bcPongArrived = true + if (!modeDecided && cprivKey !== null) { + // init already arrived; pong confirms same-profile + modeDecided = true + clearTimeout(modeTimer) + doHello() + } } } ch.postMessage({ type: 'relay-ping', nonce: nonce }) - // 2. ECDH key exchange with Dashboard. + // ── Mode detection: init message from bookmarklet ───────────────────────── + function onOpenerMessage(event) { + var msg = event.data + if (!msg || msg.type !== 'init') return + window.removeEventListener('message', onOpenerMessage) + + bookmarkletOrigin = event.origin + currentUrl = msg.url || '' + saveUrl = msg.saveUrl || currentUrl + isSecure = !!msg.isSecure + cprivKey = msg.privKey || null + + // Validate URL hostname against browser-reported origin. + if (currentUrl) { + try { + var sentHost = currentUrl.split('/')[0] + var evHost = new URL(event.origin).host.replace(/^www\./, '').toLowerCase() + if (evHost && sentHost !== evHost) { sendError('URL mismatch — security check failed'); return } + } catch(_) {} + } + + if (bcPongArrived) { + // BC pong already received: Portpass is in the same profile. + modeDecided = true + doHello() + } else { + // Wait briefly for a BC pong. If none arrives, fall back to cross-profile. + modeTimer = setTimeout(function() { + if (!modeDecided) { + modeDecided = true + if (cprivKey) { + startCrossProfile(cprivKey) + } else { + sendError('Portpass is not open — open and unlock Portpass first') + } + } + }, 200) + } + } + + // ── Cross-profile path ──────────────────────────────────────────────────── + + async function startCrossProfile(privKeyJwk) { + $status.textContent = 'Requesting credentials…' + try { + // Import ECDSA private signing key from the JWK embedded in the bookmarklet. + var signingKey = await crypto.subtle.importKey( + 'jwk', privKeyJwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'] + ) + + // Derive the ECDSA public key SPKI from the JWK (x, y are the public components). + // Portpass uses this to identify which registered delegate is making the request. + var pubJwk = { kty: privKeyJwk.kty, crv: privKeyJwk.crv, x: privKeyJwk.x, y: privKeyJwk.y } + var pubKey = await crypto.subtle.importKey( + 'jwk', pubJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'] + ) + var pubSpki = await crypto.subtle.exportKey('spki', pubKey) + var pubSpkiB64 = btoa(String.fromCharCode.apply(null, new Uint8Array(pubSpki))) + + // Sign {url, nonce, ecdh, ts}. Portpass verifies this before acting. + var ts = Date.now() + var signedPayload = JSON.stringify({ url: currentUrl, nonce: nonce, ecdh: cpEcdhSpkiB64, ts: ts }) + var sigBytes = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, signingKey, new TextEncoder().encode(signedPayload) + ) + var sigB64 = btoa(String.fromCharCode.apply(null, new Uint8Array(sigBytes))) + + // Fire web+portpass:// URL via anchor click (doesn't navigate relay.html away). + var params = new URLSearchParams({ + url: currentUrl, sig: sigB64, pub: pubSpkiB64, + ecdh: cpEcdhSpkiB64, nonce: nonce, ts: String(ts) + }) + var a = document.createElement('a') + a.href = 'web+portpass://autofill?' + params.toString() + a.style.display = 'none' + document.body.appendChild(a) + a.click() + a.remove() + + // Poll relay server until Portpass deposits the encrypted blob. + $status.textContent = 'Waiting for Portpass…' + var blob = await pollRelayServer(nonce, 60000) + await processRelayBlob(blob) + + } catch(e) { + sendError(e.message || 'Autofill failed') + } + } + + // Polls GET /pick/{nonce} every 500ms until a blob is available or timeout. + // Throws immediately if relay server is not reachable (TypeError = connection refused). + async function pollRelayServer(pollNonce, timeout) { + var deadline = Date.now() + timeout + var url = 'http://127.0.0.1:7677/pick/' + pollNonce + while (Date.now() < deadline) { + try { + var resp = await fetch(url) + if (resp.ok) return resp.json() + if (resp.status !== 404) throw new Error('Relay server error ' + resp.status) + } catch(e) { + if (e instanceof TypeError) throw new Error('portpass-relay is not running — start it first') + throw e + } + await new Promise(function(r) { setTimeout(r, 500) }) + } + throw new Error('Timed out waiting for Portpass') + } + + // Decrypts the relay server blob using the ECDH key pair generated at startup. + // Blob format: { ephPub: base64(JSON-stringified JWK), iv: base64, ciphertext: base64 } + // Or error format: { error: "message" } + // Plaintext: JSON array of records, each with pre-decrypted fields. + async function processRelayBlob(blob) { + if (blob.error) { sendError(blob.error); return } + 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( + { name: 'ECDH', public: ephPubKey }, cpEcdhKeyPair.privateKey, + { name: 'AES-GCM', length: 256 }, false, ['decrypt'] + ) + 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) + var records = JSON.parse(new TextDecoder().decode(pt)) + showCrossProfilePicker(records) + } + + // Cross-profile picker: fields are already decrypted; clicking a row sends fill immediately. + function showCrossProfilePicker(records) { + if (!records || !records.length) { sendError('No matching passwords found'); return } + $status.style.display = 'none' + $header.style.display = '' + $records.style.display = '' + + records.forEach(function(rec) { + var row = document.createElement('div') + row.className = 'rec-row' + var titleRow = document.createElement('div') + titleRow.className = 'rec-title-row' + var titleEl = document.createElement('span') + titleEl.className = 'rec-title' + titleEl.textContent = rec.title + titleRow.appendChild(titleEl) + + if (rec.isCurrent) { + var badge = document.createElement('span') + badge.className = 'rec-badge' + badge.textContent = 'open' + titleRow.appendChild(badge) + } + if (rec.matchType === 'exact') { + var mb = document.createElement('span') + mb.style.cssText = 'font-size:11px;color:#7dbf8e;flex-shrink:0' + mb.textContent = 'URL match' + titleRow.appendChild(mb) + } else if (rec.matchType === 'fuzzy') { + var mb = document.createElement('span') + mb.style.cssText = 'font-size:11px;color:#888;flex-shrink:0' + mb.textContent = 'similar URL' + titleRow.appendChild(mb) + } + row.appendChild(titleRow) + + ;(function(r) { + row.onclick = function() { + if (!isSecure && sequenceHasSensitiveCode(r.autotype || '', r.sensitiveCodes || [])) { + sendError('Cannot autofill sensitive fields on a non-HTTPS page') + return + } + if (window.opener) { + window.opener.postMessage( + { type: 'fill', title: r.title, autotype: r.autotype, fields: r.fields }, + bookmarkletOrigin || '*' + ) + } + setTimeout(function() { window.close() }, 100) + } + })(rec) + + $records.appendChild(row) + }) + } + + // ── Same-profile path ───────────────────────────────────────────────────── + async function doHello() { try { keyPair = await crypto.subtle.generateKey( @@ -145,43 +362,11 @@ { name: 'ECDH', public: ppPub }, keyPair.privateKey, { name: 'AES-GCM', length: 256 }, false, ['decrypt'] ) - } catch(_) { - sendError('Key exchange failed') - return - } + } catch(_) { sendError('Key exchange failed'); return } ch.onmessage = onBCMessage - // Signal bookmarklet that relay is ready. - if (window.opener) window.opener.postMessage({ type: 'ready' }, '*') - - // 3. Wait for init message from bookmarklet with the current page URL. - window.addEventListener('message', onOpenerMessage) - } - - function onOpenerMessage(event) { - var msg = event.data - if (!msg || msg.type !== 'init') return - window.removeEventListener('message', onOpenerMessage) - - bookmarkletOrigin = event.origin - currentUrl = msg.url || '' - saveUrl = msg.saveUrl || currentUrl - isSecure = /^https:/i.test(event.origin) || /^http:\/\/localhost/i.test(event.origin) - - // Validate: canonical URL hostname must match the browser-provided event.origin. - if (currentUrl) { - try { - var sentHost = currentUrl.split('/')[0] - var evHost = new URL(event.origin).host.replace(/^www\./, '').toLowerCase() - if (evHost && sentHost !== evHost) { - sendError('URL mismatch — security check failed') - return - } - } catch(_) {} - } - - // 4. Ask Dashboard for URL-matching records. + // currentUrl is set from init before doHello() was called. $status.textContent = 'Searching for matches…' ch.postMessage({ type: 'relay-query', url: currentUrl, nonce: relayNonce }) } @@ -189,19 +374,14 @@ function onBCMessage(e) { var msg = e.data if (!msg || msg.nonce !== relayNonce) return - if (msg.type === 'relay-records') showPicker(msg.records) + if (msg.type === 'relay-records') showPicker(msg.records) else if (msg.type === 'relay-record') deliverFill(msg) else if (msg.type === 'relay-url-saved') fetchCredentials() else if (msg.type === 'relay-error') sendError(msg.message) } - // 5. Render the record picker UI. Clicking a row immediately triggers autofill. function showPicker(records) { - if (!records || !records.length) { - sendError('Open a record in Portpass first') - return - } - + if (!records || !records.length) { sendError('Open a record in Portpass first'); return } $status.style.display = 'none' $header.style.display = '' $records.style.display = '' @@ -209,10 +389,8 @@ records.forEach(function(rec) { var row = document.createElement('div') row.className = 'rec-row' - var titleRow = document.createElement('div') titleRow.className = 'rec-title-row' - var titleEl = document.createElement('span') titleEl.className = 'rec-title' titleEl.textContent = rec.title @@ -237,8 +415,6 @@ } row.appendChild(titleRow) - // Replace URL checkbox — always visible when record URL differs from current page. - // Clicking the checkbox does not trigger autofill (stopPropagation). var replaceCheck = null if (rec.existingUrl && canonicalURL(rec.existingUrl) !== currentUrl) { var replaceLabel = document.createElement('label') @@ -279,8 +455,32 @@ ch.postMessage({ type: 'relay-query', uuid: selectedUuid, vaultUuid: selectedVaultUuid, nonce: relayNonce }) } + async function deliverFill(msg) { + try { + if (!isSecure && sequenceHasSensitiveCode(msg.autotype || '', msg.sensitiveCodes || [])) { + sendError('Cannot autofill sensitive fields on a non-HTTPS page') + return + } + var iv = Uint8Array.from(atob(msg.iv), function(c) { return c.charCodeAt(0) }) + var ct = Uint8Array.from(atob(msg.ciphertext), function(c) { return c.charCodeAt(0) }) + var pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, sessionKey, ct) + var fields = JSON.parse(new TextDecoder().decode(pt)) + if (window.opener) { + window.opener.postMessage( + { type: 'fill', title: msg.title, autotype: msg.autotype, fields: fields }, + bookmarkletOrigin || '*' + ) + } + setTimeout(function() { window.close() }, 100) + } catch(_) { + sendError('Failed to decrypt credentials') + } + } + + // ── Shared helpers ──────────────────────────────────────────────────────── + // Returns true if the autotype sequence references any of the given sensitive field codes. - // Parses the sequence properly so that \\ (literal backslash) does not cause false positives. + // Parses properly so \\ (literal backslash) does not cause false positives. function sequenceHasSensitiveCode(seq, sensitiveCodes) { if (!sensitiveCodes || !sensitiveCodes.length) return false var i = 0 @@ -288,7 +488,7 @@ if (seq[i] !== '\\') { i++; continue } var code = seq[i + 1] if (!code) break - if (code === '\\') { i += 2; continue } // literal backslash, not a field code + if (code === '\\') { i += 2; continue } if (code === 'f') { var d = seq[i + 2] var fieldCode = (d && /^[1-9]$/.test(d)) ? 'f' + d : 'f1' @@ -306,29 +506,6 @@ return false } - // 6. Decrypt credentials and deliver fill command to opener. - async function deliverFill(msg) { - try { - if (!isSecure && sequenceHasSensitiveCode(msg.autotype || '', msg.sensitiveCodes || [])) { - sendError('Cannot autofill sensitive fields on a non-HTTPS page') - return - } - var iv = Uint8Array.from(atob(msg.iv), function(c) { return c.charCodeAt(0) }) - var ct = Uint8Array.from(atob(msg.ciphertext), function(c) { return c.charCodeAt(0) }) - var pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, sessionKey, ct) - var fields = JSON.parse(new TextDecoder().decode(pt)) - if (window.opener) { - window.opener.postMessage( - { type: 'fill', title: msg.title, autotype: msg.autotype, fields: fields }, - bookmarkletOrigin || '*' - ) - } - setTimeout(function() { window.close() }, 100) - } catch(_) { - sendError('Failed to decrypt credentials') - } - } - function sendError(message) { $header.style.display = 'none' $records.style.display = 'none' @@ -355,6 +532,7 @@ else s = s.replace(/^www\./, '') return s.replace(/\/+$/, '') } + })() diff --git a/pwa/src/App.svelte b/pwa/src/App.svelte index 7748c60..a2cdfe5 100644 --- a/pwa/src/App.svelte +++ b/pwa/src/App.svelte @@ -1,5 +1,8 @@ diff --git a/pwa/public/relay.html b/pwa/public/relay.html index 3b409e1..5206db0 100644 --- a/pwa/public/relay.html +++ b/pwa/public/relay.html @@ -95,6 +95,7 @@ // ── Mode detection state ────────────────────────────────────────────────── var cprivKey = null // private key from init message; used by cross-profile path + var delegateId = null // delegate UUID; relay server request endpoint var bcPongArrived = false var modeDecided = false var modeTimer = null @@ -158,8 +159,9 @@ saveUrl = msg.saveUrl || currentUrl isSecure = !!msg.isSecure cprivKey = msg.privKey || null + delegateId = msg.delegateId || null - console.log('[portpass relay] init received; url='+currentUrl+' hasPrivKey='+!!cprivKey+' bcPongArrived='+bcPongArrived+' isSecure='+isSecure) + console.log('[portpass relay] init received; url='+currentUrl+' hasPrivKey='+!!cprivKey+' delegateId='+delegateId+' bcPongArrived='+bcPongArrived+' isSecure='+isSecure) // Validate URL hostname against browser-reported origin. if (currentUrl) { @@ -213,21 +215,20 @@ var sigB64 = btoa(String.fromCharCode.apply(null, new Uint8Array(sigBytes))) console.log('[portpass relay] pubSpkiB64 length='+pubSpkiB64.length+' sigB64 length='+sigB64.length) - var params = new URLSearchParams({ - url: currentUrl, sig: sigB64, pub: pubSpkiB64, - ecdh: cpEcdhSpkiB64, nonce: nonce, ts: String(ts) + if (!delegateId) throw new Error('No delegate ID — reinstall the bookmarklet') + + // POST signed request to relay server under the delegate's ID slot. + // Portpass polls this slot on window focus and processes any pending request. + console.log('[portpass relay] POSTing request to /drop/'+delegateId) + var dropResp = await fetch('http://127.0.0.1:7677/drop/' + delegateId, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: currentUrl, nonce: nonce, ecdh: cpEcdhSpkiB64, ts: ts, sig: sigB64, pub: pubSpkiB64 }), }) - var intentUrl = 'web+portpass://autofill?' + params.toString() - console.log('[portpass relay] firing intent URL (first 120 chars):', intentUrl.slice(0, 120)) - var a = document.createElement('a') - a.href = intentUrl - a.style.display = 'none' - document.body.appendChild(a) - a.click() - a.remove() - - $status.textContent = 'Waiting for Portpass…' - console.log('[portpass relay] polling relay server for nonce', nonce) + if (!dropResp.ok) throw new Error('Relay server error ' + dropResp.status) + + $status.textContent = 'Switch to Portpass to approve…' + console.log('[portpass relay] waiting for credentials at nonce', nonce) var blob = await pollRelayServer(nonce, 60000) console.log('[portpass relay] blob received, keys:', Object.keys(blob)) await processRelayBlob(blob) @@ -262,20 +263,30 @@ // Or error format: { error: "message" } // Plaintext: JSON array of records, each with pre-decrypted fields. async function processRelayBlob(blob) { - if (blob.error) { sendError(blob.error); return } - 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( - { name: 'ECDH', public: ephPubKey }, cpEcdhKeyPair.privateKey, - { name: 'AES-GCM', length: 256 }, false, ['decrypt'] - ) - 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) - var records = JSON.parse(new TextDecoder().decode(pt)) - showCrossProfilePicker(records) + if (blob.error) { console.log('[portpass relay] blob error:', blob.error); sendError(blob.error); return } + console.log('[portpass relay] processRelayBlob: keys='+Object.keys(blob)+' cpEcdhKeyPair='+!!cpEcdhKeyPair) + try { + var ephPubJwk = JSON.parse(atob(blob.ephPub)) + console.log('[portpass relay] importing ephemeral pubkey') + var ephPubKey = await crypto.subtle.importKey( + 'jwk', ephPubJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, [] + ) + console.log('[portpass relay] deriving session key') + var cpSessionKey = await crypto.subtle.deriveKey( + { name: 'ECDH', public: ephPubKey }, cpEcdhKeyPair.privateKey, + { name: 'AES-GCM', length: 256 }, false, ['decrypt'] + ) + 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) }) + console.log('[portpass relay] decrypting: iv.length='+iv.length+' ct.length='+ct.length) + var pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, cpSessionKey, ct) + var records = JSON.parse(new TextDecoder().decode(pt)) + console.log('[portpass relay] decrypted '+records.length+' records; opener='+!!window.opener) + showCrossProfilePicker(records) + } catch(e) { + console.log('[portpass relay] processRelayBlob error:', e.message, e) + sendError('Decrypt failed: ' + e.message) + } } // Cross-profile picker: fields are already decrypted; clicking a row sends fill immediately. diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index 6afb3d2..3b7385b 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -11,6 +11,7 @@ } from '../wasm.js' import { addSecondaryCredential, removeSecondaryCredential } from './secondaryVaults.js' import { isBiometricEnrolledForFile, unlockWithBiometric } from './biometric.js' + import { getDelegates, verifyAndUpdate } from './delegates.js' import Icon from './Icon.svelte' import RecordList from './RecordList.svelte' import RecordRead from './RecordRead.svelte' @@ -346,6 +347,44 @@ processAutofillIntent(intent) }) + // On window focus, check portpass-relay for any pending cross-profile autofill requests. + // relay.html POSTs the signed request to /drop/{delegateId}; we pick it up here. + $effect(() => { + if (isPopup) return + function onFocus() { checkPendingAutofillRequests() } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }) + + async function checkPendingAutofillRequests() { + const delegates = await getDelegates(dbKey) + if (!delegates.length) return + console.log('[portpass] checking relay server for pending requests; delegates='+delegates.length) + for (const delegate of delegates) { + try { + const resp = await fetch('http://127.0.0.1:7677/pick/' + delegate.id) + if (!resp.ok) continue // 404 = nothing pending + const req = await resp.json() + console.log('[portpass] got pending request for delegate "'+delegate.name+'"; nonce='+req.nonce) + + const age = Date.now() - req.ts + if (age > 60000 || age < -5000) { console.log('[portpass] request expired, age='+age+'ms'); continue } + + const spkiBytes = Uint8Array.from(atob(req.pub), c => c.charCodeAt(0)) + const sigBytes = Uint8Array.from(atob(req.sig), c => c.charCodeAt(0)) + const message = new TextEncoder().encode(JSON.stringify({ url: req.url, nonce: req.nonce, ecdh: req.ecdh, ts: req.ts })) + const verified = await verifyAndUpdate(dbKey, spkiBytes, message, sigBytes) + console.log('[portpass] signature verified='+!!verified+' for delegate "'+delegate.name+'"') + if (!verified) continue + + await processAutofillIntent({ url: req.url, nonce: req.nonce, ecdhSpkiB64: req.ecdh }) + } catch (e) { + if (!(e instanceof TypeError)) console.log('[portpass] checkPending error:', e.message) + // TypeError = relay server not running — silent + } + } + } + // Autofill postMessage handler — ECDH key exchange then encrypted query response. function autofillValidateSequence(seq) { if (!seq) return '' diff --git a/pwa/src/lib/VaultSheet.svelte b/pwa/src/lib/VaultSheet.svelte index aba1746..3be2c68 100644 --- a/pwa/src/lib/VaultSheet.svelte +++ b/pwa/src/lib/VaultSheet.svelte @@ -190,7 +190,7 @@ const delegate = await addDelegate(_vaultUuid, newDelegateName.trim(), pubKeySpki) delegates = [delegate, ...delegates] newDelegateUrl = makeDelegateBookmarkletUrl( - window.location.origin + import.meta.env.BASE_URL, privKeyJwk + window.location.origin + import.meta.env.BASE_URL, privKeyJwk, delegate.id ) newDelegateStep = 'install' } catch (e) { diff --git a/pwa/src/lib/bookmarklet.js b/pwa/src/lib/bookmarklet.js index 08d7b3b..f5a5637 100644 --- a/pwa/src/lib/bookmarklet.js +++ b/pwa/src/lib/bookmarklet.js @@ -2,17 +2,17 @@ // makeDelegateBookmarkletUrl(portpassUrl, privKeyJwk) returns the javascript: URL // for a named delegate. privKeyJwk is a Web Crypto JWK export of the ECDSA P-256 private key. -export function makeDelegateBookmarkletUrl(portpassUrl, privKeyJwk) { +export function makeDelegateBookmarkletUrl(portpassUrl, privKeyJwk, delegateId) { const origin = new URL(portpassUrl).origin return 'javascript:' + encodeURIComponent( - `(${DELEGATE_BOOKMARKLET_IIFE.toString()})(${JSON.stringify(portpassUrl)},${JSON.stringify(origin)},${JSON.stringify(privKeyJwk)})` + `(${DELEGATE_BOOKMARKLET_IIFE.toString()})(${JSON.stringify(portpassUrl)},${JSON.stringify(origin)},${JSON.stringify(privKeyJwk)},${JSON.stringify(delegateId)})` ) } // Self-contained IIFE embedded in the javascript: URL. // PORTPASS_URL and PORTPASS_ORIGIN are baked in at install time via JSON.stringify. -// PRIV_KEY_JWK is the ECDSA P-256 private key; passed to relay.html which signs the request. -function DELEGATE_BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN, PRIV_KEY_JWK) { +// PRIV_KEY_JWK is the ECDSA P-256 private key; DELEGATE_ID identifies the delegate on the relay server. +function DELEGATE_BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN, PRIV_KEY_JWK, DELEGATE_ID) { 'use strict' if (window.__ppRunning) return @@ -41,14 +41,15 @@ function DELEGATE_BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN, PRIV_KEY_JWK) } if (readyMsg.type === 'error') { showError(readyMsg.message); return } - // Send URL + private signing key to relay.html with strict targetOrigin. - // relay.html signs the request, fires web+portpass://, and polls the relay server. + // Send URL, private signing key, and delegate ID to relay.html with strict targetOrigin. + // relay.html signs and POSTs the request to portpass-relay, then polls for the response. pp.postMessage({ type: 'init', url: currentCanonical, saveUrl: saveUrl, isSecure: isSecure, privKey: PRIV_KEY_JWK, + delegateId: DELEGATE_ID, }, PORTPASS_ORIGIN) // Wait for fill command (relay decrypted and forwarded credentials) or error. From d43e228a811c1079033ca9b58552041f2cad94f6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 May 2026 23:28:27 +0200 Subject: [PATCH 15/99] Autofill: fix cross-profile bad blob with retry; CSP; extended debug timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit relay.html: relay.c makes a single recv() call and memcpy's from that buffer using Content-Length — when the POST body hasn't fully arrived, the stored blob is zeroed/garbage. Fix: pollRelayServer detects bad blobs (empty, unparseable, or missing ephPub/error keys) and calls an onBadBlob callback; startCrossProfile passes a callback that re-posts the signed request so Portpass re-processes and re-drops the correct credentials. Retry adds ~2s on the bad-blob path, which occurs intermittently on loopback. index.html: add http://127.0.0.1:7677 to connect-src CSP directive so Dashboard can fetch the relay server. relay.html: error close extended to 30s, fill close to 10s for debugging; detailed processRelayBlob and pollRelayServer logging retained. Co-Authored-By: Claude Sonnet 4.6 --- pwa/public/relay.html | 57 +++++++++++++++++++++++++----------- pwa/src/lib/Dashboard.svelte | 26 +++++++++------- 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/pwa/public/relay.html b/pwa/public/relay.html index 5206db0..786064c 100644 --- a/pwa/public/relay.html +++ b/pwa/public/relay.html @@ -218,18 +218,28 @@ if (!delegateId) throw new Error('No delegate ID — reinstall the bookmarklet') // POST signed request to relay server under the delegate's ID slot. - // Portpass polls this slot on window focus and processes any pending request. - console.log('[portpass relay] POSTing request to /drop/'+delegateId) - var dropResp = await fetch('http://127.0.0.1:7677/drop/' + delegateId, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: currentUrl, nonce: nonce, ecdh: cpEcdhSpkiB64, ts: ts, sig: sigB64, pub: pubSpkiB64 }), - }) - if (!dropResp.ok) throw new Error('Relay server error ' + dropResp.status) - - $status.textContent = 'Switch to Portpass to approve…' + // Portpass polls this slot on interval and processes any pending request. + var requestBody = JSON.stringify({ url: currentUrl, nonce: nonce, ecdh: cpEcdhSpkiB64, ts: ts, sig: sigB64, pub: pubSpkiB64 }) + async function postRequest() { + console.log('[portpass relay] POSTing request to /drop/'+delegateId) + var r = await fetch('http://127.0.0.1:7677/drop/' + delegateId, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: requestBody, + }) + if (!r.ok) throw new Error('Relay server error ' + r.status) + } + await postRequest() + + // On bad blob (empty/unparseable), re-post the request so Portpass re-processes. + async function onBadBlob() { + console.log('[portpass relay] bad blob — re-posting request') + await postRequest().catch(function() {}) + } + + $status.textContent = 'Waiting for Portpass…' console.log('[portpass relay] waiting for credentials at nonce', nonce) - var blob = await pollRelayServer(nonce, 60000) + var blob = await pollRelayServer(nonce, 60000, onBadBlob) console.log('[portpass relay] blob received, keys:', Object.keys(blob)) await processRelayBlob(blob) @@ -240,15 +250,28 @@ } // Polls GET /pick/{nonce} every 500ms until a blob is available or timeout. - // Throws immediately if relay server is not reachable (TypeError = connection refused). - async function pollRelayServer(pollNonce, timeout) { + // onBadBlob (optional) is called when a 200 response arrives but the body is + // empty, whitespace, or unparseable — caller can re-post the request to recover. + async function pollRelayServer(pollNonce, timeout, onBadBlob) { var deadline = Date.now() + timeout var url = 'http://127.0.0.1:7677/pick/' + pollNonce while (Date.now() < deadline) { try { var resp = await fetch(url) - if (resp.ok) return resp.json() - if (resp.status !== 404) throw new Error('Relay server error ' + resp.status) + if (resp.ok) { + var text = await resp.text() + var parsed = null + if (text.trim()) { + try { parsed = JSON.parse(text) } catch(_) {} + } + if (parsed && (parsed.ephPub || parsed.error)) { + console.log('[portpass relay] /pick response received') + return parsed + } + // Bad blob — log and trigger re-post + console.log('[portpass relay] bad blob (first 40):', JSON.stringify(text.slice(0, 40))) + if (onBadBlob) await onBadBlob() + } else if (resp.status !== 404) throw new Error('Relay server error ' + resp.status) } catch(e) { if (e instanceof TypeError) throw new Error('portpass-relay is not running — start it first') throw e @@ -337,7 +360,7 @@ bookmarkletOrigin || '*' ) } - setTimeout(function() { window.close() }, 100) + setTimeout(function() { window.close() }, 10000) } })(rec) @@ -529,7 +552,7 @@ errEl.textContent = message document.getElementById('app').appendChild(errEl) if (window.opener) window.opener.postMessage({ type: 'error', message: message }, '*') - setTimeout(function() { window.close() }, 3000) + setTimeout(function() { window.close() }, 30000) } function canonicalURL(href) { diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index 3b7385b..4911a99 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -325,14 +325,15 @@ ) const ephPubJwk = await crypto.subtle.exportKey('jwk', ephPair.publicKey) - console.log('[portpass] posting credential blob for', recordsWithFields.length, 'records') + const bodyStr = JSON.stringify({ + ephPub: btoa(JSON.stringify(ephPubJwk)), + iv: btoa(String.fromCharCode(...iv)), + ciphertext: btoa(String.fromCharCode(...new Uint8Array(ct))), + }) + console.log('[portpass] posting credential blob for', recordsWithFields.length, 'records; DROP_URL='+DROP_URL+' bodyLength='+bodyStr.length) const postResp = await fetch(DROP_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ephPub: btoa(JSON.stringify(ephPubJwk)), - iv: btoa(String.fromCharCode(...iv)), - ciphertext: btoa(String.fromCharCode(...new Uint8Array(ct))), - }), + body: bodyStr, }) console.log('[portpass] drop response status:', postResp.status) } catch (e) { @@ -347,16 +348,20 @@ processAutofillIntent(intent) }) - // On window focus, check portpass-relay for any pending cross-profile autofill requests. + // Poll portpass-relay for pending cross-profile autofill requests while vault is unlocked. // relay.html POSTs the signed request to /drop/{delegateId}; we pick it up here. $effect(() => { if (isPopup) return - function onFocus() { checkPendingAutofillRequests() } - window.addEventListener('focus', onFocus) - return () => window.removeEventListener('focus', onFocus) + const id = setInterval(checkPendingAutofillRequests, 2000) + return () => clearInterval(id) }) + let _checkInProgress = false + async function checkPendingAutofillRequests() { + if (_checkInProgress) return + _checkInProgress = true + try { const delegates = await getDelegates(dbKey) if (!delegates.length) return console.log('[portpass] checking relay server for pending requests; delegates='+delegates.length) @@ -383,6 +388,7 @@ // TypeError = relay server not running — silent } } + } finally { _checkInProgress = false } } // Autofill postMessage handler — ECDH key exchange then encrypted query response. From 0c597fb3d5e9c7ac59b8a83edc98e9692e584990 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 20 May 2026 23:47:48 +0200 Subject: [PATCH 16/99] Autofill: ECDSA authentication on same-profile BC path relay.html doHello() now signs {relayNonce, ecdhSpki} with the ECDSA private key (already in scope from the bookmarklet URL) and includes sig/pub/ecdhSpki in the relay-hello BC message. Dashboard relay-hello handler verifies the signature via verifyAndUpdate before completing the ECDH exchange. Rejects unsigned or unverified hellos with relay-error. This closes the masquerade attack on the same-profile path: a content script at the Portpass origin can observe the BC channel but cannot forge the signature. Side effect: useCount/lastUsed now increment for same-profile requests. Co-Authored-By: Claude Sonnet 4.6 --- pwa/public/relay.html | 28 +++++++++++++++++++++++++++- pwa/src/lib/Dashboard.svelte | 15 +++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/pwa/public/relay.html b/pwa/public/relay.html index 786064c..8804f0c 100644 --- a/pwa/public/relay.html +++ b/pwa/public/relay.html @@ -376,15 +376,41 @@ { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveKey'] ) var pubJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + var ecdhSpki = await crypto.subtle.exportKey('spki', keyPair.publicKey) + var ecdhSpkiB64 = btoa(String.fromCharCode.apply(null, new Uint8Array(ecdhSpki))) relayNonce = crypto.randomUUID() + // Sign {relayNonce, ecdhSpki} with the ECDSA private key so Dashboard can + // verify this relay-hello came from a registered delegate bookmarklet. + var sigB64 = null, pubSpkiB64 = null + if (cprivKey) { + 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 sigBytes = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, signingKey, + new TextEncoder().encode(JSON.stringify({ relayNonce: relayNonce, ecdhSpki: ecdhSpkiB64 })) + ) + sigB64 = btoa(String.fromCharCode.apply(null, new Uint8Array(sigBytes))) + } catch(_) { /* signing unavailable */ } + } + ch.onmessage = function(e) { var msg = e.data if (!msg || msg.nonce !== relayNonce) return if (msg.type === 'relay-hello-response') onHelloResponse(msg) else if (msg.type === 'relay-error') sendError(msg.message) } - ch.postMessage({ type: 'relay-hello', pubkey: pubJwk, nonce: relayNonce }) + var helloMsg = { type: 'relay-hello', pubkey: pubJwk, ecdhSpki: ecdhSpkiB64, nonce: relayNonce } + if (sigB64 && pubSpkiB64) { helloMsg.sig = sigB64; helloMsg.pub = pubSpkiB64 } + ch.postMessage(helloMsg) } catch(_) { sendError('Key exchange failed') } diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index 4911a99..baa1556 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -532,6 +532,21 @@ if (relayHelloInProgress) return relayHelloInProgress = true try { + // Verify ECDSA signature to authenticate the delegate bookmarklet. + // Closes the masquerade attack: a content script cannot forge this. + if (!msg.sig || !msg.pub || !msg.ecdhSpki) { + ch.postMessage({ type: 'relay-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({ relayNonce: msg.nonce, ecdhSpki: msg.ecdhSpki })) + const verified = await verifyAndUpdate(dbKey, spkiBytes, sigMsg, sigBytes) + console.log('[portpass] relay-hello verified='+!!verified) + if (!verified) { + ch.postMessage({ type: 'relay-error', message: 'Autofill request not authorized', nonce: msg.nonce }) + return + } const openerPub = await crypto.subtle.importKey( 'jwk', msg.pubkey, { name: 'ECDH', namedCurve: 'P-256' }, false, [] ) From bac00cf6583efc6959813c9cbbea3fa1dd8bc557 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 May 2026 00:02:06 +0200 Subject: [PATCH 17/99] Tests: update bookmarklet and autofill_popup specs for delegate autofill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bookmarklet.spec.ts: - Replace makeBookmarkletUrl with makeDelegateBookmarkletUrl - Add createDelegateBookmarklet() helper that opens VaultSheet, creates a delegate via the UI, and captures the bookmarklet href — no production code exposure needed; also exercises the delegate creation flow itself - setupAutofillTest() now creates a delegate and returns bookmarkletUrl - activateBookmarklet() takes bookmarkletUrl as a parameter - All 9 tests updated to pass the bookmarklet URL - Timeout bumped to 30s to accommodate delegate creation UI steps autofill_popup.spec.ts: - Remove the 4 "Autofill relay mode" tests: they drove App.svelte's tryRelay() bridge (Portpass as popup), which is no longer the production autofill path and is now broken because Dashboard requires ECDSA on relay-hello. The same-profile relay flow is covered by bookmarklet.spec.ts. - Query protocol and UI tests (9 tests) unchanged — they test direct postMessage to Portpass popup, which is unaffected. Co-Authored-By: Claude Sonnet 4.6 --- pwa/tests/autofill_popup.spec.ts | 84 ++---------------------------- pwa/tests/bookmarklet.spec.ts | 87 +++++++++++++++++++------------- 2 files changed, 55 insertions(+), 116 deletions(-) diff --git a/pwa/tests/autofill_popup.spec.ts b/pwa/tests/autofill_popup.spec.ts index 8ce533d..5210117 100644 --- a/pwa/tests/autofill_popup.spec.ts +++ b/pwa/tests/autofill_popup.spec.ts @@ -320,83 +320,7 @@ test.describe('Autofill popup mode — UI', () => { }) -// --------------------------------------------------------------------------- -// Relay mode: popup bridges between bookmarklet and an already-open main tab -// --------------------------------------------------------------------------- - -test.describe('Autofill relay mode', () => { - - test.beforeEach(async ({ context }) => { - await context.addInitScript(() => { - if ((window as any).PublicKeyCredential) { - (window.PublicKeyCredential as any).isUserVerifyingPlatformAuthenticatorAvailable = async () => false - } - ;(window as any).showSaveFilePicker = async () => ({ - name: 'new.psafe3', - createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }), - }) - }) - }) - - // Opens a main Portpass tab (non-popup), unlocks a new vault, and returns the page. - async function openMainTab(context: BrowserContext): Promise { - const main = await context.newPage() - await main.goto(PORTPASS_URL) - await main.getByRole('button', { name: 'Create one' }).click() - await main.getByPlaceholder('Master password').fill('testpassword') - await main.getByRole('button', { name: 'Create vault' }).click() - await expect(main.getByPlaceholder('Search vault')).toBeVisible({ timeout: 10000 }) - return main - } - - test('relay popup does not show unlock UI when main tab is unlocked', async ({ context }) => { - await openMainTab(context) - const { popup } = await openPortpassPopup(context) - // 300 ms relay ping + buffer; main tab responds instantly so relay succeeds quickly - await popup.waitForTimeout(500) - await expect(popup.getByRole('button', { name: 'Open vault file' })).toHaveCount(0) - await expect(popup.getByRole('button', { name: 'Create one' })).toHaveCount(0) - }) - - test('relay delivers correct credentials from the main tab', async ({ context }) => { - const main = await openMainTab(context) - await main.getByRole('button', { name: 'New', exact: true }).click() - await main.getByPlaceholder('e.g. Bank of America').fill('Relay Test') - await main.locator('input.mono').first().fill('hunter2') - await main.locator('input.input').nth(2).fill('alice') - await main.locator('.autotype-input').fill('\\u\\t\\p\\n') - await main.getByRole('button', { name: 'Save' }).click() - await main.locator('.record-row', { hasText: 'Relay Test' }).click() - - const { opener, popup } = await openPortpassPopup(context) - await popup.waitForTimeout(500) // Wait for relay mode to activate - - await doKeyExchange(opener) - const response = await sendQuery(opener) - - expect(response.type).toBe('record') - expect(response.title).toBe('Relay Test') - expect(response.autotype).toBe('\\u\\t\\p\\n') - expect(response.fields.u).toBe('alice') - expect(response.fields.p).toBe('hunter2') - }) - - test('relay returns error when main tab has no record selected', async ({ context }) => { - await openMainTab(context) // Unlocked, no record selected - const { opener, popup } = await openPortpassPopup(context) - await popup.waitForTimeout(500) - - await doKeyExchange(opener) - const response = await sendQuery(opener) - - expect(response.type).toBe('error') - expect(response.message).toContain('Open a record') - }) - - test('relay popup falls back to unlock UI when no main tab is open', async ({ context }) => { - const { popup } = await openPortpassPopup(context) - // Relay ping times out (300 ms), then WASM loads and unlock UI appears - await popup.waitForSelector('button:text("Create one")', { timeout: 10000 }) - }) - -}) +// NOTE: The relay mode tests that tested App.svelte's tryRelay() bridge have been removed. +// That path (Portpass opened as a popup bridging postMessage↔BC) is no longer exercised +// by the bookmarklet — relay.html now handles the same-profile BC relay directly and +// authenticates with ECDSA. The relay.html same-profile flow is covered by bookmarklet.spec.ts. diff --git a/pwa/tests/bookmarklet.spec.ts b/pwa/tests/bookmarklet.spec.ts index 8af99f5..1f2f819 100644 --- a/pwa/tests/bookmarklet.spec.ts +++ b/pwa/tests/bookmarklet.spec.ts @@ -1,5 +1,5 @@ import { test, expect, BrowserContext, Page } from '@playwright/test' -import { makeBookmarkletUrl } from '../src/lib/bookmarklet.js' +import { makeDelegateBookmarkletUrl } from '../src/lib/bookmarklet.js' const PORTPASS_URL = 'http://localhost:5173/portpass/' const PORTPASS_ORIGIN = 'http://localhost:5173' @@ -15,9 +15,30 @@ const LOGIN_FORM_HTML = ` ` -// Opens a main (non-popup) Portpass tab with an unlocked vault, then a separate login -// page. The bookmarklet will open a relay popup that bridges to the main Portpass tab. -async function setupAutofillTest(context: BrowserContext): Promise<{ login: Page, portpass: Page }> { +// Creates a delegate via the VaultSheet UI and returns its bookmarklet URL. +// The private key is embedded in the returned javascript: URL by the app. +async function createDelegateBookmarklet(portpass: Page): Promise { + await portpass.locator('.vault-pill').click() + await expect(portpass.locator('.vault-settings-body')).toBeVisible() + + await portpass.getByRole('button', { name: '+ New bookmarklet' }).click() + await portpass.getByPlaceholder('e.g. Chrome — work profile').fill('test') + await portpass.getByRole('button', { name: 'Create' }).click() + + await portpass.locator('.vs-bookmarklet-chip').waitFor({ timeout: 5000 }) + const url = await portpass.locator('.vs-bookmarklet-chip').getAttribute('href') ?? '' + + await portpass.getByRole('button', { name: 'Done' }).click() + await portpass.keyboard.press('Escape') + await expect(portpass.locator('.vault-settings-body')).not.toBeVisible({ timeout: 3000 }) + + return url +} + +// Opens a main (non-popup) Portpass tab with an unlocked vault, registers a delegate +// bookmarklet, then opens a separate login page. Returns the login page, the Portpass +// page, and the bookmarklet URL to use when activating autofill. +async function setupAutofillTest(context: BrowserContext): Promise<{ login: Page, portpass: Page, bookmarkletUrl: string }> { await context.addInitScript(() => { if ((window as any).PublicKeyCredential) { (window.PublicKeyCredential as any).isUserVerifyingPlatformAuthenticatorAvailable = async () => false @@ -28,7 +49,6 @@ async function setupAutofillTest(context: BrowserContext): Promise<{ login: Page }) }) - // Main Portpass tab (non-popup) — the relay popup will bridge to this. const portpass = await context.newPage() await portpass.goto(PORTPASS_URL) await portpass.getByRole('button', { name: 'Create one' }).click() @@ -36,14 +56,15 @@ async function setupAutofillTest(context: BrowserContext): Promise<{ login: Page await portpass.getByRole('button', { name: 'Create vault' }).click() await expect(portpass.getByPlaceholder('Search vault')).toBeVisible({ timeout: 10000 }) - // Login form page — the bookmarklet runs here. + const bookmarkletUrl = await createDelegateBookmarklet(portpass) + const login = await context.newPage() await login.route(LOGIN_PATH, route => route.fulfill({ contentType: 'text/html', body: LOGIN_FORM_HTML }) ) await login.goto(LOGIN_URL) - return { login, portpass } + return { login, portpass, bookmarkletUrl } } // Create a record in portpass and open its detail view. @@ -62,14 +83,13 @@ async function createRecord(portpass: Page, opts: { } // Run the bookmarklet on the login page. Opens the relay popup, drives the picker -// (selecting the first record and clicking Autofill), and waits for the popup to close. +// (selecting the first record), and waits for the popup to close. // For error cases the popup closes automatically; the caller then checks login page state. -async function activateBookmarklet(login: Page) { +async function activateBookmarklet(login: Page, bookmarkletUrl: string) { const context = login.context() const popupPromise = context.waitForEvent('page') - const code = makeBookmarkletUrl(PORTPASS_URL) - .replace('javascript:', '') // strip the scheme — evaluate runs the code directly + const code = bookmarkletUrl.replace('javascript:', '') await login.evaluate(new Function(decodeURIComponent(code)) as any) const relay = await popupPromise @@ -85,7 +105,7 @@ async function activateBookmarklet(login: Page) { // Click the first record row — single click triggers autofill immediately. await relay.locator('.rec-row').first().click() // Wait for relay to close after delivering the fill command. - await relay.waitForEvent('close', { timeout: 8000 }).catch(() => {}) + await relay.waitForEvent('close', { timeout: 12000 }).catch(() => {}) } // Give the browser one animation frame so any fill overlay or error overlay on the @@ -93,26 +113,26 @@ async function activateBookmarklet(login: Page) { await login.evaluate(() => new Promise(r => requestAnimationFrame(r))) } -test.setTimeout(25000) +test.setTimeout(30000) test.describe('Bookmarklet — overlay and autofill', () => { test('overlay shows record title and "Click the field to start from"', async ({ context }) => { - const { login, portpass } = await setupAutofillTest(context) + const { login, portpass, bookmarkletUrl } = await setupAutofillTest(context) await createRecord(portpass, { title: 'My Bank', autotype: '\\u\\t\\p\\n' }) - await activateBookmarklet(login) + await activateBookmarklet(login, bookmarkletUrl) await expect(login.locator('#__pp')).toContainText('My Bank') await expect(login.locator('#__pp')).toContainText('Click the field to start from') }) test('\\u\\t\\p fills username, tabs to password, fills password', async ({ context }) => { - const { login, portpass } = await setupAutofillTest(context) + const { login, portpass, bookmarkletUrl } = await setupAutofillTest(context) await createRecord(portpass, { title: 'My Bank', username: 'alice', password: 'hunter2', autotype: '\\u\\t\\p', }) - await activateBookmarklet(login) + await activateBookmarklet(login, bookmarkletUrl) await login.locator('#user').click() // Overlay is removed by onFieldClick — its absence confirms autotype ran. await expect(login.locator('#__pp')).not.toBeVisible({ timeout: 3000 }) @@ -122,19 +142,18 @@ test.describe('Bookmarklet — overlay and autofill', () => { }) test('\\u\\t\\p\\n fills both fields and submits the form', async ({ context }) => { - const { login, portpass } = await setupAutofillTest(context) + const { login, portpass, bookmarkletUrl } = await setupAutofillTest(context) await createRecord(portpass, { title: 'My Bank', username: 'alice', password: 'hunter2', autotype: '\\u\\t\\p\\n', }) - // Detect form submission. let submitted = false await login.exposeFunction('__ppSubmitted', () => { submitted = true }) await login.evaluate(() => { document.getElementById('f')!.addEventListener('submit', () => (window as any).__ppSubmitted()) }) - await activateBookmarklet(login) + await activateBookmarklet(login, bookmarkletUrl) await login.locator('#user').click() await expect(login.locator('#user')).toHaveValue('alice') @@ -143,12 +162,12 @@ test.describe('Bookmarklet — overlay and autofill', () => { }) test('\\u only fills username, does not touch password', async ({ context }) => { - const { login, portpass } = await setupAutofillTest(context) + const { login, portpass, bookmarkletUrl } = await setupAutofillTest(context) await createRecord(portpass, { title: 'Site', username: 'bob', password: 'secret', autotype: '\\u', }) - await activateBookmarklet(login) + await activateBookmarklet(login, bookmarkletUrl) await login.locator('#user').click() await expect(login.locator('#user')).toHaveValue('bob') @@ -156,12 +175,12 @@ test.describe('Bookmarklet — overlay and autofill', () => { }) test('\\p only fills password field (starting from password input)', async ({ context }) => { - const { login, portpass } = await setupAutofillTest(context) + const { login, portpass, bookmarkletUrl } = await setupAutofillTest(context) await createRecord(portpass, { title: 'Site', password: 'mypassword', autotype: '\\p', }) - await activateBookmarklet(login) + await activateBookmarklet(login, bookmarkletUrl) await login.locator('#pass').click() await expect(login.locator('#pass')).toHaveValue('mypassword') @@ -169,7 +188,6 @@ test.describe('Bookmarklet — overlay and autofill', () => { }) test('\\t skips non-input elements (e.g. show-password button) to reach password field', async ({ context }) => { - // Form with a button between username and password — simulates real-world sites. const formWithButton = `
@@ -180,9 +198,8 @@ test.describe('Bookmarklet — overlay and autofill', () => { ` - const { login: _login, portpass } = await setupAutofillTest(context) + const { login: _login, portpass, bookmarkletUrl } = await setupAutofillTest(context) - // Override the login page content with the button-in-the-middle form. const login = _login await login.route('/login-button-test', route => route.fulfill({ contentType: 'text/html', body: formWithButton }) @@ -193,33 +210,32 @@ test.describe('Bookmarklet — overlay and autofill', () => { title: 'Button Site', username: 'alice', password: 'secret', autotype: '\\u\\t\\p', }) - await activateBookmarklet(login) + await activateBookmarklet(login, bookmarkletUrl) await login.locator('#user').click() await expect(login.locator('#user')).toHaveValue('alice') - // The "Show" button was skipped; password field should be filled. await expect(login.locator('#pass')).toHaveValue('secret') }) test('dismiss button removes the overlay', async ({ context }) => { - const { login, portpass } = await setupAutofillTest(context) + const { login, portpass, bookmarkletUrl } = await setupAutofillTest(context) await createRecord(portpass, { title: 'Site', autotype: '\\u\\p' }) - await activateBookmarklet(login) + await activateBookmarklet(login, bookmarkletUrl) await login.locator('#__pp button').click() await expect(login.locator('#__pp')).toHaveCount(0) }) test('error shown when no record is selected in Portpass', async ({ context }) => { - const { login, portpass } = await setupAutofillTest(context) + const { login, bookmarkletUrl } = await setupAutofillTest(context) // Vault is open but no record is selected. - await activateBookmarklet(login) + await activateBookmarklet(login, bookmarkletUrl) await expect(login.locator('#__pp')).toContainText('Open a record') }) test('record without autotype sequence uses the default and shows overlay', async ({ context }) => { - const { login, portpass } = await setupAutofillTest(context) + const { login, portpass, bookmarkletUrl } = await setupAutofillTest(context) await portpass.getByRole('button', { name: 'New', exact: true }).click() await portpass.getByPlaceholder('e.g. Bank of America').fill('No Autotype') await portpass.locator('input.mono').first().fill('pass') @@ -227,8 +243,7 @@ test.describe('Bookmarklet — overlay and autofill', () => { await portpass.getByRole('button', { name: 'Save' }).click() await portpass.locator('.record-row', { hasText: 'No Autotype' }).click() - await activateBookmarklet(login) - // Overlay should show the record name, not an error. + await activateBookmarklet(login, bookmarkletUrl) await expect(login.locator('#__pp')).toContainText('No Autotype') await expect(login.locator('#__pp')).toContainText('Click the field to start from') }) From 540336749fe5b170c60cdda22122a7f31369d35f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 May 2026 00:05:18 +0200 Subject: [PATCH 18/99] Tests: fix error-case race in activateBookmarklet Add login page's #__pp to the Promise.race so the test proceeds as soon as the bookmarklet renders the error overlay (~2s), rather than waiting for the popup to close. With the extended debug close timeout (30s), the popup-close path never wins within 10s, causing the overlay to be auto-dismissed before the assertion ran. Co-Authored-By: Claude Sonnet 4.6 --- pwa/tests/bookmarklet.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pwa/tests/bookmarklet.spec.ts b/pwa/tests/bookmarklet.spec.ts index 1f2f819..2cbbc69 100644 --- a/pwa/tests/bookmarklet.spec.ts +++ b/pwa/tests/bookmarklet.spec.ts @@ -95,10 +95,13 @@ async function activateBookmarklet(login: Page, bookmarkletUrl: string) { const relay = await popupPromise await relay.waitForLoadState('domcontentloaded') - // Wait for either the picker (record rows) or the popup closing (error case). + // Wait for: picker rows, popup close, or error overlay on login page. + // The error overlay check covers the case where the popup stays open (debug close + // timeout) but the error message has already been forwarded to the login page. const which = await Promise.race([ relay.locator('.rec-row').first().waitFor({ timeout: 10000 }).then(() => 'picker').catch(() => 'timeout'), relay.waitForEvent('close', { timeout: 10000 }).then(() => 'closed').catch(() => 'timeout'), + login.locator('#__pp').waitFor({ timeout: 10000 }).then(() => 'error').catch(() => 'timeout'), ]) if (which === 'picker') { From 3aec39a240679416f2d81f54f00a046f774aaa5a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 May 2026 00:19:34 +0200 Subject: [PATCH 19/99] Autofill: use localhost:7677, fix relay-not-running error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use localhost:7677 instead of 127.0.0.1:7677 for portpass-relay URLs in relay.html, Dashboard.svelte, and index.html CSP — localhost has a stronger loopback exemption in Firefox's mixed-content rules; relay.c binds to INADDR_LOOPBACK which handles both - relay.html postRequest(): catch TypeError and surface the clear "portpass-relay is not running — start it first" message instead of the raw "NetworkError when attempting to fetch resource." that was previously shown when the relay server was not running Co-Authored-By: Claude Sonnet 4.6 --- pwa/index.html | 2 +- pwa/public/relay.html | 19 ++++++++++++------- pwa/src/lib/Dashboard.svelte | 4 ++-- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/pwa/index.html b/pwa/index.html index cf36749..68921d6 100644 --- a/pwa/index.html +++ b/pwa/index.html @@ -7,7 +7,7 @@ - + Portpass diff --git a/pwa/public/relay.html b/pwa/public/relay.html index 8804f0c..132e7ff 100644 --- a/pwa/public/relay.html +++ b/pwa/public/relay.html @@ -222,12 +222,17 @@ var requestBody = JSON.stringify({ url: currentUrl, nonce: nonce, ecdh: cpEcdhSpkiB64, ts: ts, sig: sigB64, pub: pubSpkiB64 }) async function postRequest() { console.log('[portpass relay] POSTing request to /drop/'+delegateId) - var r = await fetch('http://127.0.0.1:7677/drop/' + delegateId, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: requestBody, - }) - if (!r.ok) throw new Error('Relay server error ' + r.status) + try { + var r = await fetch('http://localhost:7677/drop/' + delegateId, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: requestBody, + }) + if (!r.ok) throw new Error('Relay server error ' + r.status) + } catch(e) { + if (e instanceof TypeError) throw new Error('portpass-relay is not running — start it first') + throw e + } } await postRequest() @@ -254,7 +259,7 @@ // empty, whitespace, or unparseable — caller can re-post the request to recover. async function pollRelayServer(pollNonce, timeout, onBadBlob) { var deadline = Date.now() + timeout - var url = 'http://127.0.0.1:7677/pick/' + pollNonce + var url = 'http://localhost:7677/pick/' + pollNonce while (Date.now() < deadline) { try { var resp = await fetch(url) diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index baa1556..bb2c4fc 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -279,7 +279,7 @@ async function processAutofillIntent({ url, nonce, ecdhSpkiB64 }) { console.log('[portpass] processAutofillIntent url='+url+' nonce='+nonce) - const DROP_URL = `http://127.0.0.1:7677/drop/${nonce}` + const DROP_URL = `http://localhost:7677/drop/${nonce}` const postError = msg => { console.log('[portpass] posting error blob:', msg) return fetch(DROP_URL, { @@ -367,7 +367,7 @@ console.log('[portpass] checking relay server for pending requests; delegates='+delegates.length) for (const delegate of delegates) { try { - const resp = await fetch('http://127.0.0.1:7677/pick/' + delegate.id) + const resp = await fetch('http://localhost:7677/pick/' + delegate.id) if (!resp.ok) continue // 404 = nothing pending const req = await resp.json() console.log('[portpass] got pending request for delegate "'+delegate.name+'"; nonce='+req.nonce) From de0c2e6fa612c35b12b1dbd31eda3e54c8c00f42 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 May 2026 00:23:45 +0200 Subject: [PATCH 20/99] Autofill: remove debug logging, revert debug close timeouts Remove all [portpass] and [portpass relay] console.log statements added during cross-platform debugging. Revert relay.html close delays: fill popup back to 100ms, sendError back to 3000ms. Fix syntax error in delegates.js verifyAndUpdate introduced by log removal (missing try/catch closing braces). Co-Authored-By: Claude Sonnet 4.6 --- pwa/public/relay.html | 26 ++------------------------ pwa/src/App.svelte | 11 ----------- pwa/src/lib/Dashboard.svelte | 13 ------------- pwa/src/lib/delegates.js | 6 +----- 4 files changed, 3 insertions(+), 53 deletions(-) diff --git a/pwa/public/relay.html b/pwa/public/relay.html index 132e7ff..e44aafb 100644 --- a/pwa/public/relay.html +++ b/pwa/public/relay.html @@ -136,12 +136,10 @@ // Mode is finalised in onOpenerMessage once init arrives. ch.onmessage = function(e) { if (e.data && e.data.type === 'relay-pong' && e.data.nonce === nonce) { - console.log('[portpass relay] BC pong received; modeDecided='+modeDecided+' cprivKey='+!!cprivKey) bcPongArrived = true if (!modeDecided && cprivKey !== null) { modeDecided = true clearTimeout(modeTimer) - console.log('[portpass relay] mode: same-profile (BC)') doHello() } } @@ -161,8 +159,6 @@ cprivKey = msg.privKey || null delegateId = msg.delegateId || null - console.log('[portpass relay] init received; url='+currentUrl+' hasPrivKey='+!!cprivKey+' delegateId='+delegateId+' bcPongArrived='+bcPongArrived+' isSecure='+isSecure) - // Validate URL hostname against browser-reported origin. if (currentUrl) { try { @@ -174,14 +170,12 @@ if (bcPongArrived) { modeDecided = true - console.log('[portpass relay] mode: same-profile (BC pong already here)') doHello() } else { modeTimer = setTimeout(function() { if (!modeDecided) { modeDecided = true if (cprivKey) { - console.log('[portpass relay] mode: cross-profile (no BC pong after 200ms)') startCrossProfile(cprivKey) } else { sendError('Portpass is not open — open and unlock Portpass first') @@ -208,12 +202,10 @@ var ts = Date.now() var signedPayload = JSON.stringify({ url: currentUrl, nonce: nonce, ecdh: cpEcdhSpkiB64, ts: ts }) - console.log('[portpass relay] signing payload:', signedPayload) var sigBytes = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, signingKey, new TextEncoder().encode(signedPayload) ) var sigB64 = btoa(String.fromCharCode.apply(null, new Uint8Array(sigBytes))) - console.log('[portpass relay] pubSpkiB64 length='+pubSpkiB64.length+' sigB64 length='+sigB64.length) if (!delegateId) throw new Error('No delegate ID — reinstall the bookmarklet') @@ -221,7 +213,6 @@ // Portpass polls this slot on interval and processes any pending request. var requestBody = JSON.stringify({ url: currentUrl, nonce: nonce, ecdh: cpEcdhSpkiB64, ts: ts, sig: sigB64, pub: pubSpkiB64 }) async function postRequest() { - console.log('[portpass relay] POSTing request to /drop/'+delegateId) try { var r = await fetch('http://localhost:7677/drop/' + delegateId, { method: 'POST', @@ -238,18 +229,14 @@ // On bad blob (empty/unparseable), re-post the request so Portpass re-processes. async function onBadBlob() { - console.log('[portpass relay] bad blob — re-posting request') await postRequest().catch(function() {}) } $status.textContent = 'Waiting for Portpass…' - console.log('[portpass relay] waiting for credentials at nonce', nonce) var blob = await pollRelayServer(nonce, 60000, onBadBlob) - console.log('[portpass relay] blob received, keys:', Object.keys(blob)) await processRelayBlob(blob) } catch(e) { - console.log('[portpass relay] cross-profile error:', e.message) sendError(e.message || 'Autofill failed') } } @@ -270,11 +257,9 @@ try { parsed = JSON.parse(text) } catch(_) {} } if (parsed && (parsed.ephPub || parsed.error)) { - console.log('[portpass relay] /pick response received') return parsed } // Bad blob — log and trigger re-post - console.log('[portpass relay] bad blob (first 40):', JSON.stringify(text.slice(0, 40))) if (onBadBlob) await onBadBlob() } else if (resp.status !== 404) throw new Error('Relay server error ' + resp.status) } catch(e) { @@ -291,28 +276,21 @@ // Or error format: { error: "message" } // Plaintext: JSON array of records, each with pre-decrypted fields. async function processRelayBlob(blob) { - if (blob.error) { console.log('[portpass relay] blob error:', blob.error); sendError(blob.error); return } - console.log('[portpass relay] processRelayBlob: keys='+Object.keys(blob)+' cpEcdhKeyPair='+!!cpEcdhKeyPair) try { var ephPubJwk = JSON.parse(atob(blob.ephPub)) - console.log('[portpass relay] importing ephemeral pubkey') var ephPubKey = await crypto.subtle.importKey( 'jwk', ephPubJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, [] ) - console.log('[portpass relay] deriving session key') var cpSessionKey = await crypto.subtle.deriveKey( { name: 'ECDH', public: ephPubKey }, cpEcdhKeyPair.privateKey, { name: 'AES-GCM', length: 256 }, false, ['decrypt'] ) 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) }) - console.log('[portpass relay] decrypting: iv.length='+iv.length+' ct.length='+ct.length) var pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, cpSessionKey, ct) var records = JSON.parse(new TextDecoder().decode(pt)) - console.log('[portpass relay] decrypted '+records.length+' records; opener='+!!window.opener) showCrossProfilePicker(records) } catch(e) { - console.log('[portpass relay] processRelayBlob error:', e.message, e) sendError('Decrypt failed: ' + e.message) } } @@ -365,7 +343,7 @@ bookmarkletOrigin || '*' ) } - setTimeout(function() { window.close() }, 10000) + setTimeout(function() { window.close() }, 100) } })(rec) @@ -583,7 +561,7 @@ errEl.textContent = message document.getElementById('app').appendChild(errEl) if (window.opener) window.opener.postMessage({ type: 'error', message: message }, '*') - setTimeout(function() { window.close() }, 30000) + setTimeout(function() { window.close() }, 3000) } function canonicalURL(href) { diff --git a/pwa/src/App.svelte b/pwa/src/App.svelte index 43c5e29..7e17625 100644 --- a/pwa/src/App.svelte +++ b/pwa/src/App.svelte @@ -25,8 +25,6 @@ $effect(() => { localStorage.setItem('accent', accent) }) async function handleIntent(intentUrl) { - console.log('[portpass] handleIntent called; view='+view+' url='+intentUrl.slice(0,80)) - if (view !== 'dashboard') { console.log('[portpass] vault not unlocked — discarding'); return } const q = intentUrl.indexOf('?') if (q < 0) return @@ -39,29 +37,20 @@ const nonce = params.get('nonce') ?? '' const ts = parseInt(params.get('ts') ?? '0', 10) - console.log('[portpass] intent params: url='+url+' nonce='+nonce+' ts='+ts+' age='+(Date.now()-ts)+'ms') - console.log('[portpass] sig len='+sigB64.length+' pub len='+pubB64.length+' ecdh len='+ecdhB64.length) - if (!sigB64 || !pubB64 || !ecdhB64 || !nonce || !ts) { console.log('[portpass] missing params'); return } const age = Date.now() - ts - if (age > 60000 || age < -5000) { console.log('[portpass] timestamp out of window, age='+age); return } const vaultUuid = get(selectedFile)?.uuid ?? '' - console.log('[portpass] vaultUuid='+vaultUuid) if (!vaultUuid) return const spkiBytes = Uint8Array.from(atob(pubB64), c => c.charCodeAt(0)) const sigBytes = Uint8Array.from(atob(sigB64), c => c.charCodeAt(0)) const message = new TextEncoder().encode(JSON.stringify({ url, nonce, ecdh: ecdhB64, ts })) - console.log('[portpass] verifying signature; spkiBytes.length='+spkiBytes.length+' sigBytes.length='+sigBytes.length) - console.log('[portpass] signed message:', JSON.stringify({ url, nonce, ecdh: ecdhB64, ts })) const delegate = await verifyAndUpdate(vaultUuid, spkiBytes, message, sigBytes) - console.log('[portpass] verifyAndUpdate result:', delegate ? 'delegate "'+delegate.name+'" (useCount='+delegate.useCount+')' : 'null (no match)') if (!delegate) return - console.log('[portpass] dispatching intent to Dashboard') pendingIntent = { url, nonce, ecdhSpkiB64: ecdhB64 } } diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index bb2c4fc..8bd9e12 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -278,18 +278,14 @@ } async function processAutofillIntent({ url, nonce, ecdhSpkiB64 }) { - console.log('[portpass] processAutofillIntent url='+url+' nonce='+nonce) const DROP_URL = `http://localhost:7677/drop/${nonce}` const postError = msg => { - console.log('[portpass] posting error blob:', msg) return fetch(DROP_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: msg }), - }).catch(e => console.log('[portpass] postError fetch failed:', e.message)) } const records = autofillFindRecords(url) - console.log('[portpass] autofillFindRecords returned', records.length, 'records') if (!records.length) { await postError('No matching passwords found'); return } try { @@ -330,14 +326,11 @@ iv: btoa(String.fromCharCode(...iv)), ciphertext: btoa(String.fromCharCode(...new Uint8Array(ct))), }) - console.log('[portpass] posting credential blob for', recordsWithFields.length, 'records; DROP_URL='+DROP_URL+' bodyLength='+bodyStr.length) const postResp = await fetch(DROP_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: bodyStr, }) - console.log('[portpass] drop response status:', postResp.status) } catch (e) { - console.log('[portpass] processAutofillIntent error:', e.message) await postError(e.message || 'Autofill failed') } } @@ -364,27 +357,22 @@ try { const delegates = await getDelegates(dbKey) if (!delegates.length) return - console.log('[portpass] checking relay server for pending requests; delegates='+delegates.length) for (const delegate of delegates) { try { const resp = await fetch('http://localhost:7677/pick/' + delegate.id) if (!resp.ok) continue // 404 = nothing pending const req = await resp.json() - console.log('[portpass] got pending request for delegate "'+delegate.name+'"; nonce='+req.nonce) const age = Date.now() - req.ts - if (age > 60000 || age < -5000) { console.log('[portpass] request expired, age='+age+'ms'); continue } const spkiBytes = Uint8Array.from(atob(req.pub), c => c.charCodeAt(0)) const sigBytes = Uint8Array.from(atob(req.sig), c => c.charCodeAt(0)) const message = new TextEncoder().encode(JSON.stringify({ url: req.url, nonce: req.nonce, ecdh: req.ecdh, ts: req.ts })) const verified = await verifyAndUpdate(dbKey, spkiBytes, message, sigBytes) - console.log('[portpass] signature verified='+!!verified+' for delegate "'+delegate.name+'"') if (!verified) continue await processAutofillIntent({ url: req.url, nonce: req.nonce, ecdhSpkiB64: req.ecdh }) } catch (e) { - if (!(e instanceof TypeError)) console.log('[portpass] checkPending error:', e.message) // TypeError = relay server not running — silent } } @@ -542,7 +530,6 @@ const sigBytes = Uint8Array.from(atob(msg.sig), c => c.charCodeAt(0)) const sigMsg = new TextEncoder().encode(JSON.stringify({ relayNonce: msg.nonce, ecdhSpki: msg.ecdhSpki })) const verified = await verifyAndUpdate(dbKey, spkiBytes, sigMsg, sigBytes) - console.log('[portpass] relay-hello verified='+!!verified) if (!verified) { ch.postMessage({ type: 'relay-error', message: 'Autofill request not authorized', nonce: msg.nonce }) return diff --git a/pwa/src/lib/delegates.js b/pwa/src/lib/delegates.js index e564c6e..43894c5 100644 --- a/pwa/src/lib/delegates.js +++ b/pwa/src/lib/delegates.js @@ -45,12 +45,10 @@ export async function revokeDelegate(vaultUuid, delegateId) { export async function verifyAndUpdate(vaultUuid, spkiBytes, message, signatureBytes) { const all = await load() const list = all[vaultUuid] ?? [] - console.log('[portpass] verifyAndUpdate: vaultUuid='+vaultUuid+' delegates='+list.length+' spkiBytes.length='+spkiBytes.length) for (const d of list) { const stored = new Uint8Array(d.publicKey) const lenMatch = stored.length === spkiBytes.length const bytesMatch = lenMatch && stored.every((b, i) => b === spkiBytes[i]) - console.log('[portpass] checking delegate "'+d.name+'": stored.length='+stored.length+' lenMatch='+lenMatch+' bytesMatch='+bytesMatch) if (!bytesMatch) continue try { const key = await crypto.subtle.importKey( @@ -59,15 +57,13 @@ export async function verifyAndUpdate(vaultUuid, spkiBytes, message, signatureBy const valid = await crypto.subtle.verify( { name: 'ECDSA', hash: 'SHA-256' }, key, signatureBytes, message ) - console.log('[portpass] signature valid='+valid+' for delegate "'+d.name+'"') if (valid) { d.useCount++ d.lastUsed = Date.now() await save(all) return d } - } catch (e) { console.log('[portpass] crypto error for delegate "'+d.name+'":', e.message); continue } + } catch { continue } } - console.log('[portpass] no matching delegate found') return null } From c0097e3e242499a04f64bf4ff5abe4a65ad46ac1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 May 2026 00:28:28 +0200 Subject: [PATCH 21/99] Fix syntax errors introduced by debug log cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cleanup agent removed console.log lines but also accidentally: - Stripped the closing }) and .catch of postError in processAutofillIntent, leaving it as an unclosed arrow function (Vite compile error) - Removed the timestamp expiry check (age > 60s) in checkPendingAutofillRequests along with its log line — requests would never expire - Dropped try/catch closing braces in delegates.js verifyAndUpdate Co-Authored-By: Claude Sonnet 4.6 --- pwa/src/lib/Dashboard.svelte | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index 8bd9e12..1fa1226 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -279,11 +279,11 @@ async function processAutofillIntent({ url, nonce, ecdhSpkiB64 }) { const DROP_URL = `http://localhost:7677/drop/${nonce}` - const postError = msg => { - return fetch(DROP_URL, { + const postError = msg => + fetch(DROP_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: msg }), - } + }).catch(() => {}) const records = autofillFindRecords(url) if (!records.length) { await postError('No matching passwords found'); return } @@ -364,6 +364,7 @@ const req = await resp.json() const age = Date.now() - req.ts + if (age > 60000 || age < -5000) continue const spkiBytes = Uint8Array.from(atob(req.pub), c => c.charCodeAt(0)) const sigBytes = Uint8Array.from(atob(req.sig), c => c.charCodeAt(0)) From be60e0cc7d5dc19460ba6997265c65fd8f428457 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 May 2026 00:33:42 +0200 Subject: [PATCH 22/99] Security: document autofill delegate model and cross-profile channel Update SECURITY.md: - Autofill section under dedicated-profile mitigation: describe both same-profile and cross-profile modes; explain delegate model + portpass-relay allows cross-profile without sacrificing extension isolation - Add "Autofill security" section: trusted islands, ECDSA authentication, ECDH+AES-GCM credential encryption, relay server as dumb pipe, limitations (credential in DOM, bookmarklet theft, install-time boundary) Co-Authored-By: Claude Sonnet 4.6 --- SECURITY.md | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index e9a1cba..36939e6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -56,7 +56,13 @@ Extensions are installed per-profile. A profile with no extensions has no extens **Workflow:** Alt-tab to the Portpass window when you need a password, copy it, and paste it in your main browser. The 30-second clipboard autoclear limits the window during which a compromised extension could read it. -**Autofill and the dedicated profile.** The Autofill bookmarklet feature requires Portpass to run in the same browser profile as the pages you are filling — making the dedicated clean-profile approach above incompatible with Autofill. This is a deliberate opt-in tradeoff: you gain the convenience of autofill at the cost of exposing Portpass to any extensions installed in your main profile. Users who want the strongest extension isolation should continue using Portpass in a clean profile with manual copy and paste. +**Autofill and the dedicated profile.** Portpass supports two autofill modes that work alongside the dedicated clean-profile setup: + +- **Same-profile autofill**: both Portpass and the page being filled are in the same browser profile. The bookmarklet opens a relay popup that communicates with Portpass via BroadcastChannel. This is more convenient but exposes Portpass to any extensions in that profile. + +- **Cross-profile autofill**: Portpass runs in a clean, extension-free profile; the bookmarklet runs in your main browsing profile. Credentials travel from Portpass to the login page via [portpass-relay](https://github.com/dbro/portpass), a tiny local HTTP dead-drop server (`localhost:7677`). This preserves full extension isolation — Portpass never touches the browsing profile. Requires portpass-relay 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. --- @@ -113,6 +119,38 @@ 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. + +### 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. + +### 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. + +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. + +### 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. + +### Cross-profile relay server + +portpass-relay (`localhost:7677`) 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. + +### 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. +- **Bookmarklet theft**: if an attacker can read your browser's bookmark store (requires local access), they obtain the delegate's private key. Revoke the delegate in Portpass and drag a new bookmarklet to replace it. +- **Extension present at drag-install time**: an extension could modify the bookmarklet's JavaScript before it is dragged. The effective security boundary is: no hostile extension may be present when the bookmarklet is installed. + +--- + ## Implementation notes - **Memory**: the salted password buffer used during key stretching is zeroed immediately after use. From 1fe8c74d5491078d8b7bb90c3431b1b610530c24 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 May 2026 00:37:00 +0200 Subject: [PATCH 23/99] Security: clarify bookmarklet theft requires physical/OS access, not extensions Co-Authored-By: Claude Sonnet 4.6 --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 36939e6..d7e4da6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -146,7 +146,7 @@ portpass-relay (`localhost:7677`) is a dumb pipe — it stores and forwards encr ### 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. -- **Bookmarklet theft**: if an attacker can read your browser's bookmark store (requires local access), they obtain the delegate's private key. Revoke the delegate in Portpass and drag a new bookmarklet to replace it. +- **Bookmarklet theft**: the delegate's private key is in the bookmark store, which no web API or browser extension can read. Obtaining it requires physical access to the device (someone at the keyboard), a compromised OS, or full control of the browser process itself — not achievable by a malicious web page or extension. If you believe a device has been physically compromised, revoke the delegate in Portpass and drag a new bookmarklet to replace it. - **Extension present at drag-install time**: an extension could modify the bookmarklet's JavaScript before it is dragged. The effective security boundary is: no hostile extension may be present when the bookmarklet is installed. --- From d2eb8235668eae140580dfab4027ffccf8fa3d57 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 May 2026 00:38:49 +0200 Subject: [PATCH 24/99] Security: clarify drag-install threat is in the Portpass (FROM) profile The clean profile must be free of extensions to ensure the bookmarklet dragged to the destination browser is genuine. Extensions in the destination browsing profile cannot intercept it because the key arrives already stored in the bookmark store. Co-Authored-By: Claude Sonnet 4.6 --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index d7e4da6..e7e2e3b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -147,7 +147,7 @@ portpass-relay (`localhost:7677`) is a dumb pipe — it stores and forwards encr - **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. - **Bookmarklet theft**: the delegate's private key is in the bookmark store, which no web API or browser extension can read. Obtaining it requires physical access to the device (someone at the keyboard), a compromised OS, or full control of the browser process itself — not achievable by a malicious web page or extension. If you believe a device has been physically compromised, revoke the delegate in Portpass and drag a new bookmarklet to replace it. -- **Extension present at drag-install time**: an extension could modify the bookmarklet's JavaScript before it is dragged. The effective security boundary is: no hostile extension may be present when the bookmarklet is installed. +- **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. --- From cd75c8abc0867669ce5fef59005f57dc98af9bda Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 May 2026 00:45:33 +0200 Subject: [PATCH 25/99] README/SECURITY: document Autofill feature README.md: - Add Autofill to feature list - Update Password Safe comparison: autofill into native apps is still missing, but web form autofill via bookmarklet is now a Portpass strength - New Autofill section: how it works, same-profile vs cross-profile, setup steps, autotype codes, best practices; references SECURITY.md for delegate model and threat details SECURITY.md: - Already committed in prior commits this session Co-Authored-By: Claude Sonnet 4.6 --- README.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ca5f723..34d298f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Portpass is different: your passwords live in a file on your device, or in a clo ## What Portpass does +* fills login forms automatically via a bookmarklet — no browser extension, no clipboard * streamlines login to apps and websites * works fully offline, no internet connection required * encrypts your vault using an established open source format (pwsafe v3) @@ -79,7 +80,7 @@ Portpass reads and writes the [Password Safe v3](https://github.com/pwsafe/pwsaf **Features in Password Safe not currently supported by Portpass:** -- Autofill passwords into other apps (requires a browser extension or native helper app) +- Autofill into native desktop apps (Portpass fills web login forms only) - Automatic vault lock after an idle timeout - Password strength indicator and breach alerts - Password entry aliases (re-using a password across multiple sites) @@ -93,6 +94,7 @@ Portpass reads and writes the [Password Safe v3](https://github.com/pwsafe/pwsaf **What Portpass offers that Password Safe does not:** +- Autofill web login forms via a bookmarklet — no browser extension required; works cross-browser-profile - Runs in any modern browser — no installation required - Works on mobile (iOS, Android) with a touch-friendly interface - Biometric/PIN unlock via fingerprint, face recognition, PIN, or hardware security key (WebAuthn PRF — YubiKey series 5+ may work but is untested) @@ -108,6 +110,55 @@ There is no server, no account, and nothing to trust except the open source code 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). +## Autofill + +Portpass can fill login forms automatically — username, password, one-time code, and any other fields in the right order — without a browser extension and without copying anything to the clipboard. + +### How it 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 **Autotype** sequence (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. + +### Same-profile and cross-profile autofill + +**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. + +**Cross-profile**: Portpass runs in a dedicated clean profile (no extensions), and the bookmarklet runs in your regular browsing profile. This is the most secure setup — your vault is completely isolated from extensions in your main profile. A small local background service, **portpass-relay**, bridges the two profiles over `localhost`. No data leaves your machine. + +### Setting up autofill + +1. Open Portpass and unlock your vault. +2. Open vault settings (tap the vault name in the top bar). +3. Under **Autofill**, click **+ New bookmarklet**. Give it a name (e.g. "Chrome — main profile") and click **Create**. +4. Drag the chip to your browser's bookmarks bar. If the bar is hidden, click **Copy link** and add the bookmark manually. + +For cross-profile setup, start portpass-relay as a background service on your machine before using the bookmarklet. + +### Autotype sequences + +Each record has an **Autotype** field controlling what gets filled and in what order. The default `\u\t\p\n` covers most sites: fill username, tab to the next field, fill password, submit. You can customise this for unusual login flows (e.g. single-field pages, sites that require an email, sites with two-factor code fields). + +| Code | Action | +|---|---| +| `\u` | Username | +| `\p` | Password | +| `\m` | Email | +| `\2` | One-time code (TOTP) | +| `\t` | Tab to next field | +| `\s` | Shift-Tab (previous field) | +| `\n` | Submit form | +| `\wNNN` | Wait NNN milliseconds | + +### Best practices + +- **Use a dedicated browser profile for Portpass.** Install Portpass in a separate browser profile with no extensions. Drag bookmarklets from that clean profile to your main browsing profile. This keeps your vault isolated from any extension installed in your day-to-day browser, including the one the bookmarklet runs in. See [SECURITY.md](SECURITY.md) for setup instructions. +- **One bookmarklet per browser or 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. +- **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.** 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. + +See [SECURITY.md](SECURITY.md) for a full description of how the delegate model guards against malicious extensions, clipboard eavesdropping, and other threats. + ## Security Portpass's threat model, known limitations, and guidance on protecting yourself from malicious browser extensions are documented in [SECURITY.md](SECURITY.md). From cdbef960adb33fd25438022ddd43d8539560b7e5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 May 2026 09:54:36 +0200 Subject: [PATCH 26/99] StartPage: read-only fallback for browsers without showOpenFilePicker Firefox doesn't implement the File System Access API, so the app previously showed a dead-end error. Now it falls back to , opens the vault with readonly:true, and skips features that need a persistent handle (recent vaults, secondary auto-unlock, biometric offer). Co-Authored-By: Claude Sonnet 4.6 --- pwa/src/lib/StartPage.svelte | 67 ++++++++++++++++++++++++++---------- pwa/tests/unlock.spec.ts | 22 ++++++++++++ 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/pwa/src/lib/StartPage.svelte b/pwa/src/lib/StartPage.svelte index 6979587..ecb3d4a 100644 --- a/pwa/src/lib/StartPage.svelte +++ b/pwa/src/lib/StartPage.svelte @@ -30,6 +30,9 @@ const supportsFilePicker = typeof window !== 'undefined' && 'showOpenFilePicker' in window + let fallbackFile = $state(null) // File object when supportsFilePicker is false + let fileInputEl = $state(null) + onMount(async () => { biometricAvailable = await isBiometricSupported() @@ -61,12 +64,27 @@ } } + function pickFileFallback() { + fileInputEl.click() + } + + function onFileInputChange(e) { + const file = e.target.files?.[0] + if (!file) return + e.target.value = '' + fallbackFile = file + mode = 'unlock' + error = '' + password = '' + biometricEnrolled = false + } + // After a successful vault open, check whether to offer biometric enrollment. // The offer is shown at most once per vault file — if dismissed, the user can // enable biometric/PIN unlock later from the vault settings sheet. function afterUnlock() { const offerKey = `biometric-offered-${fileHandle?.name}` - if (biometricAvailable && !biometricEnrolled && !localStorage.getItem(offerKey)) { + if (!fallbackFile && biometricAvailable && !biometricEnrolled && !localStorage.getItem(offerKey)) { localStorage.setItem(offerKey, '1') mode = 'offer-biometric' } else { @@ -75,24 +93,32 @@ } async function unlock() { - if (!password || !fileHandle) return + if (!password || (!fileHandle && !fallbackFile)) return busy = true; error = '' try { - const perm = await fileHandle.requestPermission({ mode: 'read' }) - if (perm !== 'granted') { error = 'File access was denied.'; return } - let file - try { file = await fileHandle.getFile() } catch (e) { - if (e.name === 'NotFoundError') { await handleFileMissing(); return } - throw e + let buf + if (fallbackFile) { + buf = await fallbackFile.arrayBuffer() + } else { + const perm = await fileHandle.requestPermission({ mode: 'read' }) + if (perm !== 'granted') { error = 'File access was denied.'; return } + let file + try { file = await fileHandle.getFile() } catch (e) { + if (e.name === 'NotFoundError') { await handleFileMissing(); return } + throw e + } + buf = await file.arrayBuffer() } - const buf = await file.arrayBuffer() const vaultUuid = openDatabase(new Uint8Array(buf), password) dbItems.set(getDatabaseData(vaultUuid)) - const info = getDatabaseInfo(vaultUuid) - const writable = await probeWriteAccess(fileHandle) - selectedFile.set({ handle: fileHandle, name: fileHandle.name, readonly: !writable, uuid: vaultUuid }) - try { await pushRecentHandle(fileHandle, vaultUuid) } catch {} - await autoUnlockSecondaries(vaultUuid) + if (fallbackFile) { + selectedFile.set({ handle: null, name: fallbackFile.name, readonly: true, uuid: vaultUuid }) + } else { + const writable = await probeWriteAccess(fileHandle) + selectedFile.set({ handle: fileHandle, name: fileHandle.name, readonly: !writable, uuid: vaultUuid }) + try { await pushRecentHandle(fileHandle, vaultUuid) } catch {} + await autoUnlockSecondaries(vaultUuid) + } afterUnlock() } catch (e) { error = 'Wrong password or invalid file.' @@ -215,7 +241,7 @@ } function switchFile() { - fileHandle = null; password = ''; error = ''; mode = 'landing' + fileHandle = null; fallbackFile = null; password = ''; error = ''; mode = 'landing' secondaryVaults.set([]) } @@ -243,9 +269,9 @@ {#if supportsFilePicker} {:else} -
- Your browser doesn't support file picker.
Try Chrome or Safari. -
+ +
Read-only — your browser can't save changes back to the file
+ {/if}
@@ -261,10 +287,13 @@
Portpass
-
{fileHandle?.name ?? 'Vault'}
+
{(fallbackFile ?? fileHandle)?.name ?? 'Vault'}
{busy ? 'Unlocking…' : isPopup ? 'Unlock to use Autofill' : 'Vault is locked'}
+ {#if fallbackFile && !busy} +
Read-only (your browser can't save changes)
+ {/if} {#if busy}
diff --git a/pwa/tests/unlock.spec.ts b/pwa/tests/unlock.spec.ts index 1543beb..afb3710 100644 --- a/pwa/tests/unlock.spec.ts +++ b/pwa/tests/unlock.spec.ts @@ -44,6 +44,28 @@ test.describe('Unlock screen', () => { await expect(page.locator('.empty-vault')).toBeVisible() }) + test('fallback file input opens vault read-only when showOpenFilePicker unavailable', async ({ page }) => { + await page.addInitScript(() => { + delete (window as any).showOpenFilePicker + }) + + await page.goto('/portpass/') + await expect(page.getByText('Read-only')).toBeVisible() + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.getByRole('button', { name: 'Open vault file' }).click(), + ]) + await fileChooser.setFiles(THREE_DB_PATH) + + await expect(page.getByText('Read-only', { exact: false })).toBeVisible() + await page.getByPlaceholder('Master password').fill('three3#;') + await page.getByRole('button', { name: 'Unlock' }).click() + + await expect(page.getByPlaceholder('Search vault')).toBeVisible({ timeout: 10000 }) + await expect(page.locator('.desktop-new-btn')).not.toBeVisible() + }) + test('Enter key submits password', async ({ page }) => { const data = [...fs.readFileSync(THREE_DB_PATH)] await page.addInitScript((fileData: number[]) => { From 01240c24ce49b0b6dd38f9fb64f20e18dd6c677f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 May 2026 10:43:18 +0200 Subject: [PATCH 27/99] relay: change default port from 7677 to 7577 (avoid IBM service conflict) Updates all hardcoded port references in Dashboard.svelte, relay.html, and the CSP connect-src in index.html. Co-Authored-By: Claude Sonnet 4.6 --- pwa/index.html | 2 +- pwa/public/relay.html | 4 ++-- pwa/src/lib/Dashboard.svelte | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pwa/index.html b/pwa/index.html index 68921d6..64dd99a 100644 --- a/pwa/index.html +++ b/pwa/index.html @@ -7,7 +7,7 @@ - + Portpass diff --git a/pwa/public/relay.html b/pwa/public/relay.html index e44aafb..a3b16fe 100644 --- a/pwa/public/relay.html +++ b/pwa/public/relay.html @@ -214,7 +214,7 @@ var requestBody = JSON.stringify({ url: currentUrl, nonce: nonce, ecdh: cpEcdhSpkiB64, ts: ts, sig: sigB64, pub: pubSpkiB64 }) async function postRequest() { try { - var r = await fetch('http://localhost:7677/drop/' + delegateId, { + var r = await fetch('http://localhost:7577/drop/' + delegateId, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: requestBody, @@ -246,7 +246,7 @@ // empty, whitespace, or unparseable — caller can re-post the request to recover. async function pollRelayServer(pollNonce, timeout, onBadBlob) { var deadline = Date.now() + timeout - var url = 'http://localhost:7677/pick/' + pollNonce + var url = 'http://localhost:7577/pick/' + pollNonce while (Date.now() < deadline) { try { var resp = await fetch(url) diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index 1fa1226..0a95373 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -278,7 +278,7 @@ } async function processAutofillIntent({ url, nonce, ecdhSpkiB64 }) { - const DROP_URL = `http://localhost:7677/drop/${nonce}` + const DROP_URL = `http://localhost:7577/drop/${nonce}` const postError = msg => fetch(DROP_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -359,7 +359,7 @@ if (!delegates.length) return for (const delegate of delegates) { try { - const resp = await fetch('http://localhost:7677/pick/' + delegate.id) + const resp = await fetch('http://localhost:7577/pick/' + delegate.id) if (!resp.ok) continue // 404 = nothing pending const req = await resp.json() From 44e3cf32cc06ef192dc410577697f25dc82af0fb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 May 2026 11:56:50 +0200 Subject: [PATCH 28/99] Autofill: per-channel use counts, relay URL config, Advanced section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delegates.js: - Replace useCount/lastUsed with bcCount/bcLastUsed and relayCount/relayLastUsed - verifyAndUpdate() takes a channel arg ('bc' or 'relay') to increment the right pair - Migration zeros counters on old delegate records - getRelayUrl/setRelayUrl: per-vault relay URL stored in IDB (default http://localhost:7577) store.js: add relayUrl writable store Dashboard.svelte: - Init relayUrl store from IDB on vault open - Use relayUrl store for all relay server fetch URLs (replaces hardcoded localhost:7577) - Pass 'bc' or 'relay' channel to verifyAndUpdate at each call site VaultSheet.svelte: - Delegate rows show total uses (bc + relay) and max last-used timestamp - Advanced section: relay URL input with Save/Cancel when dirty, 3-state status indicator (blank/ok/error) probed on open and every 5s, count of cross-profile autofill uses across all delegates - Probe uses AbortSignal.timeout(500ms); status blank until first probe completes relay.c: add GET /status → 200 "ok" for VaultSheet probe vault_sheet.spec.ts: fix 5 pre-existing failures — bookmarklet chip tests now go through the + New bookmarklet flow before asserting chip visibility Co-Authored-By: Claude Sonnet 4.6 --- pwa/src/lib/Dashboard.svelte | 12 +-- pwa/src/lib/VaultSheet.svelte | 141 ++++++++++++++++++++++++++++++++-- pwa/src/lib/delegates.js | 48 +++++++++--- pwa/src/store.js | 1 + pwa/tests/vault_sheet.spec.ts | 28 +++---- 5 files changed, 198 insertions(+), 32 deletions(-) diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index 0a95373..a3fb692 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -1,7 +1,7 @@ diff --git a/pwa/public/relay.html b/pwa/public/relay.html index 948b719..62db180 100644 --- a/pwa/public/relay.html +++ b/pwa/public/relay.html @@ -101,10 +101,11 @@ var modeTimer = null // ── Cross-profile state ─────────────────────────────────────────────────── - // ECDH key pair generated at startup; public key sent to Portpass in the - // web+portpass:// URL so Portpass can encrypt the credential blob for us. + // ECDH key pair generated at startup; public key sent to Portpass via the + // switchboard so Portpass can encrypt the credential blob for us. var cpEcdhKeyPair = null var cpEcdhSpkiB64 = null + var switchboardWsUrl = 'ws://localhost:7577/ws' // updated from relay-pong if available // ── Same-profile state ──────────────────────────────────────────────────── var relayNonce = null @@ -136,6 +137,9 @@ // Mode is finalised in onOpenerMessage once init arrives. ch.onmessage = function(e) { if (e.data && e.data.type === 'relay-pong' && e.data.nonce === nonce) { + if (e.data.switchboardUrl) { + switchboardWsUrl = e.data.switchboardUrl.replace(/^http/, 'ws') + '/ws' + } bcPongArrived = true if (!modeDecided && cprivKey !== null) { modeDecided = true @@ -209,31 +213,11 @@ if (!delegateId) throw new Error('No delegate ID — reinstall the bookmarklet') - // POST signed request to switchboard under the delegate's ID slot. - // Portpass polls this slot on interval and processes any pending request. - var requestBody = JSON.stringify({ url: currentUrl, nonce: nonce, ecdh: cpEcdhSpkiB64, ts: ts, sig: sigB64, pub: pubSpkiB64 }) - async function postRequest() { - try { - var r = await fetch('http://localhost:7577/drop/' + delegateId, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: requestBody, - }) - if (!r.ok) throw new Error('Relay server error ' + r.status) - } catch(e) { - if (e instanceof TypeError) throw new Error('portpass-switchboard is not running — start it first') - throw e - } - } - await postRequest() - - // On bad blob (empty/unparseable), re-post the request so Portpass re-processes. - async function onBadBlob() { - await postRequest().catch(function() {}) - } - $status.textContent = 'Waiting for Portpass…' - var blob = await pollRelayServer(nonce, 60000, onBadBlob) + var blob = await sendViaSwitchboard({ + type: 'request', delegateId: delegateId, nonce: nonce, + url: currentUrl, ecdh: cpEcdhSpkiB64, ts: ts, sig: sigB64, pub: pubSpkiB64 + }) await processRelayBlob(blob) } catch(e) { @@ -241,41 +225,35 @@ } } - // Polls GET /pick/{nonce} every 500ms until a blob is available or timeout. - // onBadBlob (optional) is called when a 200 response arrives but the body is - // empty, whitespace, or unparseable — caller can re-post the request to recover. - async function pollRelayServer(pollNonce, timeout, onBadBlob) { - var deadline = Date.now() + timeout - var url = 'http://localhost:7577/pick/' + pollNonce - while (Date.now() < deadline) { - try { - var resp = await fetch(url) - if (resp.ok) { - var text = await resp.text() - var parsed = null - if (text.trim()) { - try { parsed = JSON.parse(text) } catch(_) {} - } - if (parsed && (parsed.ephPub || parsed.error)) { - return parsed - } - // Bad blob — log and trigger re-post - if (onBadBlob) await onBadBlob() - } else if (resp.status !== 404) throw new Error('Relay server error ' + resp.status) - } catch(e) { - if (e instanceof TypeError) throw new Error('portpass-switchboard is not running — start it first') - throw e + // Opens a WebSocket to the switchboard, sends the request, and waits for a response. + // Returns a promise that resolves with the response message or rejects on error. + function sendViaSwitchboard(requestMsg) { + return new Promise(function(resolve, reject) { + var ws + try { ws = new WebSocket(switchboardWsUrl) } catch(e) { + reject(new Error('portpass-switchboard is not running — start it first')); return } - await new Promise(function(r) { setTimeout(r, 500) }) - } - throw new Error('Timed out waiting for Portpass') + var settled = false + function done(fn, arg) { if (!settled) { settled = true; ws.close(); fn(arg) } } + ws.onopen = function() { ws.send(JSON.stringify(requestMsg)) } + ws.onmessage = function(e) { + try { + var msg = JSON.parse(e.data) + if (msg.type === 'error') done(reject, new Error(msg.message || 'Autofill failed')) + else if (msg.type === 'response') done(resolve, msg) + } catch(_) { done(reject, new Error('Invalid switchboard message')) } + } + ws.onerror = function() { done(reject, new Error('portpass-switchboard is not running — start it first')) } + ws.onclose = function() { if (!settled) done(reject, new Error('portpass-switchboard closed unexpectedly')) } + }) } - // Decrypts the switchboard blob using the ECDH key pair generated at startup. - // Blob format: { ephPub: base64(JSON-stringified JWK), iv: base64, ciphertext: base64 } - // Or error format: { error: "message" } + // Decrypts the switchboard response using the ECDH key pair generated at startup. + // Response format: { type:"response", nonce, ephPub, iv, ciphertext } + // Or error: { type:"response", nonce, error: "message" } // Plaintext: JSON array of records, each with pre-decrypted fields. async function processRelayBlob(blob) { + if (blob.error) { sendError(blob.error); return } try { var ephPubJwk = JSON.parse(atob(blob.ephPub)) var ephPubKey = await crypto.subtle.importKey( diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index 631dc6f..badeb6a 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -280,15 +280,11 @@ } async function processAutofillIntent({ url, nonce, ecdhSpkiB64 }) { - const DROP_URL = `${get(switchboardUrl)}/drop/${nonce}` - const postError = msg => - fetch(DROP_URL, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ error: msg }), - }).catch(() => {}) + const wsSend = (msg) => { if (_sbWs) _sbWs.send(JSON.stringify(msg)) } + const wsError = (error) => wsSend({ type: 'response', nonce, error }) const records = autofillFindRecords(url) - if (!records.length) { await postError('No matching passwords found'); return } + if (!records.length) { wsError('No matching passwords found'); return } try { const ephPair = await crypto.subtle.generateKey( @@ -314,7 +310,7 @@ }) } catch { /* skip records with invalid autotype */ } } - if (!recordsWithFields.length) { await postError('No matching passwords found'); return } + if (!recordsWithFields.length) { wsError('No matching passwords found'); return } const iv = crypto.getRandomValues(new Uint8Array(12)) const ct = await crypto.subtle.encrypt( @@ -323,17 +319,14 @@ ) const ephPubJwk = await crypto.subtle.exportKey('jwk', ephPair.publicKey) - const bodyStr = JSON.stringify({ + wsSend({ + type: 'response', nonce, ephPub: btoa(JSON.stringify(ephPubJwk)), iv: btoa(String.fromCharCode(...iv)), ciphertext: btoa(String.fromCharCode(...new Uint8Array(ct))), }) - const postResp = await fetch(DROP_URL, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: bodyStr, - }) } catch (e) { - await postError(e.message || 'Autofill failed') + wsError(e.message || 'Autofill failed') } } @@ -343,45 +336,58 @@ processAutofillIntent(intent) }) - // Poll portpass-switchboard for pending cross-profile autofill requests while vault is unlocked. - // relay.html POSTs the signed request to /drop/{delegateId}; we pick it up here. - $effect(() => { - if (isPopup) return - const id = setInterval(checkPendingAutofillRequests, 2000) - return () => clearInterval(id) - }) + // ── Switchboard WebSocket (cross-profile autofill) ───────────────────────── + // relay.html connects to the switchboard and sends a signed request; + // the switchboard routes it here via WebSocket. No polling. - let _checkInProgress = false + let _sbWs = null + let _sbConnecting = false - async function checkPendingAutofillRequests() { - if (_checkInProgress) return - _checkInProgress = true - try { - const delegates = await getDelegates(dbKey) - if (!delegates.length) return - for (const delegate of delegates) { - try { - const resp = await fetch(`${get(switchboardUrl)}/pick/` + delegate.id) - if (resp.status === 204) continue // 204 = nothing pending - const req = await resp.json() - - const age = Date.now() - req.ts - if (age > 60000 || age < -5000) continue + function sbWsUrl() { + return get(switchboardUrl).replace(/^http/, 'ws') + '/ws' + } - const spkiBytes = Uint8Array.from(atob(req.pub), c => c.charCodeAt(0)) - const sigBytes = Uint8Array.from(atob(req.sig), c => c.charCodeAt(0)) - const message = new TextEncoder().encode(JSON.stringify({ url: req.url, nonce: req.nonce, ecdh: req.ecdh, ts: req.ts })) + function connectSwitchboard() { + if (_sbWs || _sbConnecting) return + _sbConnecting = true + let ws + try { ws = new WebSocket(sbWsUrl()) } catch { _sbConnecting = false; return } + ws.onopen = async () => { + _sbWs = ws + _sbConnecting = false + const delegates = await getDelegates(dbKey) + if (delegates.length && _sbWs === ws) + ws.send(JSON.stringify({ type: 'register', delegates: delegates.map(d => d.id) })) + } + ws.onmessage = async (event) => { + try { + const msg = JSON.parse(event.data) + if (msg.type !== 'request') 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.nonce, ecdh: msg.ecdh, ts: msg.ts })) const verified = await verifyAndUpdate(dbKey, spkiBytes, message, sigBytes, 'relay') - if (!verified) continue - - await processAutofillIntent({ url: req.url, nonce: req.nonce, ecdhSpkiB64: req.ecdh }) - } catch (e) { - // TypeError = switchboard not running — silent - } + if (!verified) return + await processAutofillIntent({ url: msg.url, nonce: msg.nonce, ecdhSpkiB64: msg.ecdh }) + } catch(e) {} + } + ws.onclose = ws.onerror = () => { + if (_sbWs === ws) { _sbWs = null; _sbConnecting = false } + setTimeout(connectSwitchboard, 2000) } - } finally { _checkInProgress = false } } + $effect(() => { + if (isPopup) return + const _url = $switchboardUrl // re-connect when URL changes + if (_sbWs) { _sbWs.close(); _sbWs = null } + _sbConnecting = false + connectSwitchboard() + return () => { if (_sbWs) { _sbWs.close(); _sbWs = null }; _sbConnecting = false } + }) + // Autofill postMessage handler — ECDH key exchange then encrypted query response. function autofillValidateSequence(seq) { if (!seq) return '' @@ -515,7 +521,7 @@ if (!msg?.type) return if (msg.type === 'relay-ping') { - ch.postMessage({ type: 'relay-pong', nonce: msg.nonce }) + ch.postMessage({ type: 'relay-pong', nonce: msg.nonce, switchboardUrl: get(switchboardUrl) }) return } From 227bbac27d0e384763cc21eeb3c713c3d9ef7004 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 21 May 2026 13:33:40 +0200 Subject: [PATCH 34/99] Switchboard: replace HTTP /status probe with switchboardConnected store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add switchboardConnected writable store (true when WS is open) - Dashboard sets it true/false in ws.onopen / ws.onclose - VaultSheet reads $switchboardConnected directly — no HTTP probe, no setInterval, no onDestroy cleanup - Default switchboardUrl changed to ws://localhost:7577 (was http://) - Placeholder in Advanced section updated to match - CSP: drop http://localhost:7577 (no HTTP traffic to switchboard) - sbWsUrl() handles both ws:// and http:// stored formats Co-Authored-By: Claude Sonnet 4.6 --- pwa/index.html | 2 +- pwa/src/lib/Dashboard.svelte | 10 +++++---- pwa/src/lib/VaultSheet.svelte | 40 +++++++---------------------------- pwa/src/lib/delegates.js | 2 +- pwa/src/store.js | 3 ++- 5 files changed, 18 insertions(+), 39 deletions(-) diff --git a/pwa/index.html b/pwa/index.html index 3eef11b..53fd859 100644 --- a/pwa/index.html +++ b/pwa/index.html @@ -7,7 +7,7 @@ - + Portpass diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index badeb6a..2045c1c 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -1,7 +1,7 @@ diff --git a/pwa/src/lib/VaultSheet.svelte b/pwa/src/lib/VaultSheet.svelte index 5439572..2b304b9 100644 --- a/pwa/src/lib/VaultSheet.svelte +++ b/pwa/src/lib/VaultSheet.svelte @@ -162,54 +162,96 @@ // ── Autofill delegates ───────────────────────────────────────────────────── let delegates = $state([]) - let newDelegateStep = $state(null) // null | 'form' | 'install' + let newDelegateOpen = $state(false) let newDelegateName = $state('') + let newDelegatePrivKeyJwk = $state(null) + let newDelegatePubKeySpki = $state(null) + let newDelegateId = $state(null) let newDelegateUrl = $state('') let newDelegateError = $state('') let newDelegateBusy = $state(false) - let chipCopied = $state(false) + let newDelegateBirthAt = $state(null) + let chipCopied = $state(false) let chipCopyTimer = null - - function openNewDelegate() { - newDelegateStep = 'form' - newDelegateName = '' - newDelegateUrl = '' + let chipDragged = $state(false) + let chipLinked = $state(false) // persistent: set on copy, not reset by the feedback timer + let globeTipOpen = $state(false) + + let chipUsed = $derived(chipDragged || chipLinked) + let canUseChip = $derived(!!newDelegateName.trim() && !!newDelegatePrivKeyJwk) + let canCommit = $derived((!!newDelegateName.trim() || chipUsed) && !!newDelegatePrivKeyJwk && !newDelegateBusy) + + function defaultDelegateName() { + return 'Bookmarklet created ' + new Date(newDelegateBirthAt ?? Date.now()).toLocaleString( + undefined, { month: 'short', day: 'numeric', year: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit' } + ) + } + + async function openNewDelegate() { + newDelegateOpen = true + newDelegateName = '' + newDelegatePrivKeyJwk = null + newDelegatePubKeySpki = null + newDelegateId = null + newDelegateUrl = '' newDelegateError = '' + newDelegateBusy = false + newDelegateBirthAt = Date.now() chipCopied = false + chipDragged = false + chipLinked = false + globeTipOpen = false + try { + const keyPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'] + ) + newDelegatePrivKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) + newDelegatePubKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey) + newDelegateId = crypto.randomUUID() + newDelegateUrl = makeDelegateBookmarkletUrl( + window.location.origin + import.meta.env.BASE_URL, newDelegatePrivKeyJwk, newDelegateId + ) + } catch (e) { + newDelegateError = 'Failed to generate key pair' + } } function closeNewDelegate() { - newDelegateStep = null - newDelegateName = '' - newDelegateUrl = '' + newDelegateOpen = false + newDelegateName = '' + newDelegatePrivKeyJwk = null + newDelegatePubKeySpki = null + newDelegateId = null + newDelegateUrl = '' newDelegateError = '' chipCopied = false + chipDragged = false + chipLinked = false + globeTipOpen = false clearTimeout(chipCopyTimer) } - async function createDelegate() { - if (!newDelegateName.trim() || !_vaultUuid) return + async function commitDelegate() { + if (!_vaultUuid || !newDelegatePubKeySpki || !newDelegateId) return + const name = newDelegateName.trim() || defaultDelegateName() newDelegateBusy = true newDelegateError = '' try { - const keyPair = await crypto.subtle.generateKey( - { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'] - ) - const privKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) - const pubKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey) - const delegate = await addDelegate(_vaultUuid, newDelegateName.trim(), pubKeySpki) + const delegate = await addDelegate(_vaultUuid, name, newDelegatePubKeySpki, newDelegateId) delegates = [delegate, ...delegates] - newDelegateUrl = makeDelegateBookmarkletUrl( - window.location.origin + import.meta.env.BASE_URL, privKeyJwk, delegate.id - ) - newDelegateStep = 'install' + closeNewDelegate() } catch (e) { - newDelegateError = e.message || 'Failed to create delegate' - } finally { + newDelegateError = e.message || 'Failed to save bookmarklet' newDelegateBusy = false } } + async function cancelOrSave() { + if (chipUsed) await commitDelegate() + else closeNewDelegate() + } + async function revokeOne(delegateId) { await revokeDelegate(_vaultUuid, delegateId) delegates = delegates.filter(d => d.id !== delegateId) @@ -247,8 +289,9 @@ function copyChip() { navigator.clipboard.writeText(newDelegateUrl).then(() => { chipCopied = true + chipLinked = true clearTimeout(chipCopyTimer) - chipCopyTimer = setTimeout(() => { chipCopied = false }, 2000) + chipCopyTimer = setTimeout(() => { chipCopied = false }, 2200) }) } @@ -647,54 +690,74 @@ {/if} -{#if newDelegateStep} +{#if newDelegateOpen} From 208604954f9339083bc183d14f4d9a7c3abfd491 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 25 May 2026 05:48:23 +0200 Subject: [PATCH 81/99] handle too-wide record fields --- pwa/src/lib/components.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pwa/src/lib/components.css b/pwa/src/lib/components.css index 222603d..52e5d47 100644 --- a/pwa/src/lib/components.css +++ b/pwa/src/lib/components.css @@ -231,7 +231,7 @@ .vault-app .record-bar-group { font-size: 13px; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; } -.vault-app .record-body { flex: 1; overflow-y: auto; padding: 20px 16px 32px; } +.vault-app .record-body { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 20px 16px 32px; } .vault-app .record-title { font-size: 26px; font-weight: 700; letter-spacing: -0.015em; margin: 0 0 20px; } /* Drain bar uses transform:scaleX (reliable cross-browser) behind text via z-index:-1. @@ -509,7 +509,7 @@ height: 100%; padding-bottom: 80px; } .vault-app.is-desktop .record-screen { - grid-column: 2; grid-row: 2 / -1; height: 100%; + grid-column: 2; grid-row: 2 / -1; height: 100%; min-width: 0; overflow-x: hidden; } .vault-app.is-desktop .record-screen .record-bar { display: none; } .vault-app.is-desktop .record-pane-header { From 6e98f7d3dacf9a4e9ad97e61c7477ed25366aea6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 25 May 2026 09:27:54 +0200 Subject: [PATCH 82/99] remove immediate biometric unlock on mobile --- pwa/src/lib/StartPage.svelte | 55 ++++++++++++++++++++++++++++-------- pwa/src/lib/biometric.js | 42 +++++++++++++++------------ 2 files changed, 68 insertions(+), 29 deletions(-) diff --git a/pwa/src/lib/StartPage.svelte b/pwa/src/lib/StartPage.svelte index ecb3d4a..bb21985 100644 --- a/pwa/src/lib/StartPage.svelte +++ b/pwa/src/lib/StartPage.svelte @@ -46,7 +46,9 @@ biometricEnrolled = entry.uuid ? await isBiometricEnrolled(entry.uuid) : await isBiometricEnrolledForFile(fileHandle.name) - if (biometricEnrolled && autoBiometric) unlockBiometric() + // Only trigger if you can ensure a clean permission flow. + const status = await fileHandle.queryPermission({ mode: 'read' }) + if (status == 'granted' && biometricEnrolled && autoBiometric) unlockBiometric() } catch {} }) @@ -129,9 +131,21 @@ } async function unlockBiometric() { - busy = true; error = '' + // 1. Guard against re-entry + if (busy) return; busy = true + error = '' + try { - // Retrieve the master password from biometric, use it immediately, let it go out of scope. + // 2. Request File Permission FIRST (User-Activation requirement) + // This is a direct call on the handle, which Chrome trusts if + // called within the same event loop as the button click. + const perm = await fileHandle.requestPermission({ mode: 'read' }) + if (perm !== 'granted') { + error = 'File access was denied.' + return + } + + // 3. Authenticate with Biometric let biometricPassword try { biometricPassword = await unlockWithBiometric(fileHandle.name) @@ -140,18 +154,26 @@ console.error(e) return } - const perm = await fileHandle.requestPermission({ mode: 'read' }) - if (perm !== 'granted') { biometricPassword = null; error = 'File access was denied.'; return } + + // 4. Verify file existence let file - try { file = await fileHandle.getFile() } catch (e) { - if (e.name === 'NotFoundError') { biometricPassword = null; await handleFileMissing(); return } + try { + file = await fileHandle.getFile() + } catch (e) { + if (e.name === 'NotFoundError') { + await handleFileMissing() + return + } throw e } - const buf = await file.arrayBuffer() + + // 5. Decrypt and Open + const buf = await file.arrayBuffer() let vaultUuid try { vaultUuid = openDatabase(new Uint8Array(buf), biometricPassword) - } catch { + } catch (e) { + console.error("[DEBUG] Decryption failed. Error:", e) biometricPassword = null await clearBiometricForFile(fileHandle.name) biometricEnrolled = false @@ -159,13 +181,24 @@ return } biometricPassword = null + + // 6. UI/State updates dbItems.set(getDatabaseData(vaultUuid)) - const info = getDatabaseInfo(vaultUuid) const writable = await probeWriteAccess(fileHandle) - selectedFile.set({ handle: fileHandle, name: fileHandle.name, readonly: !writable, uuid: vaultUuid }) + selectedFile.set({ + handle: fileHandle, + name: fileHandle.name, + readonly: !writable, + uuid: vaultUuid + }) + await pushRecentHandle(fileHandle, vaultUuid) await autoUnlockSecondaries(vaultUuid) onopened() + + } catch (e) { + console.error(e) + error = 'An unexpected error occurred.' } finally { busy = false } diff --git a/pwa/src/lib/biometric.js b/pwa/src/lib/biometric.js index 73a6284..19e3d7c 100644 --- a/pwa/src/lib/biometric.js +++ b/pwa/src/lib/biometric.js @@ -91,27 +91,33 @@ export async function unlockWithBiometric(filename) { const stored = enrollments.find(e => e.filename === filename) if (!stored) throw new Error('No biometric enrollment found.') - const assertion = await navigator.credentials.get({ - publicKey: { - challenge: crypto.getRandomValues(new Uint8Array(32)), - allowCredentials: [{ type: 'public-key', id: new Uint8Array(stored.credentialId) }], - userVerification: 'required', - extensions: { prf: { eval: { first: PRF_SALT } } }, - timeout: 60000, - }, - }) + try { + const assertion = await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + allowCredentials: [{ type: 'public-key', id: new Uint8Array(stored.credentialId) }], + userVerification: 'required', + extensions: { prf: { eval: { first: PRF_SALT } } }, + timeout: 60000, + }, + }); - const prfResult = assertion.getClientExtensionResults().prf?.results?.first - if (!prfResult) throw new Error('PRF extension not available — biometric unlock failed.') + const prfResult = assertion.getClientExtensionResults().prf?.results?.first + if (!prfResult) throw new Error('PRF extension not available — biometric unlock failed.') - const key = await deriveKey(prfResult) - const plaintext = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: new Uint8Array(stored.iv) }, - key, - new Uint8Array(stored.ciphertext) - ) + const key = await deriveKey(prfResult) + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: new Uint8Array(stored.iv) }, + key, + new Uint8Array(stored.ciphertext) + ) + + return new TextDecoder().decode(plaintext) - return new TextDecoder().decode(plaintext) + } catch (err) { + console.error(`[DEBUG] FAILED. Name: ${err.name}, Message: ${err.message}`) + throw err + } } // Remove enrollment for a specific vault UUID (used when disabling from vault settings). From 897dafe0ef82904dce382a4426fc066ccbc8e83a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 25 May 2026 10:11:36 +0200 Subject: [PATCH 83/99] add header to remove interstitial page when running ngrok --- pwa/vite.config.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pwa/vite.config.js b/pwa/vite.config.js index 61d00fa..46baeb1 100644 --- a/pwa/vite.config.js +++ b/pwa/vite.config.js @@ -68,6 +68,17 @@ export default defineConfig({ workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg,wasm,gz}'], maximumFileSizeToCacheInBytes: 5000000, + // Inject the header into the requests + manifestTransforms: [async (entries) => { + const manifest = entries.map(entry => { + return { + ...entry, + // This tells Workbox to use custom headers for requests + headers: { 'ngrok-skip-browser-warning': 'true' } + } + }) + return { manifest } + }] } }) ], From ab350246c5f12238ffaa83dce2d3ba3930ec262a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 26 May 2026 22:00:12 +0200 Subject: [PATCH 84/99] Support read-only vaults on iOS and browsers without File System Access API - Fix showOpenFilePicker detection to use typeof check (iOS has the property but it's not a callable function) - Open vaults via fallback on iOS; mark them read-only since createWritable() isn't supported - Enable Edit button for read-only vaults (primary and secondary), showing a 'read-only' chip next to it - Show 'Save as' instead of 'Save' when editing a read-only vault; triggers showSaveFilePicker (Firefox) or blob download (iOS) - After Save As, update selectedFile so the vault becomes writable going forward - Keep FAB and desktop New button visible regardless of read-only status - Enable biometric/PIN unlock on fallback (iOS) path: offer after password unlock, show button on file re-selection when enrolled - Support 'Unlock additional vault' on iOS via hidden file input fallback - Handle read-only secondary vaults with the same Save As flow - Add readonly_vault.spec.ts (10 tests); update multi_vault and unlock specs to reflect new read-only Edit/chip behaviour Co-Authored-By: Claude Sonnet 4.6 --- pwa/src/lib/Dashboard.svelte | 204 +++++++++++++++++++------ pwa/src/lib/RecordEdit.svelte | 12 +- pwa/src/lib/RecordRead.svelte | 23 ++- pwa/src/lib/StartPage.svelte | 74 +++++----- pwa/tests/multi_vault.spec.ts | 13 +- pwa/tests/readonly_vault.spec.ts | 245 +++++++++++++++++++++++++++++++ pwa/tests/unlock.spec.ts | 4 +- 7 files changed, 486 insertions(+), 89 deletions(-) create mode 100644 pwa/tests/readonly_vault.spec.ts diff --git a/pwa/src/lib/Dashboard.svelte b/pwa/src/lib/Dashboard.svelte index f6e4943..9504d94 100644 --- a/pwa/src/lib/Dashboard.svelte +++ b/pwa/src/lib/Dashboard.svelte @@ -3,6 +3,7 @@ import { get } from 'svelte/store' import { selectedFile, dbItems, secondaryVaults, toast, clipboardSession, clipboardContext, switchboardUrl, switchboardConnected, crossProfileEnabled, delegatesVersion } from '../store.js' import { + openDatabase, getRecordData, getDatabaseData, saveDatabase, getDatabaseInfo, updateRecordFields, updateDBFields, deleteRecord as wasmDeleteRecord, searchRecords, closeDatabase, loadVaultFile, @@ -21,6 +22,9 @@ let { onclosed, isPopup = false, theme, accent, isDesktop, bookmarkletsSupported = false, ontheme, onaccent, intent = null, onclearintent } = $props() + const supportsFilePicker = typeof window !== 'undefined' && typeof window.showOpenFilePicker === 'function' + const supportsSaveAs = typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function' + function focusOnMount(node) { if (passwordCount > 0) setTimeout(() => node.focus(), 0) } @@ -56,7 +60,14 @@ + $secondaryVaults.reduce((n, v) => n + new Set(v.items?.map(i => i.group).filter(Boolean)).size, 0) ) let secondaryCount = $derived($secondaryVaults.length) - let allVaultsReadonly = $derived($selectedFile?.readonly && $secondaryVaults.every(v => v.readonly)) + // True when the vault being edited/created is readonly (primary or secondary) + let editVaultReadonly = $derived( + isNew + ? !!$selectedFile?.readonly && (!newRecordVaultUuid || newRecordVaultUuid === dbKey) + : selectedVaultUuid + ? !!($secondaryVaults.find(v => v.uuid === selectedVaultUuid)?.readonly) + : !!$selectedFile?.readonly + ) // Tracked file modification timestamps — detect external changes before saving. // Kept outside $state; these are write-guards, not reactive UI state. @@ -79,6 +90,8 @@ // State for the "unlock additional vault" modal flow. // handle is kept outside $state to prevent Svelte 5 from deep-proxying the FileSystemFileHandle. let _secondaryHandle = null + let _secondaryFallbackFile = null // File object when showOpenFilePicker isn't available + let secondaryFileInputEl = $state(null) let secondarySetup = $state(null) // { password, showPw, busy, error, needsAuth, filename } let newRecordVaultUuid = $state(null) // null = primary vault @@ -786,6 +799,42 @@ editDirty = false } + // Save a secondary vault when it has no writable handle (Save As picker or iOS download). + // Updates secondaryVaults state and stored credentials on success. Throws on abort/error. + async function saveSecondaryAs(sv, data) { + if (supportsSaveAs) { + const handle = await window.showSaveFilePicker({ + suggestedName: sv.filename ?? 'vault.psafe3', + types: [{ description: 'Password Safe', accept: { 'application/octet-stream': ['.psafe3', '.dat'] } }], + }) + const w = await handle.createWritable() + await w.write(data) + await w.close() + const filename = handle.name + secondaryVaults.update(vs => vs.map(v => v.uuid === sv.uuid + ? { ...v, handle, filename, readonly: false } + : v + )) + secondaryHead[sv.uuid] = data.slice(72, 152) + try { secondaryModified[sv.uuid] = (await handle.getFile()).lastModified } catch {} + try { await addSecondaryCredential(dbKey, filename, sv.uuid, sv.masterPassword, handle) } catch {} + showToast('Saved to ' + (sv.name || filename), null, 2000) + } else { + const fname = sv.filename ?? 'vault' + const download = fname.endsWith('.psafe3') || fname.endsWith('.dat') ? fname : fname + '.psafe3' + const blob = new Blob([data], { type: 'application/octet-stream' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = download + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + showToast('Vault downloaded', null, 2000) + } + } + async function saveRecord(draft) { try { const targetVault = isNew ? (newRecordVaultUuid || dbKey) : (selectedVaultUuid || dbKey) @@ -811,7 +860,7 @@ : v )) selectedVaultUuid = targetVault - if (secondaryModified[targetVault] !== undefined) { + if (sv.handle && !sv.readonly && secondaryModified[targetVault] !== undefined) { try { const file = await sv.handle.getFile() if (file.lastModified !== secondaryModified[targetVault]) { @@ -827,16 +876,20 @@ } catch {} } const data = saveDatabase(targetVault) - const w = await sv.handle.createWritable() - await w.write(data) - await w.close() - secondaryHead[targetVault] = data.slice(72, 152) - try { secondaryModified[targetVault] = (await sv.handle.getFile()).lastModified } catch {} - showToast('Saved to ' + (sv.name || sv.filename), null, 2000) + if (sv.handle && !sv.readonly) { + const w = await sv.handle.createWritable() + await w.write(data) + await w.close() + secondaryHead[targetVault] = data.slice(72, 152) + try { secondaryModified[targetVault] = (await sv.handle.getFile()).lastModified } catch {} + showToast('Saved to ' + (sv.name || sv.filename), null, 2000) + } else { + await saveSecondaryAs(sv, data) + } } } } catch (e) { - showToast('Failed to save: ' + e.message) + if (e.name !== 'AbortError') showToast('Failed to save: ' + e.message) } } @@ -875,7 +928,7 @@ ? { ...v, items: items.map(i => ({ ...i, vaultUuid: targetVault })) } : v )) - if (secondaryModified[targetVault] !== undefined) { + if (sv.handle && !sv.readonly && secondaryModified[targetVault] !== undefined) { try { const file = await sv.handle.getFile() if (file.lastModified !== secondaryModified[targetVault]) { @@ -891,11 +944,15 @@ } catch {} } const data = saveDatabase(targetVault) - const w = await sv.handle.createWritable() - await w.write(data) - await w.close() - secondaryHead[targetVault] = data.slice(72, 152) - try { secondaryModified[targetVault] = (await sv.handle.getFile()).lastModified } catch {} + if (sv.handle && !sv.readonly) { + const w = await sv.handle.createWritable() + await w.write(data) + await w.close() + secondaryHead[targetVault] = data.slice(72, 152) + try { secondaryModified[targetVault] = (await sv.handle.getFile()).lastModified } catch {} + } else { + await saveSecondaryAs(sv, data) + } } } } catch (e) { @@ -940,16 +997,36 @@ try { const data = saveDatabase(dbKey) let handle = $selectedFile?.handle - - if (!handle) { - handle = await window.showSaveFilePicker({ - suggestedName: $selectedFile?.name ?? 'vault.psafe3', - types: [{ description: 'Password Safe', accept: { 'application/octet-stream': ['.psafe3', '.dat'] } }], - }) - selectedFile.update(s => ({ ...s, handle, name: handle.name })) + let savedAs = false + + if (!handle || $selectedFile?.readonly) { + if (supportsSaveAs) { + handle = await window.showSaveFilePicker({ + suggestedName: $selectedFile?.name ?? 'vault.psafe3', + types: [{ description: 'Password Safe', accept: { 'application/octet-stream': ['.psafe3', '.dat'] } }], + }) + savedAs = true + } else { + // No file picker (iOS): trigger a download + const fname = ($selectedFile?.name ?? 'vault') + const download = fname.endsWith('.psafe3') || fname.endsWith('.dat') ? fname : fname + '.psafe3' + const blob = new Blob([data], { type: 'application/octet-stream' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = download + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + isDirty = false + try { lastSave = getDatabaseInfo(dbKey)?.when ?? '' } catch {} + if (!silent) showToast('Vault saved') + return + } } - if (primaryModified !== null) { + if (!savedAs && primaryModified !== null) { try { const file = await handle.getFile() if (file.lastModified !== primaryModified) { @@ -969,6 +1046,9 @@ await w.close() primaryHead = data.slice(72, 152) try { primaryModified = (await handle.getFile()).lastModified } catch {} + if (savedAs) { + selectedFile.update(s => ({ ...s, handle, name: handle.name, readonly: false })) + } isDirty = false try { lastSave = getDatabaseInfo(dbKey)?.when ?? '' } catch {} if (!silent) showToast('Vault saved') @@ -1182,21 +1262,39 @@ async function unlockAdditionalVault() { sheetOpen = false - let secondaryHandle - try { - ;[secondaryHandle] = await window.showOpenFilePicker({ - types: [{ description: 'Password Safe', accept: { 'application/octet-stream': ['.psafe3', '.dat'] } }], - }) - } catch (e) { - sheetOpen = true - if (e.name !== 'AbortError') showToast('Could not open file: ' + e.message) - return + if (supportsFilePicker) { + let secondaryHandle + try { + ;[secondaryHandle] = await window.showOpenFilePicker({ + types: [{ description: 'Password Safe', accept: { 'application/octet-stream': ['.psafe3', '.dat'] } }], + }) + } catch (e) { + sheetOpen = true + if (e.name !== 'AbortError') showToast('Could not open file: ' + e.message) + return + } + _secondaryHandle = secondaryHandle + _secondaryFallbackFile = null + secondarySetup = { + filename: secondaryHandle.name, + password: '', showPw: false, busy: false, error: '', + needsAuth: await isBiometricEnrolledForFile($selectedFile?.name ?? ''), + } + } else { + secondaryFileInputEl.click() } - _secondaryHandle = secondaryHandle + } + + function onSecondaryFileInput(e) { + const file = e.target.files?.[0] + e.target.value = '' + if (!file) { sheetOpen = true; return } + _secondaryHandle = null + _secondaryFallbackFile = file secondarySetup = { - filename: secondaryHandle.name, + filename: file.name, password: '', showPw: false, busy: false, error: '', - needsAuth: await isBiometricEnrolledForFile($selectedFile?.name ?? ''), + needsAuth: false, } } @@ -1216,7 +1314,13 @@ } try { - const secondaryUuid = await loadVaultFile(_secondaryHandle, secondarySetup.password) + let secondaryUuid + if (_secondaryHandle) { + secondaryUuid = await loadVaultFile(_secondaryHandle, secondarySetup.password) + } else { + const buf = await _secondaryFallbackFile.arrayBuffer() + secondaryUuid = openDatabase(new Uint8Array(buf), secondarySetup.password) + } if (secondaryUuid === dbKey) { closeDatabase(secondaryUuid) @@ -1235,7 +1339,9 @@ const info = getDatabaseInfo(secondaryUuid) const items = getDatabaseData(secondaryUuid) let readonly = true - try { const w = await _secondaryHandle.createWritable(); await w.abort(); readonly = false } catch {} + if (_secondaryHandle) { + try { const w = await _secondaryHandle.createWritable(); await w.abort(); readonly = false } catch {} + } await addSecondaryCredential(dbKey, secondarySetup.filename, secondaryUuid, secondarySetup.password, _secondaryHandle) @@ -1251,7 +1357,10 @@ masterPassword: secondarySetup.password, }] }) - _secondaryHandle.getFile().then(f => { secondaryModified[secondaryUuid] = f.lastModified }).catch(() => {}) + if (_secondaryHandle) { + _secondaryHandle.getFile().then(f => { secondaryModified[secondaryUuid] = f.lastModified }).catch(() => {}) + } + _secondaryFallbackFile = null secondarySetup = null sheetOpen = true } catch (e) { @@ -1491,20 +1600,21 @@ - - {#if !allVaultsReadonly} - - {/if} + + - {#if isDesktop && !allVaultsReadonly} + {#if isDesktop} {/if} + + + {#if showHelp} @@ -1571,6 +1681,7 @@ {hasDelegates} vaultUuid={isNew ? (newRecordVaultUuid || dbKey) : (selectedVaultUuid || dbKey)} {rwVaults} + vaultReadonly={editVaultReadonly} onvaultchange={(uuid) => newRecordVaultUuid = uuid} oncancel={cancelEdit} onsave={saveRecord} @@ -1587,7 +1698,8 @@ {hasDelegates} vaultUuid={selectedVaultUuid || dbKey} onback={() => { record = null; selectedUUID = null; selectedVaultUuid = null }} - onedit={($secondaryVaults.find(v => v.uuid === selectedVaultUuid)?.readonly ?? $selectedFile?.readonly) ? null : startEdit} + onedit={startEdit} + vaultReadonly={selectedVaultUuid ? !!$secondaryVaults.find(v => v.uuid === selectedVaultUuid)?.readonly : !!$selectedFile?.readonly} oncopy={copyToClipboard} oncopytotp={copyTOTPForUUID} onwasmcopyfield={copyFieldViaWasm} diff --git a/pwa/src/lib/RecordEdit.svelte b/pwa/src/lib/RecordEdit.svelte index 21fc60a..97cb1dc 100644 --- a/pwa/src/lib/RecordEdit.svelte +++ b/pwa/src/lib/RecordEdit.svelte @@ -25,7 +25,7 @@ return { secret: secret.toUpperCase().replace(/[\s-]/g, ''), digits, period } } - let { record, isNew, isDesktop, bookmarkletsSupported = false, hasDelegates = false, vaultUuid, rwVaults = [], onvaultchange, oncancel, onsave, ondelete, ondirtychange } = $props() + let { record, isNew, isDesktop, bookmarkletsSupported = false, hasDelegates = false, vaultUuid, rwVaults = [], vaultReadonly = false, onvaultchange, oncancel, onsave, ondelete, ondirtychange } = $props() let vaultDropOpen = $state(false) @@ -397,7 +397,7 @@
{isNew ? 'New' : 'Edit'}
- +
{#if isDesktop} @@ -406,7 +406,7 @@
+ style="height:36px;padding:0 14px;font-size:14px">{vaultReadonly ? 'Save as' : 'Save'}
{/if} @@ -821,6 +821,7 @@ Delete {draft.Title} + {#if vaultReadonly}
Saves a copy of the vault with this entry removed
{/if} {/if} @@ -1021,6 +1022,11 @@ opacity: 0.75; } .btn-delete:hover { opacity: 1; } + .delete-ro-note { + font-size: 12px; + margin-top: 4px; + padding-left: 2px; + } /* --- Autofill sequence header row --- */ .autotype-label-group { diff --git a/pwa/src/lib/RecordRead.svelte b/pwa/src/lib/RecordRead.svelte index 5cb78e2..5203418 100644 --- a/pwa/src/lib/RecordRead.svelte +++ b/pwa/src/lib/RecordRead.svelte @@ -5,7 +5,7 @@ import { getTOTP, getFieldValue, getCustomFieldValue } from '../wasm.js' import Icon from './Icon.svelte' - let { record, uuid, isDesktop, bookmarkletsSupported = false, hasDelegates = false, vaultUuid, onback, onedit, oncopy, oncopytotp, + let { record, uuid, isDesktop, bookmarkletsSupported = false, hasDelegates = false, vaultUuid, onback, onedit, vaultReadonly = false, oncopy, oncopytotp, onwasmcopyfield, onwasmcopycustomfield } = $props() let revealed = $state(false) @@ -319,7 +319,10 @@
{record.Group ?? ''}
- +
+ + {#if vaultReadonly}read-only{/if} +
@@ -328,6 +331,7 @@ {record.Group ?? ''} {#if onedit}
+ {#if vaultReadonly}read-only{/if}
{/if} @@ -603,6 +607,21 @@