diff --git a/.gitignore b/.gitignore
index 4886018..48bdce7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,10 @@ test-results/
design-wip/
CLAUDE.md
AUTOFILL.md
+AUTOFILL-RESEARCH.md
+AUTOCOPY.md
LAZYSENSITIVE.md
SUGGESTIONS.md
.claude/
+/wasm
+skills/
diff --git a/Makefile b/Makefile
index 172ebe5..d2d5455 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,7 @@ wasm_exec:
cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" ./pwa/public/
setup: wasm_exec
- cd pwa && npm install
+ cd pwa && npm ci
build: wasm wasm_exec
cd pwa && npm run build
diff --git a/README.md b/README.md
index ca5f723..b5391ec 100644
--- a/README.md
+++ b/README.md
@@ -1,42 +1,60 @@
# Portpass
-*A simple password manager that keeps your data in your control, across all your devices.*
+*Open source password manager. One file, every device*
**Free and open source. Try it: [https://dbro.github.io/portpass](https://dbro.github.io/portpass)**
-Portpass is for people who want full control over their passwords, in an app that works great on mobile devices and is built with solid encryption methods. Hosted services like 1Password and LastPass are polished, but require trusting a startup with your most sensitive data.
+Portpass is for people who want full control of their passwords. Portpass is a password manager app that runs on mobile and desktop devices, storing the encrypted information in a single file using the pwsafe v3 format invented by cryptographer [Bruce Schneier](https://www.schneier.com/) in the 1990s, which is open source and audited.
-Portpass is different: your passwords live in a file on your device, or in a cloud storage service you already trust. The encrypted vault is stored as a pwsafe v3 file, using the method invented by cryptographer [Bruce Schneier](https://www.schneier.com/) in the 1990s, open-sourced and audited for decades. No browser extensions, no proprietary sync, no new crypto to evaluate.
+_No cloud required, no browser extensions required, no proprietary synchronization methods, no new encryption methods._
+
+You decide where to store your vault file: on-device, self-hosted, or in a cloud storage service that you trust. You can allow other people to read or write to your vault files using cloud service file sharing settings. You can open your password vault file with any app that supports the pwsafe v3 format. You can enable your web browser to fill-in website login forms with usernames and passwords from your vault.
## What Portpass does
-* streamlines login to apps and websites
-* works fully offline, no internet connection required
-* encrypts your vault using an established open source format (pwsafe v3)
+* works fully offline, no network connection required after initial installation. Can also work with cloud-hosted files if you choose.
* runs on all your devices: mobile, tablet, and desktop
-* supports convenient WebAuthn unlock methods: fingerprint, face recognition, and PIN
+* stores each vault as a file on your device, for easy sync/backup/sharing
+* unlocks vault files using WebAuthn methods: fingerprint, face recognition, and PIN
+* fills login forms on websites using a bookmarklet in two clicks (desktop only). No browser extension with excessive permissions, no copying secrets to the system clipboard
* generates strong passwords
-* organizes password records into groups for browsing
+* keeps a history of previous password values
* generates one-time codes (TOTP) for two-factor authentication (2FA)
-* supports custom fields (eg. PIN codes, account numbers, API keys)
-* stores your vault as a file on your device, for easy sync/backup
-* opens multiple vaults simultaneously (eg. personal, work, family), allowing sharing settings for each vault
-* supports read-only access to each vault according to file's permission settings
+* supports custom fields (eg. credit card numbers, PIN codes, account numbers, API keys)
+* searches instantly across multiple vaults and multiple data fields
+* organizes password records into groups for browsing
+* encrypts your vault using an established open source format (pwsafe v3)
+* opens multiple vaults simultaneously (eg. personal, work, family), supports sharing vaults with other people
+* respects read-only file permissions for each vault
* has a mobile-first design with both light and dark modes
## Installation
-Portpass runs in your browser and can be installed as an app on any device. Installation involves visiting a web page and then telling your browser to create an app icon (like a bookmark) on your homescreen. Safari and Chrome browsers support installing as standalone webpage apps like this, and maybe some other browsers do too. There is no app store involved, and the process is the same on mobile and desktop.
+Portpass runs in a browser (Chrome, Safari, Firefox, Edge) and can be installed as anapp on your device. This style of installing a web page as an app icon is called a "Progressive Web App" or PWA. Installation involves visiting a web page and then telling your browser to create an app icon (like a bookmark) on your homescreen. This caches the Portpass code locally, and Portpass does not communicate with any remote servers. Safari, Chrome, and Edge browsers support installing as standalone webpage apps like this. There is no app store involved, and the process is the same on mobile and desktop.
* Open [https://dbro.github.io/portpass/](https://dbro.github.io/portpass/) in your browser
* When prompted, tap "Add to Home Screen" (iOS/Android) or "Install" (desktop)
-* You might also want to pin the app to your app launcher dashboard on your desktop
+* You can pin the app to your operating system's launcher dashboard
+
+Portpass will then be visible as a standalone app and can be launched with a tap. It works offline and uses your local vault file. Portpass NEVER sends your vault file or your master password anywhere, they remain on your device and in your control. Your vault file is in your control and you can choose how to share it and copy it.
+
+For improved security, install Portpass in a [dedicated browser profile with no extensions](SECURITY.md#mitigation-use-a-dedicated-browser-profile). This protects against malicious browser extensions that may be running in your web browser's primary profile.
+
+**Caveat: Firefox** browsers do not support PWA and do not support opening a file in read+write mode, only in read-only mode. This means you should not use Firefox to install Portpass, and you cannot edit Portpass vaults that are opened with Firefox. You can access Portpass vault information while using Firefox to browse the web, using the normal copy+paste and autofill methods to get passwords from the Portpass vault into a browser setting. In this scenario, Portpass runs in a different browser such as Chrome or Safari.
+
+**Caveat: iOS** does not allow overwriting files. When running on iOS devices, Portpass opens all vaults in read-only mode. If you modify the vault data and then save it, the file will get a new name (eg. "My vault(1).psafe3").
+
+## Choosing the right level of Security and Convenience
+
+Basic operation of Portpass involves opening local vault files where you can store and retrieve your login names and passwords, and other sensitive information. If you want, you can run Portpass in a separate browser profile to isolate it from any potentially malicious browser extensions installed in your main browser profile.
+
+If you are comfortable using the system clipboard, Portpass enables you to copy+paste information from your vault into the websites and apps that need them. Note that on Windows systems, the clipboard is readable by all applications, presenting a risk of a malicious app eavesdropping on the clipboard contents.
-Portpass will then be visible as a standalone app and can be launched with a tap. It works offline and uses your local vault file.
+When running on desktop/laptop systems, Portpass supports automatic insertion of field values into web page DOM elements. This can be a more convenient way to log in to websites when using a browser profile that you trust to not have malicious browser extensions running.
-For improved security, install Portpass in a [dedicated browser profile with no extensions](SECURITY.md#mitigation-use-a-dedicated-browser-profile).
+You can read and write vault files stored in cloud storage. A file stored in the cloud can be accessed from multiple devices, and in some cases can be configured (using the cloud storage provider's settings) to have automatic versioning and backup capabilities.
## Cross-platform + how to sync
@@ -48,26 +66,28 @@ Because your vault is a regular file, syncing across devices is straightforward
Portpass can open multiple vault files at the same time. This is believed to be unique among Password Safe-compatible apps. All open vaults appear together in a single merged list, grouped by vault, with a unified search across all of them.
-**How secondary vaults work**
+Portpass checks each vault file if it is read-only according to the file system. Read-only vaults are clearly labelled; their records appear normally in the list and search results but cannot be edited.
-After opening a vault, tap the vault name in the top bar to open vault settings, then tap **Unlock additional vault**. Pick another vault file, enter its master password, and Portpass remembers it as a secondary vault of the original, primary vault you opened. On future sessions, secondary vaults unlock automatically when you open the same original vault. One biometric tap or master password entry unlocks all of these vaults at once.
+**How secondary vaults work**
-Each secondary vault can be read+write (you can add, edit, and delete its records) or read-only (if the file's permissions prevent writing). Read-only vaults are clearly labelled; their records appear normally in the list and search results but cannot be edited.
+To open more vaults, tap the vault name in the top bar to open vault settings, then tap **Unlock additional vault**. Pick another vault file, enter its master password, and Portpass remembers it as a secondary vault of the original, primary vault you opened. On future sessions, secondary vaults unlock automatically when you open the same original vault. One biometric tap or master password entry unlocks all of these vaults at once.
**Sharing passwords with a team or family**
-Because vault files are just files, you can share them using the same cloud storage services you already use for file sharing:
+Vault files can be shared just like any other regular file using file system and cloud storage settings. For example:
-1. Create a vault containing the shared passwords (team credentials, family Wi-Fi, subscriptions, etc.).
-2. Place the vault file in a shared folder: a Dropbox shared folder, an iCloud shared album, a Google Drive shared drive, a NAS share, or any similar service.
-3. Give the people you want to share with access to that folder using the cloud service's own sharing permissions. Grant read+write access to people who should be able to add or change shared passwords, and read-only access to everyone else.
-4. Each person opens Portpass on their own device, unlocks their personal vault, and adds the shared vault as a secondary vault.
+1. Create a vault containing the passwords you want to share (team credentials, family Wi-Fi passwords, sharable subscriptions, etc.)
+2. Move the vault file to a shared folder in a cloud service such as a Dropbox, iCloud, Google Drive, or a local NAS share, or similar.
+3. Use the cloud service (or NAS device) settings to grant permission to each person to access the file, which can be read-only if desired
+4. Each person runs Portpass (or any app that can read a pwsafev3 vault file) on their own device, and can unlock one or more vault files, including the vault file you shared with them.
From that point on, the shared vault opens automatically alongside each person's personal vault. Adding new records to the shared vault or editing existing ones writes the changes back to the shared file, where they propagate to everyone else via normal cloud sync.
-**Sync conflicts are not reconciled automatically**
+**Sync conflicts are automatically detected**
+
+Two people editing the shared vault at exactly the same time leads to a situation where neither version of the file is the "most current". Portpass detects the potential conflict and asks for confirmation before overwriting the conflicting version of the file. To reduce the chance of colliding edits, select one person to have read+write access and everyone else to have read-only access to each vault file. Note that Portpass auto-saves changes immediately, but does NOT automatically reload if the underlying file has been changed since it was first opened.
-Two people editing the shared vault at exactly the same time may produce a sync conflict in the cloud service (the same limitation that applies to any shared file). Portpass does not merge conflicts; if that happens, use the cloud service's version history to recover the version you want. For most teams and families this is rarely a problem in practice. To reduce the chance of colliding edits, select one person to have read+write access and everyone else to have read-only access to each vault file.
+Tip: check if your cloud storage service supports file versioning and rollback, which can be useful in password management recovery and auditing scenarios.
## Compatibility & no vendor lock-in
@@ -75,26 +95,26 @@ Portpass reads and writes the [Password Safe v3](https://github.com/pwsafe/pwsaf
## Compared to Password Safe
-[Password Safe](https://pwsafe.org/) is the original app for this file format, available as a native desktop app for Windows and Linux. Portpass and Password Safe share the same vault format, so your data is never locked in.
+[Password Safe](https://pwsafe.org/) is the original and official app for pwsafe v3 vault files. It is available as a native desktop app for Windows and Linux. Portpass and Password Safe share the same vault format, so your data is never locked in.
**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 autofills into desktop browsers)
- Automatic vault lock after an idle timeout
- Password strength indicator and breach alerts
-- Password entry aliases (re-using a password across multiple sites)
+- Password entry aliases (re-using a password across multiple entries)
- Passphrase generation (diceware / word lists)
-- Multiple password generation policies
+- Multiple password generation policies (Portpass uses the same adjustable policy for all vaults and entries)
- File attachments and passkeys stored in the vault
-- Export and import in other formats
+- Export and import in other vault file formats
- SSH agent integration
- Automatic file version backups
-- Adjustable unlock difficulty count
+- Adjustable unlock difficulty (key stretching iteration count)
**What Portpass offers that Password Safe does not:**
-- Runs in any modern browser — no installation required
-- Works on mobile (iOS, Android) with a touch-friendly interface
+- Runs on mobile, desktop, and tablet devices
+- Modern mobile-first design with 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)
- Opens multiple vault files simultaneously, especially useful for sharing passwords
- Light/dark themes with selectable accent colors
@@ -108,6 +128,88 @@ 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 to simplify your login experiences on desktop websites. This feature is only available on desktop browsers, and the settings are not visible when using a mobile device. It works using a bookmarklet that Portpass creates for you, and it avoids copying passwords into the clipboard where malicious apps could try to eavesdrop. The streamlined process involves **two clicks** and never leaves the browser window, and can handle situations with multiple URL matches and fuzzy matching.
+
+1. Visit a webpage with a login form you want to fill in
+2. (optional) **Click on the first field** you want filled (e.g. username)
+3. **Click the bookmarklet** in your browser's bookmarks bar
+
+Portpass finds matching vault entries by URL, lets you pick one if there are multiple matches, and fills the form fields following the record's Autofill sequence.
+
+
+
+### How Autofill works
+
+A `javascript:` bookmarklet in your browser's bookmarks bar opens a small picker popup when you click it on a login page. The popup shows credentials that match the current page's URL. Click a record and Portpass fills the fields directly, following the record's Autofill sequence setting (default: fill username → Tab → fill password → Submit).
+
+The bookmarklet communicates with your open Portpass vault over an encrypted channel. No credentials pass through the clipboard at any point — this matters on Windows and Linux, where clipboard contents can be read by any running process, and in browsers where extensions with clipboard permission could read a copied password before it is pasted. The encryption key is unique to each new bookmarklet created in Portpass, and can be revoked from Portpass's vault settings.
+
+Portpass searches all unlocked vaults for URLs that match the current web page. It compares the canonical version, removing "www." as well as url parameters after the "?" and "#" characters. It looks for exact matches first, then falls back to offering the current open record (if one is open) as well as up to 5 near matches. If one of the non-exact matches is chosen, you can instruct Portpass to update that entry's URL in the vault to match the current webpage URL to accelerate future Autofill requests on this webpage. The "near match" method uses edit distance (Levenshtein) showing the five closest matches within a distance of 5 edits.
+
+### 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 the [switchboard](https://github.com/dbro/switchboard) as a background service on your machine before using the bookmarklet. See the repo README for instructions to run switchboard automatically in the background.
+
+
+
+### Autofill form field configuration
+
+Each entry in Portpass has an optional field called **Autofill sequence** that describes what and where to fill in the login form. It is based on keyboard actions, which most web login forms support natively. The visual representation shows each action as a separate unit:
+
+
+
+The text representation is also possible, and is easier to document here. The default `\u\t\p\n` covers most sites and means: fill username, tab to the next field, fill password, press enter to 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) |
+| `\fN` | Nth custom field (N is between 1 and 9) |
+| `\t` | Tab to next field |
+| `\s` | Shift-Tab (previous field) |
+| `\n` | Submit form |
+| `\wNNN` | Wait NNN milliseconds |
+| `\WNNN` | Wait NNN seconds |
+
+| Example | Actions |
+|---|---|
+| `\p\n` | fill password, submit form |
+| `\u\n\W5\2` | fill username, submit form, wait 5 seconds, fill one-time code |
+| `\f1\t\f2\t\f3` | fill custom field #1 (eg credit card number), tab, fill custom field #2 (eg. expiration date), tab, fill custom field #3 (eg. CVN number) |
+
+### Best practices with Autofill
+
+- **Use a unique bookmarklet for each browser profile.** Each bookmarklet holds a unique private key. Create a separate bookmarklet for each browser and profile where you want autofill, and give each a descriptive name so you can revoke individual ones if needed.
+- **Revoke bookmarklets you no longer use.** Open vault settings → Autofill, and click **Revoke** next to any entry you want to invalidate. The corresponding bookmarklet will be rejected immediately, even if it is still in someone's bookmarks bar.
+- **Prefer autofill over copy-paste on Windows and Linux (X11).** On these platforms, any running process can read the clipboard at any time. Autofill writes directly to the form field without ever putting the credential in the clipboard, eliminating that exposure window entirely. (Linux Wayland has better clipboard security than X11.)
+
+See [SECURITY.md](SECURITY.md) for a full description of how the delegate model guards against malicious extensions, clipboard eavesdropping, and other threats.
+
+### Differences from Official Password Safe app Autotype
+
+The official desktop Password Safe app has a function called "Autotype" that can insert keystrokes into other apps. Portpass uses the browser's javascript to inject values directly into the DOM. Portpass adds a new code for custom fields (\fN) which the official Password Safe app does not support.
+
+### Same-profile and cross-profile autofill
+
+It is possible to autofill while running Portpass in a separate clean profile, following the security best-practice to reduce exposure to browser extensions -- however, it requires a helper switchboard running in its own process on your system. It is also possible to run Portpass in a different browser (eg. Chrome) and use autofill in another browser (eg. Firefox).
+
+**Same-profile**: Portpass and the pages you fill are in the same browser profile. The bookmarklet opens a relay popup that talks to Portpass directly via a browser-internal channel. No extra software needed. This is the simpler approach, but it means that all your browser extensions could try to attack Portpass. If you trust your browser extensions, this is ok.
+
+**Cross-profile**: To protect against malicious browser extensions, you could choose to run Portpass in a separate browser profile with no extensions installed. This means that the browser has stronger isolation between Portpass and the websites that you visit, and the browser prevents the bookmarklet from communicating with Portpass to transfer information from your vault to the bookmarklet. In this scenario, a helper service provides a simple message switchboard between Portpass and the bookmarklet. This service is called **[switchboard](https://github.com/dbro/switchboard)**, and it runs in the background acting as a very limited shared memory. No data leaves your machine. All messages sent between Portpass and the bookmarklet via the switchboard are encrypted end-to-end using the key that is set up when the bookmarklet is installed. An eavesdropper would see only encrypted blobs.
+
+See [SECURITY.md](SECURITY.md) for setup instructions.
+
+Note that while Portpass should run in Chrome or Safari, the bookmarklet can run in Chrome, Safari, and Firefox. More than one bookmarklet can be created and used by Portpass, allowing fine-grained control for people who use multiple browsers and profiles.
+
## Security
Portpass's threat model, known limitations, and guidance on protecting yourself from malicious browser extensions are documented in [SECURITY.md](SECURITY.md).
diff --git a/SECURITY.md b/SECURITY.md
index c91795e..c5dfaba 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -34,6 +34,28 @@ An extension with broad host permissions runs in the same process as every web p
This applies to every browser-based password manager and it is not a flaw specific to Portpass. It is a fundamental property of how browser extensions work.
+### The supply-chain / auto-update risk — including Portpass itself
+
+Extensions update silently and automatically. An extension that was safe when you installed it can be bought, transferred to a new owner, or hacked, and a subsequent update can make it malicious — without any visible change and without any action on your part. This attack has occurred with popular, widely-reviewed Chrome extensions. Even careful upfront vetting only tells you the extension was safe at that moment; you are extending ongoing trust in the publisher and in anyone who might later acquire them.
+
+**Portpass is subject to the same risk.** It is a Progressive Web App served from GitHub Pages and it updates automatically when a new version is deployed. A compromised update — whether through the GitHub repository, the build pipeline, or a dependency in the npm or Go module graph — would run in the same browser context as your vault. The mitigations are real: Portpass is open-source and every change is publicly auditable; releases are tagged and deployed by the project maintainer; the encryption core is compiled from Go source whose history is visible. But these reduce risk rather than eliminating it. If you need higher assurance, treat Portpass the same way you would any critical piece of software: review updates before they apply, or use a dedicated profile that you update intentionally.
+
+### How to audit extensions in your regular profile
+
+Not all extensions are equally relevant to your vault. An extension that only has access to `reddit.com` cannot run on the Portpass page and cannot see your vault or your master password — it is simply not in scope. Only extensions with broad host permissions or specific access to the Portpass URL (`dbro.github.io`) matter for vault security.
+
+**In Chrome:** open `chrome://extensions`, click **Details** on each extension, then scroll to **Permissions**. An extension that says "Read and change all your data on all websites" (or similar) can run on the Portpass page. An extension with a narrower list of sites can only run on those sites.
+
+**In Firefox:** open `about:addons`, click an extension, then the **Permissions** tab. "Access your data for all websites" is the broad permission; narrower extensions will list specific domains.
+
+Neither browser provides a consolidated view — you must check each extension individually.
+
+Once you have identified which extensions have broad access, consider two distinct surfaces:
+
+1. **Extensions that can run on the Portpass URL** (`dbro.github.io`): highest impact. They can observe your master password as you type it, read any revealed password in the UI, and — during the brief window when the "New bookmarklet" modal is open — read the delegate private key from the chip's link attribute. If any extension you cannot fully trust has this access, use a dedicated profile for Portpass instead.
+
+2. **Extensions that can run on login pages**: they can read form field values after autofill. This is the same exposure that exists with every password manager, including native apps — an extension that runs on `bank.com` can read whatever was typed or filled into that page. Audit extensions on your sensitive login pages separately from the Portpass audit.
+
### Mitigation: use a dedicated browser profile
The most effective defense is to use Portpass in a separate browser profile that has no extensions installed.
@@ -56,6 +78,14 @@ 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.** 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 via [switchboard](https://github.com/dbro/switchboard), a tiny local WebSocket broker (`localhost:7577`). This preserves full extension isolation — Portpass never touches the browsing profile. Requires switchboard to be running as a background service.
+
+Both modes use the **delegate model**: each bookmarklet installation holds an ECDSA P-256 private key; the corresponding public key is registered in Portpass. Every autofill request is signed with the private key and verified by Portpass before any credentials are exchanged. This means a malicious extension on the page cannot impersonate a legitimate bookmarklet and trick Portpass into delivering credentials to an attacker's key.
+
---
## Vault file security
@@ -89,16 +119,66 @@ 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.
---
+## 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
+
+The switchboard (`localhost:7577`) is a dumb pipe — it stores and forwards encrypted blobs without inspecting content. It binds to `127.0.0.1` only and is not accessible over the network. An attacker who can read the relay server's memory has OS-level access and is outside the threat model.
+
+### What autofill does not protect against
+
+- **Credential in the DOM**: after filling, the credential is in `input.value` and readable by any extension on the page. This is identical to manual typing or any other password manager and cannot be avoided without browser-level APIs.
+- **Delegate private key in the bookmark store**: the bookmark store is stored as a plaintext JSON file in the browser profile directory. Any process that can read that directory — including backup software, another user account with filesystem access, or malware with user-level permissions — can extract the private key. The key persists indefinitely, so an exfiltrated copy remains valid until the delegate is explicitly revoked in Portpass.
+
+ However, the key is an *authentication token*, not an encryption key. It does not contain or unlock any vault data on its own. An attacker who holds the key can only use it by making a signed request to a running, unlocked Portpass instance — and only via BroadcastChannel (same browser profile) or the local switchboard WebSocket (same machine). Both channels require the attacker to be active on the same machine at the same moment the user has Portpass open. At that point the attacker already has access to more direct attacks.
+
+ This makes the key fundamentally different from a password captured off the clipboard: a clipboard password is immediately and permanently usable anywhere; the delegate key requires an ongoing session to exploit and becomes worthless the moment the vault is locked.
+
+ The same property that protects the key — the bookmark store is inaccessible to web pages — also means Portpass cannot rotate it automatically. Periodic manual rotation (revoke the old delegate in VaultSheet, drag a new bookmarklet) is good hygiene, especially if you suspect a device may have been accessed by someone else. A future VaultSheet version may display key age to prompt rotation.
+- **Extension present at drag-install time**: an extension running in the **Portpass profile** (the clean profile where the bookmarklet is dragged *from*) could modify the bookmarklet's JavaScript or substitute a different key before the drag completes. This is why the clean profile must have no extensions — not just to protect the vault, but to ensure the bookmarklet delivered to the bookmarks bar is genuine. Extensions in the *destination* browsing profile cannot intercept the bookmarklet because it arrives already stored in the bookmark store, which extensions cannot read.
+
+---
+
## Implementation notes
- **Memory**: the salted password buffer used during key stretching is zeroed immediately after use.
diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go
index a3042f4..f78fb86 100644
--- a/cmd/wasm/main.go
+++ b/cmd/wasm/main.go
@@ -58,7 +58,9 @@ func openDB(this js.Value, args []js.Value) interface{} {
}
uuid := vaultUUID(newDB)
- databases[uuid] = newDB
+ if _, exists := databases[uuid]; !exists {
+ databases[uuid] = newDB
+ }
result, _ := json.Marshal(map[string]string{"uuid": uuid})
return string(result)
@@ -95,6 +97,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 +107,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,
})
}
@@ -128,6 +132,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"`
@@ -159,7 +164,7 @@ func sensitiveString(s string) *string {
func recordToView(rec pwsafe.Record) recordView {
mt := ""
if !rec.ModTime.IsZero() {
- mt = rec.ModTime.Format("2006-01-02")
+ mt = rec.ModTime.UTC().Format(time.RFC3339)
}
cfViews := make([]customFieldView, len(rec.CustomFields))
@@ -185,6 +190,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),
@@ -232,21 +238,8 @@ func getDBInfo(this js.Value, args []js.Value) interface{} {
Iter uint32 `json:"iter"`
}
- versionMap := map[uint16]string{
- 0x0300: "3.01", 0x0301: "3.03", 0x0302: "3.09", 0x0303: "3.12",
- 0x0304: "3.13", 0x0305: "3.14", 0x0306: "3.19", 0x0307: "3.22",
- 0x0308: "3.25", 0x0309: "3.26", 0x030A: "3.28", 0x030B: "3.29",
- 0x030C: "3.29", 0x030D: "3.30", 0x030E: "3.47", 0x030F: "3.68",
- 0x0310: "3.69",
- }
-
versionVal := binary.LittleEndian.Uint16(db.Header.Version[:])
- versionStr := versionMap[versionVal]
- if versionStr == "" {
- versionStr = fmt.Sprintf("Format 0x%04x", versionVal)
- } else {
- versionStr = "v" + versionStr
- }
+ versionStr := fmt.Sprintf("0x%04X", versionVal)
info := DBInfo{
Version: versionStr,
@@ -306,6 +299,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
@@ -354,13 +349,14 @@ func updateRecordFields(this js.Value, args []js.Value) interface{} {
cfs[i] = pwsafe.CustomField{Name: inp.Name, Sensitive: inp.Sensitive}
if inp.Value != nil {
cfs[i].Value = *inp.Value
- } else {
- // null = preserve existing value for this field name
- for _, ex := range rec.CustomFields {
- if ex.Name == inp.Name {
+ }
+ for _, ex := range rec.CustomFields {
+ if ex.Name == inp.Name {
+ if inp.Value == nil {
cfs[i].Value = ex.Value
- break
}
+ cfs[i].UnknownProps = ex.UnknownProps
+ break
}
}
}
@@ -439,11 +435,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/index.html b/pwa/index.html
index ae4bd3c..9c3f723 100644
--- a/pwa/index.html
+++ b/pwa/index.html
@@ -7,7 +7,7 @@
-
+
Portpass
diff --git a/pwa/package-lock.json b/pwa/package-lock.json
index 6d46324..088c20d 100644
--- a/pwa/package-lock.json
+++ b/pwa/package-lock.json
@@ -15,6 +15,7 @@
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/node": "^25.0.2",
"svelte": "^5.53.6",
+ "terser": "^5.48.0",
"vite": "^7.3.2",
"vite-plugin-pwa": "^1.2.0"
}
@@ -2422,9 +2423,9 @@
]
},
"node_modules/@sveltejs/acorn-typescript": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
- "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
+ "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==",
"dev": true,
"peerDependencies": {
"acorn": "^8.9.0"
@@ -2987,9 +2988,9 @@
}
},
"node_modules/devalue": {
- "version": "5.8.0",
- "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.0.tgz",
- "integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==",
+ "version": "5.8.1",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
+ "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"dev": true
},
"node_modules/dunder-proto": {
@@ -3214,9 +3215,9 @@
"dev": true
},
"node_modules/esrap": {
- "version": "2.2.7",
- "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.7.tgz",
- "integrity": "sha512-Dl7o7btn2YXca1VXx+PVl+lKuZdHBm8oCFuckUxqchMvNMdHMJ/qF31wtPaVyWvFYLQePkbXJrirWzbAP6Yamw==",
+ "version": "2.2.9",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz",
+ "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==",
"dev": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -5064,23 +5065,23 @@
}
},
"node_modules/svelte": {
- "version": "5.55.5",
- "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
- "integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==",
+ "version": "5.55.9",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz",
+ "integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
"dev": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
- "@sveltejs/acorn-typescript": "^1.0.5",
+ "@sveltejs/acorn-typescript": "^1.0.10",
"@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
- "devalue": "^5.6.4",
+ "devalue": "^5.8.1",
"esm-env": "^1.2.1",
- "esrap": "^2.2.4",
+ "esrap": "^2.2.9",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
@@ -5118,9 +5119,9 @@
}
},
"node_modules/terser": {
- "version": "5.47.1",
- "resolved": "https://registry.npmjs.org/terser/-/terser-5.47.1.tgz",
- "integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==",
+ "version": "5.48.0",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz",
+ "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==",
"dev": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
diff --git a/pwa/package.json b/pwa/package.json
index 9ab7d75..9eb0a6e 100644
--- a/pwa/package.json
+++ b/pwa/package.json
@@ -14,6 +14,7 @@
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/node": "^25.0.2",
"svelte": "^5.53.6",
+ "terser": "^5.48.0",
"vite": "^7.3.2",
"vite-plugin-pwa": "^1.2.0"
},
diff --git a/pwa/public/autofill.html b/pwa/public/autofill.html
new file mode 100644
index 0000000..778496b
--- /dev/null
+++ b/pwa/public/autofill.html
@@ -0,0 +1,1445 @@
+
+
+
+
+
+
+Portpass autofill
+
+
+
+
+
+
+
+
+
+
+
+
+
Connecting to Portpass…
+
+
+
+
+
diff --git a/pwa/src/App.svelte b/pwa/src/App.svelte
index ee4e9b6..4042d0b 100644
--- a/pwa/src/App.svelte
+++ b/pwa/src/App.svelte
@@ -1,5 +1,8 @@
diff --git a/pwa/src/lib/RecordEdit.svelte b/pwa/src/lib/RecordEdit.svelte
index 7a85bad..97cb1dc 100644
--- a/pwa/src/lib/RecordEdit.svelte
+++ b/pwa/src/lib/RecordEdit.svelte
@@ -5,28 +5,6 @@
import { generatePassword, loadOpts } from './passwordgen.js'
import { getAutocompleteSuggestion, getFieldValue, getCustomFieldValue } from '../wasm.js'
- // --- TOTP helpers ---
- const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
-
- function base32Encode(bytes) {
- let bits = '', out = ''
- for (const b of bytes) bits += b.toString(2).padStart(8, '0')
- for (let i = 0; i + 5 <= bits.length; i += 5) out += B32[parseInt(bits.slice(i, i + 5), 2)]
- const rem = bits.length % 5
- if (rem > 0) out += B32[parseInt(bits.slice(-rem).padEnd(5, '0'), 2)]
- return out
- }
-
- function base64ToBase32(b64) {
- if (!b64) return ''
- try {
- const bin = atob(b64)
- const bytes = new Uint8Array(bin.length)
- for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
- return base32Encode(bytes)
- } catch { return '' }
- }
-
function parseOtpAuthUri(uri) {
// Use regex rather than new URL() — custom protocols are unreliable across browsers
const m = uri.match(/^otpauth:\/\/([^/?#]+)(?:\/[^?#]*)?(?:\?(.*))?$/i)
@@ -47,7 +25,7 @@
return { secret: secret.toUpperCase().replace(/[\s-]/g, ''), digits, period }
}
- let { record, isNew, isDesktop, 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)
@@ -62,18 +40,19 @@
// 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 ?? '')))
+ let totpSecret = $state('')
let totpDigits = $state(untrack(() => record?.TOTPLength || 6))
let totpPeriod = $state(untrack(() => record?.TOTPTimeStep || 30))
- // Custom fields — independent editable copy of initial prop value
- let customFields = $state(untrack(() => (record?.CustomFields ?? []).slice(0, 9).map(cf => ({ Name: cf.Name, Value: cf.Value, Sensitive: !!cf.Sensitive }))))
+ // Custom fields — independent editable copy of initial prop value (first 9 only; overflow preserved on save)
+ let customFields = $state(untrack(() => (record?.CustomFields ?? []).slice(0, 9).map(cf => ({ Name: cf.Name, Value: cf.Value, Sensitive: !!cf.Sensitive }))))
+ let overflowFields = untrack(() => (record?.CustomFields ?? []).slice(9))
let totpGearOpen = $state(false)
let totpRevealed = $state(false)
let totpError = $state('')
@@ -111,6 +90,19 @@
}
showPw = !showPw
}
+
+ let showNotes = $state(false)
+ let notesLoading = $state(false)
+
+ async function revealOrToggleNotes() {
+ if (!showNotes && notesWasWithheld && !draft.Notes) {
+ notesLoading = true
+ const val = getFieldValue(vaultUuid, record?.UUID, 'Notes')
+ set('Notes', val ?? '')
+ notesLoading = false
+ }
+ showNotes = !showNotes
+ }
let genOpen = $state(false)
let showHistory = $state(false)
@@ -158,7 +150,7 @@
let totpChanged = $derived(
(totpWasConfigured && totpFieldTouched && !totpSecret) || // user focused and cleared
- totpSecret !== (totpLoadedSecret || base64ToBase32(record?.TwoFactorKey ?? '')) ||
+ totpSecret !== totpLoadedSecret ||
(totpDigits !== (record?.TOTPLength || 6)) ||
(totpPeriod !== (record?.TOTPTimeStep || 30))
)
@@ -166,13 +158,13 @@
const orig = (record?.CustomFields ?? []).slice(0, 9)
if (orig.length !== customFields.length) return true
return customFields.some((cf, i) =>
- cf.Name !== orig[i].Name || cf.Value !== orig[i].Value || cf.Sensitive !== !!orig[i].Sensitive
+ cf.Name !== orig[i]?.Name || cf.Value !== orig[i]?.Value || cf.Sensitive !== !!orig[i]?.Sensitive
)
})
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 }
@@ -185,7 +177,7 @@
d.TOTPLength = String(totpDigits)
d.TOTPTimeStep = String(totpPeriod)
}
- d.CustomFields = customFields.slice()
+ d.CustomFields = [...customFields, ...overflowFields]
return d
}
@@ -250,6 +242,152 @@
set('Password', pw)
genOpen = false
}
+
+ // Returns a blocking error string (prevents save) for structural problems.
+ function validateAutotype(seq) {
+ if (!seq) return ''
+ let i = 0
+ while (i < seq.length) {
+ if (seq[i] !== '\\') { i++; continue }
+ if (i + 1 >= seq.length) return 'Sequence ends with \\'
+ const code = seq[i + 1]
+ if (code === 'f') {
+ const d = seq[i + 2]
+ if (d !== undefined && /^[0-9]$/.test(d)) {
+ if (d === '0') return '\\f0 is not valid — field numbers start at 1'
+ i += 3
+ } else {
+ 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++ }
+ if (count === 0) return `\\${code} must be followed by 1–3 digits`
+ i = j
+ } else {
+ i += 2 // known and unknown codes both advance; unknown flagged by warnAutotype
+ }
+ }
+ return ''
+ }
+
+ // Returns a warning string (non-blocking) for codes Portpass doesn't support.
+ 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(', ')}`
+ }
+
+ let autotypeError = $derived(validateAutotype(draft.Autotype))
+ let autotypeWarning = $derived(!autotypeError ? warnAutotype(draft.Autotype) : '')
+
+ // --- Visual chip builder ---
+ function parseTokens(seq, cf) {
+ if (!seq) return []
+ const toks = []
+ let lit = '', i = 0
+ const fl = () => { if (lit) { toks.push({ type: 'literal', value: lit }); lit = '' } }
+ while (i < seq.length) {
+ if (seq[i] !== '\\') { lit += seq[i++]; continue }
+ if (i + 1 >= seq.length) {
+ fl()
+ toks.push({ type: 'error', raw: '\\', label: 'trailing \\', message: 'Sequence ends with \\' })
+ break
+ }
+ const c = seq[i + 1]
+ if (c === '\\') { lit += '\\'; i += 2; continue }
+ fl()
+ if (c === 'u') { toks.push({ type: 'field', label: 'Username', raw: '\\u' }); i += 2 }
+ else if (c === 'p') { toks.push({ type: 'field', label: 'Password', raw: '\\p' }); i += 2 }
+ else if (c === 'm') { toks.push({ type: 'field', label: 'Email', raw: '\\m' }); i += 2 }
+ else if (c === '2') { toks.push({ type: 'field', label: 'One-time code', raw: '\\2' }); i += 2 }
+ else if (c === 't') { toks.push({ type: 'nav', label: 'Tab', suffix: '→', raw: '\\t' }); i += 2 }
+ else if (c === 's') { toks.push({ type: 'nav', label: 'Shift-Tab', suffix: '←', raw: '\\s' }); i += 2 }
+ else if (c === 'n') { toks.push({ type: 'nav', label: 'Enter', suffix: '↵', raw: '\\n' }); i += 2 }
+ else if (c === 'f') {
+ const d = seq[i + 2]
+ if (d !== undefined && /^[0-9]$/.test(d)) {
+ if (d === '0') {
+ toks.push({ type: 'error', raw: '\\f0', label: '\\f0 invalid', message: '\\f0 is not valid — field numbers start at 1' }); i += 3
+ } else {
+ const n = parseInt(d)
+ toks.push({ type: 'field', label: cf?.[n - 1]?.Name?.trim() || `Custom ${n}`, raw: `\\f${d}` }); i += 3
+ }
+ } else {
+ toks.push({ type: 'field', label: cf?.[0]?.Name?.trim() || 'Custom 1', raw: '\\f' }); i += 2
+ }
+ } else if (c === 'w' || c === 'W') {
+ let j = i + 2, cnt = 0
+ while (j < seq.length && cnt < 3 && /^[0-9]$/.test(seq[j])) { j++; cnt++ }
+ if (cnt === 0) {
+ toks.push({ type: 'error', raw: `\\${c}`, label: `\\${c} no digits`, message: `\\${c} must be followed by 1–3 digits` }); i += 2
+ } else {
+ const unit = c === 'w' ? 'ms' : 's'
+ toks.push({ type: 'wait', label: `Wait ${seq.slice(i + 2, j)}${unit}`, raw: seq.slice(i, j) }); i = j
+ }
+ } else {
+ toks.push({ type: 'unknown', label: `Unknown \\${c}`, raw: `\\${c}` }); i += 2
+ }
+ }
+ fl()
+ return toks
+ }
+
+ function tokensToRaw(toks) {
+ return toks.map(t => t.type === 'literal' ? t.value.replace(/\\/g, '\\\\') : t.raw).join('')
+ }
+
+ const savedAutotype = untrack(() => record?.Autotype ?? '')
+ let autofillMode = $state('visual')
+ let showAutofillInfo = $state(false)
+ let dragIdx = $state(-1), dropIdx = $state(-1)
+ let activeMiniForm = $state('')
+ let literalInput = $state(''), waitAmount = $state('500'), waitUnit = $state('ms')
+ let tokens = $derived(parseTokens(draft.Autotype, customFields))
+
+ function removeToken(idx) {
+ set('Autotype', tokensToRaw(tokens.filter((_, i) => i !== idx)))
+ }
+ function addRaw(raw) { set('Autotype', draft.Autotype + raw) }
+ function addLiteralToken() {
+ if (!literalInput) return
+ addRaw(literalInput.replace(/\\/g, '\\\\'))
+ literalInput = ''; activeMiniForm = ''
+ }
+ function addWaitToken() {
+ const n = parseInt(waitAmount)
+ if (!n || n < 1 || n > 999) return
+ addRaw(`\\${waitUnit === 'ms' ? 'w' : 'W'}${n}`)
+ waitAmount = '500'; activeMiniForm = ''
+ }
+ function onChipDrop() {
+ if (dragIdx === -1) return
+ const arr = tokens.slice()
+ const [moved] = arr.splice(dragIdx, 1)
+ arr.splice(dragIdx < dropIdx ? dropIdx - 1 : dropIdx, 0, moved)
+ set('Autotype', tokensToRaw(arr))
+ dragIdx = -1; dropIdx = -1
+ }
+ function resetAutotype() { set('Autotype', savedAutotype) }
{#if genOpen}
@@ -259,7 +397,7 @@
Cancel
{isNew ? 'New' : 'Edit'}
-
onsave(buildSaveDraft())}>Save
+
onsave(buildSaveDraft())}>{vaultReadonly ? 'Save as' : 'Save'}
{#if isDesktop}
@@ -268,7 +406,7 @@
Cancel
onsave(buildSaveDraft())}
- style="height:36px;padding:0 14px;font-size:14px">Save
+ style="height:36px;padding:0 14px;font-size:14px">{vaultReadonly ? 'Save as' : 'Save'}
{/if}
@@ -436,6 +574,178 @@
set('URL', e.target.value)}/>
+ {#if bookmarkletsSupported}
+
+
+ {#if showAutofillInfo}
+
+ Autofill types your credentials into web forms automatically. To use it, open Vault settings and install a bookmarklet in your browser.
+
+ {/if}
+
+ {#if autofillMode === 'visual'}
+
{ e.preventDefault(); dropIdx = tokens.length }}
+ ondrop={onChipDrop}>
+ {#if tokens.length === 0}
+
Add tokens from the palette below
+ {/if}
+ {#each tokens as tok, i}
+ {#if dragIdx !== -1 && dropIdx === i}
+
+ {/if}
+
{ dragIdx = i; dropIdx = i }}
+ ondragover={e => { e.preventDefault(); e.stopPropagation(); dropIdx = i }}
+ ondrop={e => { e.stopPropagation(); onChipDrop() }}
+ ondragend={() => { dragIdx = -1; dropIdx = -1 }}>
+ {#if tok.type === 'error'}✕
+ {:else if tok.type === 'unknown'}⚠ {/if}
+ {#if tok.type === 'literal'}{tok.value} {:else}{tok.label}{/if}
+ {#if tok.suffix}{tok.suffix} {/if}
+ removeToken(i)} aria-label="Remove">×
+
+ {#if i < tokens.length - 1}
+
{ e.preventDefault(); e.stopPropagation(); dropIdx = i + 1 }}>→
+ {/if}
+ {/each}
+ {#if dragIdx !== -1 && dropIdx === tokens.length}
+
+ {/if}
+
+
+
+
+
Fields
+
+ addRaw('\\u')}>+ Username
+ addRaw('\\p')}>+ Password
+ addRaw('\\m')}>+ Email
+ addRaw('\\2')}>+ One-time code
+ {#each customFields as cf, cfi}
+ {#if cf.Name.trim()}
+ addRaw(`\\f${cfi + 1}`)}>+ {cf.Name.trim()}
+ {/if}
+ {/each}
+
+
+
+
Navigate
+
+ addRaw('\\t')}>+ Tab
+ addRaw('\\s')}>+ Shift-Tab
+ addRaw('\\n')}>+ Enter
+
+
+
+
Other
+
+ activeMiniForm = activeMiniForm === 'literal' ? '' : 'literal'}>+ Text…
+ activeMiniForm = activeMiniForm === 'wait' ? '' : 'wait'}>+ Wait…
+
+
+ {#if activeMiniForm === 'literal'}
+
+ Text:
+ e.key === 'Enter' && (e.preventDefault(), addLiteralToken())}
+ autocomplete="off" spellcheck="false"/>
+ Add
+ activeMiniForm = ''}>×
+
+ {/if}
+ {#if activeMiniForm === 'wait'}
+
+ {/if}
+
+
+ {#if tokens.length > 0}
+
Raw: {draft.Autotype}
+ {/if}
+
+ {:else}
+
set('Autotype', e.target.value)}
+ autocomplete="off" spellcheck="false"/>
+
+
+ Fields
+ \u Username
+ \p Password
+ \m Email
+ \2 OTP
+ \fN Custom N
+
+
+ Navigate
+ \t Tab
+ \s Shift-Tab
+ \n Enter
+
+
+ Other
+ \wNNN wait ms
+ \WNNN wait s
+ \\ literal \
+
+
+ {/if}
+
+ {#if autotypeError}
+
+
⚠
+
+
Cannot save — fix the error first
+
{autotypeError}
+
+
+ {:else if autotypeWarning}
+
+
⚠
+
+
Saved with warnings
+
{autotypeWarning}
+
+
+ {/if}
+
+ {/if}
+
Email
set('Email', e.target.value)}/>
@@ -487,11 +797,23 @@
{/if}
-
- Notes
-
-
+
+
+ Notes
+ {#if notesWasWithheld}
+
+
+
+ {/if}
+
+ {#if notesWasWithheld && !showNotes}
+
••••••••••••••••
+ {:else}
+
+ {/if}
+
{#if !isNew && ondelete}
@@ -499,6 +821,7 @@
Delete {draft.Title}
+ {#if vaultReadonly}Saves a copy of the vault with this entry removed
{/if}
{/if}
@@ -623,6 +946,17 @@
}
.history-entry:last-child { border-bottom: none; }
+ .notes-label-row {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+ .notes-label-row .field-label { margin-bottom: 0; }
+ .notes-masked {
+ color: var(--text-soft);
+ padding: 2px 0;
+ }
+
.history-time {
font-size: 12px;
white-space: nowrap;
@@ -688,4 +1022,355 @@
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 {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+ .autofill-info-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 15px;
+ color: var(--text-muted);
+ padding: 0;
+ line-height: 1;
+ }
+ .autofill-info-btn:hover { color: var(--accent); }
+ .autofill-info-card {
+ background: var(--surface-2);
+ border: 1px solid var(--border-strong);
+ border-radius: 8px;
+ padding: 10px 12px;
+ font-size: 13px;
+ color: var(--text-soft);
+ line-height: 1.5;
+ margin-bottom: 8px;
+ }
+
+ .autotype-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 6px;
+ }
+ .autotype-header .field-label { margin-bottom: 0; }
+ .autotype-header-right {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+ }
+ .autotype-reset {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--text-muted);
+ padding: 2px 6px;
+ border-radius: 6px;
+ }
+ .autotype-reset:hover { color: var(--text); background: var(--surface-2); }
+
+ /* Mode toggle pill */
+ .mode-toggle {
+ display: flex;
+ border: 1px solid var(--border-strong);
+ border-radius: 8px;
+ overflow: hidden;
+ }
+ .mode-toggle button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-muted);
+ padding: 4px 10px;
+ transition: background 0.1s, color 0.1s;
+ }
+ .mode-toggle button.active {
+ background: var(--surface-2);
+ color: var(--text);
+ font-weight: 700;
+ }
+
+ /* --- Chip display area --- */
+ .chip-area {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ border: 2px solid var(--border-strong);
+ border-radius: 9px;
+ background: var(--surface-2);
+ min-height: 52px;
+ padding: 10px 12px;
+ }
+ .chip-area-error {
+ border-color: var(--danger);
+ background: var(--red-bg-strong);
+ }
+ .chip-area-warn {
+ border-color: var(--orange);
+ background: var(--orange-bg-strong);
+ }
+ .chip-placeholder {
+ font-style: italic;
+ color: var(--text-soft);
+ font-size: 13px;
+ }
+
+ /* Drop indicator */
+ .drop-indicator {
+ width: 3px;
+ min-height: 24px;
+ align-self: stretch;
+ background: var(--accent);
+ border-radius: 2px;
+ flex-shrink: 0;
+ }
+
+ /* Chip separator */
+ .chip-sep {
+ color: var(--text-soft);
+ font-size: 12px;
+ user-select: none;
+ }
+
+ /* Base chip */
+ .chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ border-radius: 100px;
+ padding: 4px 8px 4px 10px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: default;
+ user-select: none;
+ }
+ .chip[draggable=true] { cursor: grab; }
+ .chip[draggable=true]:active { cursor: grabbing; }
+
+ /* Chip type variants */
+ .chip-field {
+ background: var(--amber-bg);
+ color: var(--amber);
+ font-weight: 600;
+ }
+ .chip-nav {
+ background: var(--surface);
+ color: var(--text-muted);
+ }
+ .chip-literal {
+ background: transparent;
+ color: var(--text);
+ font-weight: 400;
+ border: 1px solid var(--border-strong);
+ }
+ .chip-wait {
+ background: var(--wait-blue-bg);
+ color: var(--wait-blue);
+ }
+ .chip-unknown {
+ background: transparent;
+ color: var(--orange);
+ font-weight: 600;
+ border: 1.5px solid var(--orange);
+ }
+ .chip-error {
+ background: transparent;
+ color: var(--danger);
+ font-weight: 600;
+ border: 1.5px solid var(--danger);
+ }
+
+ .chip-pre { font-size: 11px; }
+ .chip-pre-error { color: var(--danger); }
+ .chip-pre-warn { color: var(--orange); }
+
+ .chip-nav-suffix {
+ font-size: 11px;
+ opacity: 0.55;
+ margin-left: 1px;
+ }
+ .chip-label { line-height: 1; }
+ .chip-remove {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 14px;
+ line-height: 1;
+ color: inherit;
+ opacity: 0.45;
+ padding: 0 0 0 2px;
+ margin-left: 2px;
+ }
+ .chip-remove:hover { opacity: 1; }
+
+ /* --- Palette --- */
+ .palette {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 8px;
+ }
+ .palette-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ }
+ .palette-label {
+ font-size: 12px;
+ font-weight: 600;
+ min-width: 58px;
+ padding-top: 5px;
+ flex-shrink: 0;
+ }
+ .palette-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ }
+ .palette-btn {
+ background: var(--surface);
+ border: 1px solid var(--border-strong);
+ border-radius: 100px;
+ padding: 3px 10px;
+ font-size: 12px;
+ cursor: pointer;
+ color: var(--text-muted);
+ transition: background 0.1s, border-color 0.1s;
+ }
+ .palette-btn:hover { background: var(--surface-2); border-color: var(--accent); }
+ .palette-field { color: var(--amber); }
+ .palette-wait { color: var(--wait-blue); }
+ .palette-active { border-color: var(--accent); background: var(--surface-2); }
+
+ /* --- Inline mini-forms --- */
+ .mini-form {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 4px;
+ padding: 8px 10px;
+ background: var(--surface-2);
+ border-radius: 8px;
+ }
+ .mini-input {
+ padding: 5px 10px;
+ font-size: 13px;
+ height: auto;
+ flex: 1;
+ min-width: 0;
+ }
+ .mini-number {
+ flex: 0 0 70px;
+ -moz-appearance: textfield;
+ }
+ .mini-number::-webkit-inner-spin-button,
+ .mini-number::-webkit-outer-spin-button { display: none; }
+ .mini-add {
+ padding: 5px 12px;
+ font-size: 13px;
+ height: auto;
+ white-space: nowrap;
+ flex-shrink: 0;
+ }
+ .mini-close {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 16px;
+ color: var(--text-soft);
+ padding: 0 2px;
+ flex-shrink: 0;
+ }
+ .mini-close:hover { color: var(--text); }
+
+ /* Unit toggle (ms / s) */
+ .unit-toggle {
+ display: flex;
+ border: 1px solid var(--border-strong);
+ border-radius: 6px;
+ overflow: hidden;
+ flex-shrink: 0;
+ }
+ .unit-toggle button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--text-muted);
+ padding: 4px 8px;
+ }
+ .unit-toggle button.active {
+ background: var(--surface);
+ color: var(--text);
+ font-weight: 600;
+ }
+
+ /* Raw equivalence line */
+ .autotype-raw-equiv {
+ font-size: 11px;
+ margin-top: 4px;
+ padding: 0 2px;
+ }
+
+ /* --- Raw mode legend --- */
+ .raw-legend {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-top: 6px;
+ font-size: 12px;
+ color: var(--text-muted);
+ }
+ .raw-legend-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 4px 12px;
+ }
+ .raw-legend-cat {
+ font-weight: 600;
+ min-width: 64px;
+ flex-shrink: 0;
+ }
+ .raw-code {
+ font-family: var(--font-mono);
+ color: var(--amber);
+ font-size: 12px;
+ letter-spacing: -0.005em;
+ }
+
+ /* --- Validation banners --- */
+ .autotype-banner {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ border-radius: 8px;
+ padding: 10px 14px;
+ margin-top: 6px;
+ }
+ .banner-error {
+ border: 1.5px solid var(--danger);
+ background: var(--red-bg-strong);
+ }
+ .banner-warn {
+ border: 1.5px solid var(--orange);
+ background: var(--orange-bg-strong);
+ }
+ .banner-icon { font-size: 16px; line-height: 1.4; flex-shrink: 0; }
+ .banner-title { font-size: 13px; font-weight: 700; }
+ .banner-body { font-size: 12px; margin-top: 2px; }
diff --git a/pwa/src/lib/RecordList.svelte b/pwa/src/lib/RecordList.svelte
index 150250f..aaaf915 100644
--- a/pwa/src/lib/RecordList.svelte
+++ b/pwa/src/lib/RecordList.svelte
@@ -1,7 +1,7 @@
+{#if !supportsFilePicker}
+
+{/if}
+
{#if mode === 'landing'}
@@ -243,9 +341,8 @@
{#if supportsFilePicker}
Open vault file
{:else}
-
- Your browser doesn't support file picker. Try Chrome or Safari.
-
+
Open vault file
+
Read-only — your browser can't save changes back to the file
{/if}
@@ -261,10 +358,13 @@
-
{fileHandle?.name ?? 'Vault'}
+
{(fallbackFile ?? fileHandle)?.name ?? rememberedName ?? 'Vault'}
- {busy ? 'Unlocking…' : 'Vault is locked'}
+ {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/src/lib/VaultSheet.svelte b/pwa/src/lib/VaultSheet.svelte
index 1673a61..d98511c 100644
--- a/pwa/src/lib/VaultSheet.svelte
+++ b/pwa/src/lib/VaultSheet.svelte
@@ -2,11 +2,13 @@
import { onMount } from 'svelte'
import { get } from 'svelte/store'
import { getDatabaseInfo, openDatabase, updateDBFields } from '../wasm.js'
- import { selectedFile, dbItems, secondaryVaults } from '../store.js'
+ import { selectedFile, dbItems, secondaryVaults, switchboardUrl, switchboardConnected, crossProfileEnabled, delegatesVersion } from '../store.js'
import { isBiometricSupported, isBiometricEnrolled, enrollBiometric, clearBiometric } from './biometric.js'
+ import { makeDelegateBookmarkletUrl } from './bookmarklet.js'
+ import { getDelegates, addDelegate, revokeDelegate, setSwitchboardUrl, setCrossProfileEnabled } from './delegates.js'
import Icon from './Icon.svelte'
- let { isDesktop, onback, onlock, onlockall, onlocksecondary, onunlockadditional, ondbsave, ondirtychange, theme, accent, ontheme, onaccent } = $props()
+ let { isDesktop, bookmarkletsSupported = false, onback, onlock, onlockall, onlocksecondary, onunlockadditional, ondbsave, ondirtychange, theme, accent, ontheme, onaccent } = $props()
// ── Biometric ──────────────────────────────────────────────────────────────
let biometricAvailable = $state(false)
@@ -22,6 +24,17 @@
biometricEnrolled = await isBiometricEnrolled(info?.uuid)
})
+ $effect(() => {
+ void $delegatesVersion
+ getDelegates(_vaultUuid).then(d => {
+ delegates = d
+ if (d.length === 0 && get(crossProfileEnabled)) {
+ setCrossProfileEnabled(_vaultUuid, false)
+ crossProfileEnabled.set(false)
+ }
+ })
+ })
+
async function disableBiometric() {
await clearBiometric(info?.uuid)
biometricEnrolled = false
@@ -147,6 +160,157 @@
selectedDetailVault = null
}
+ // ── Autofill delegates ─────────────────────────────────────────────────────
+ let delegates = $state([])
+ 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 newDelegateBirthAt = $state(null)
+ let chipCopied = $state(false)
+ let chipCopyTimer = null
+ 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,
+ get(switchboardUrl)
+ )
+ } catch (e) {
+ newDelegateError = 'Failed to generate key pair'
+ }
+ }
+
+ function closeNewDelegate() {
+ newDelegateOpen = false
+ newDelegateName = ''
+ newDelegatePrivKeyJwk = null
+ newDelegatePubKeySpki = null
+ newDelegateId = null
+ newDelegateUrl = ''
+ newDelegateError = ''
+ chipCopied = false
+ chipDragged = false
+ chipLinked = false
+ globeTipOpen = false
+ clearTimeout(chipCopyTimer)
+ }
+
+ async function commitDelegate() {
+ if (!_vaultUuid || !newDelegatePubKeySpki || !newDelegateId) return
+ const name = newDelegateName.trim() || defaultDelegateName()
+ newDelegateBusy = true
+ newDelegateError = ''
+ try {
+ const delegate = await addDelegate(_vaultUuid, name, newDelegatePubKeySpki, newDelegateId)
+ delegates = [delegate, ...delegates]
+ closeNewDelegate()
+ } catch (e) {
+ 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)
+ }
+
+ // ── Advanced / switchboard ────────────────────────────────────────────────
+ let advancedOpen = $state(false)
+ let editSwitchboardUrl = $state('')
+ let switchboardUrlDirty = $state(false)
+
+ let totalRelayCount = $derived(delegates.reduce((n, d) => n + (d.relayCount ?? 0), 0))
+ let lastRelayUsed = $derived(
+ delegates.reduce((t, d) => d.relayLastUsed ? Math.max(t, d.relayLastUsed) : t, 0) || null
+ )
+
+ function toggleAdvanced() {
+ advancedOpen = !advancedOpen
+ if (advancedOpen) {
+ editSwitchboardUrl = get(switchboardUrl)
+ switchboardUrlDirty = false
+ }
+ }
+
+ async function saveRelayUrl() {
+ await setSwitchboardUrl(_vaultUuid, editSwitchboardUrl)
+ switchboardUrl.set(editSwitchboardUrl)
+ switchboardUrlDirty = false
+ }
+
+ function cancelRelayUrlEdit() {
+ editSwitchboardUrl = get(switchboardUrl)
+ switchboardUrlDirty = false
+ }
+
+ function copyChip() {
+ navigator.clipboard.writeText(newDelegateUrl).then(() => {
+ chipCopied = true
+ chipLinked = true
+ clearTimeout(chipCopyTimer)
+ chipCopyTimer = setTimeout(() => { chipCopied = false }, 2200)
+ })
+ }
+
+ function fmtDate(ts) {
+ if (!ts) return '—'
+ return new Date(ts).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
+ }
+
+ function fmtRelative(ts) {
+ if (!ts) return 'never'
+ const days = Math.floor((Date.now() - ts) / 86400000)
+ if (days === 0) return 'today'
+ if (days === 1) return 'yesterday'
+ if (days < 7) return `${days} days ago`
+ return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
+ }
+
// ── Helpers ────────────────────────────────────────────────────────────────
const appVersion = (__APP_VERSION__.match(/^v?\d+\.\d+\.\d+/) ?? [__APP_VERSION__])[0]
@@ -211,17 +375,17 @@
{/if}
+
+ {passwordCount}
+ passwords
+
{#if groupCount > 0}
+
{groupCount}
groups
-
{/if}
-
- {passwordCount}
- passwords
-
@@ -280,6 +444,75 @@
+
+ {#if bookmarkletsSupported}
+
+
AUTOFILL
+
+ Create a uniquely keyed bookmarklet for each browser profile where you want autofill.
+
+ {#if delegates.length > 0}
+
+ {#each delegates as d}
+ {@const total = (d.bcCount ?? 0) + (d.relayCount ?? 0)}
+ {@const lastTs = Math.max(d.bcLastUsed ?? 0, d.relayLastUsed ?? 0) || null}
+
+
+ {d.name}
+ Created {fmtDate(d.created)} · {total} {total === 1 ? 'page filled' : 'pages filled'}{lastTs ? ' · Last filled ' + fmtRelative(lastTs) : ''}
+
+
revokeOne(d.id)}>Revoke
+
+ {/each}
+
+ {/if}
+
+ New bookmarklet
+
+
+
+ Cross-profile autofill {advancedOpen ? '▲' : '▼'}
+
+ {#if advancedOpen}
+
+
+
Enable cross-profile autofill
+
+ { await setCrossProfileEnabled(_vaultUuid, false); crossProfileEnabled.set(false) }}>Off
+ { await setCrossProfileEnabled(_vaultUuid, true); crossProfileEnabled.set(true) }}>On
+
+
+ {#if $crossProfileEnabled}
+
+ WebSocket Relay URL
+ { switchboardUrlDirty = editSwitchboardUrl !== get(switchboardUrl) }}
+ placeholder="ws://localhost:7577"
+ />
+
+ {#if switchboardUrlDirty}
+
+ Cancel
+ Save
+
+ {/if}
+
+
+
+ {$switchboardConnected ? 'Cross-profile autofill ready' : 'websocket relay not connected'}
+
+
+
+ Count of cross-profile autofill uses: {totalRelayCount}{#if lastRelayUsed} · Last {fmtRelative(lastRelayUsed)}{/if}
+
+ {/if}
+
+ {/if}
+
+ {/if}
+
APPEARANCE
@@ -460,6 +693,79 @@
{/if}
+
+{#if newDelegateOpen}
+ { e.stopPropagation(); if (!newDelegateBusy) cancelOrSave() }}
+ onkeydown={e => { if (e.key === 'Escape' && !newDelegateBusy) cancelOrSave() }}>
+
e.stopPropagation()} onkeydown={e => e.stopPropagation()}>
+
+
+ Name
+ { if (e.key === 'Enter' && canCommit) commitDelegate() }}
+ use:focusOnMount
+ />
+
+ {#if newDelegateError}
{newDelegateError}
{/if}
+
+
+
+ BAR HIDDEN
+
+
+ {chipCopied ? 'Copied!' : 'Copy link'}
+
+ Add a bookmark manually and paste the link
+
+
+
globeTipOpen = !globeTipOpen}>
+ ▶
+ Bookmark showing a generic icon instead of the Portpass logo?
+
+ {#if globeTipOpen}
+
+ Bookmark this page normally first (⌘D / Ctrl+D), then right-click the bookmark → Edit bookmark → paste this link as the URL.
+
+ {/if}
+
+
+ The bookmarklet contains a unique private key that will not be shown again. Drag it to your bookmarks bar or copy the link before closing.
+
+
+
+ {newDelegateBusy ? 'Saving…' : 'Save and Close'}
+
+
+
+
+{/if}
+
{#if setupMode}
diff --git a/pwa/src/lib/biometric.js b/pwa/src/lib/biometric.js
index 73a6284..3213031 100644
--- a/pwa/src/lib/biometric.js
+++ b/pwa/src/lib/biometric.js
@@ -91,27 +91,32 @@ 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) {
+ throw err
+ }
}
// Remove enrollment for a specific vault UUID (used when disabling from vault settings).
diff --git a/pwa/src/lib/bookmarklet.js b/pwa/src/lib/bookmarklet.js
new file mode 100644
index 0000000..b3f9991
--- /dev/null
+++ b/pwa/src/lib/bookmarklet.js
@@ -0,0 +1,273 @@
+// 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 makeDelegateBookmarkletUrl(portpassUrl, privKeyJwk, delegateId, relayUrl) {
+ const origin = new URL(portpassUrl).origin
+ return 'javascript:' + encodeURIComponent(
+ `(${DELEGATE_BOOKMARKLET_IIFE.toString()})(${JSON.stringify(portpassUrl)},${JSON.stringify(origin)},${JSON.stringify(privKeyJwk)},${JSON.stringify(delegateId)},${JSON.stringify(relayUrl)})`
+ )
+}
+
+// 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; DELEGATE_ID identifies the delegate on the switchboard.
+function DELEGATE_BOOKMARKLET_IIFE(PORTPASS_URL, PORTPASS_ORIGIN, PRIV_KEY_JWK, DELEGATE_ID, RELAY_URL) {
+ 'use strict'
+
+ if (window.__ppRunning) return
+ window.__ppRunning = true
+
+ // Capture focused element before window.open() can blur it.
+ // isUsableInput is a function declaration so it is hoisted and available here.
+ var activeEl = isUsableInput(document.activeElement) ? document.activeElement : null
+
+ var isSecure = window.location.protocol === 'https:' || window.location.hostname === 'localhost'
+ var currentCanonical = canonicalURL(window.location.href)
+ var saveUrl = window.location.origin + window.location.pathname
+ var AUTOFILL_URL = PORTPASS_URL + 'autofill.html'
+ var pp = null
+ var startEl = null // element the user clicked in the host page during the waiting phase
+ var cleanedUp = false
+
+ function cleanup() {
+ if (cleanedUp) return
+ cleanedUp = true
+ document.removeEventListener('click', onFieldClick, true)
+ window.removeEventListener('message', onPopupMessage)
+ window.__ppRunning = false
+ }
+
+ function onFieldClick(e) {
+ var target = e.target
+ if (!isUsableInput(target)) return
+ e.preventDefault()
+ startEl = target
+ try { pp.postMessage({ type: 'field-clicked' }, PORTPASS_ORIGIN) } catch(_) {}
+ }
+
+ function onPopupMessage(e) {
+ if (!pp || e.source !== pp) return
+ var msg = e.data
+ if (!msg) return
+ if (msg.type === 'fill') {
+ // Autofill popup has chosen a record and the user has clicked a field (or one was pre-focused).
+ // Remove the field-click listener so it doesn't fire again mid-fill.
+ document.removeEventListener('click', onFieldClick, true)
+ var el = startEl || activeEl
+ executeAutotype(el, msg.autotype, msg.fields).then(function() {
+ try { pp.postMessage({ type: 'fill-done' }, PORTPASS_ORIGIN) } catch(_) {}
+ // Focus the autofill popup from the main-window context so the done state is visible.
+ // (window.focus() from within autofill.html is blocked; pp.focus() from the opener works.)
+ try { pp.focus() } catch(_) {}
+ cleanup()
+ })
+ } else if (msg.type === 'cancel') {
+ cleanup()
+ }
+ }
+
+ ;(async function run() {
+ try {
+ var ppW = 380, ppH = 480
+ var ppLeft = screen.width - ppW - 24
+ pp = window.open(AUTOFILL_URL, 'portpass_autofill',
+ 'popup=yes,width=' + ppW + ',height=' + ppH + ',left=' + ppLeft + ',top=24')
+ if (!pp) { cleanup(); return }
+
+ var readyMsg
+ try { readyMsg = await recv(pp, ['ready', 'error'], 10000) }
+ catch (_) {
+ try { pp.close() } catch (_2) {}
+ cleanup()
+ return
+ }
+ if (readyMsg.type === 'error') { cleanup(); return }
+
+ pp.postMessage({
+ type: 'init',
+ url: currentCanonical,
+ saveUrl: saveUrl,
+ isSecure: isSecure,
+ privKey: PRIV_KEY_JWK,
+ delegateId: DELEGATE_ID,
+ relayUrl: RELAY_URL,
+ hasActiveField: !!activeEl,
+ }, PORTPASS_ORIGIN)
+
+ // Register field-click listener (host page) and message handler (popup).
+ window.addEventListener('message', onPopupMessage)
+ document.addEventListener('click', onFieldClick, true)
+
+ // Clean up when popup is closed by the user.
+ var closeCheck = setInterval(function() {
+ if (pp && pp.closed) { clearInterval(closeCheck); cleanup() }
+ }, 500)
+
+ } catch (e) {
+ cleanup()
+ }
+ })()
+
+ // ── Messaging helpers ────────────────────────────────────────────────────
+
+ 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)
+ })
+ }
+
+ // ── 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 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 === '\\') {
+ 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
+ }
+ }
+ if (lit) tokens.push({ type: 'lit', text: lit })
+ return tokens
+ }
+
+ function isUsableInput(el) {
+ if (!el) return false
+ var tag = el.tagName
+ if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false
+ if (el.disabled || el.type === 'hidden') return false
+ 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')
+ if (setter && setter.set) setter.set.call(el, value)
+ el.dispatchEvent(new Event('input', { bubbles: true }))
+ el.dispatchEvent(new Event('change', { bubbles: true }))
+ }
+
+ function focusableList() {
+ var q = 'input:not([disabled]):not([type=hidden]):not([type=submit]):not([type=button])' +
+ ':not([type=reset]):not([type=image]):not([type=checkbox]):not([type=radio]),' +
+ 'textarea:not([disabled])'
+ var all = Array.from(document.querySelectorAll(q)).filter(function(e) {
+ var s = getComputedStyle(e)
+ return e.tabIndex >= 0 && s.display !== 'none' && s.visibility !== 'hidden'
+ })
+ 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 })
+ return pos.concat(zero)
+ }
+
+ function nextFocusable(el) {
+ var sorted = focusableList()
+ var i = sorted.indexOf(el)
+ return i >= 0 ? sorted[i + 1] || null : null
+ }
+
+ function prevFocusable(el) {
+ var sorted = focusableList()
+ var i = sorted.indexOf(el)
+ return i > 0 ? sorted[i - 1] : null
+ }
+
+ async function executeAutotype(startEl, sequence, fields) {
+ var tokens = parseAutotype(sequence)
+ var el = startEl
+ for (var i = 0; i < tokens.length; i++) {
+ var tok = tokens[i]
+ if (tok.type === 'delay') {
+ await new Promise(function(r) { setTimeout(r, tok.ms) })
+ } else if (tok.type === 'lit' || tok.type === 'f') {
+ if (el) fillField(el, tok.type === 'f' ? (fields['f' + tok.n] || '') : tok.text)
+ } else {
+ var code = tok.code
+ if (code === 'u' || code === 'p' || code === 'm' || code === '2') {
+ if (el) fillField(el, fields[code] || '')
+ } else if (code === 't') {
+ var next = nextFocusable(el)
+ if (next) { if (el) el.dispatchEvent(new Event('blur', { bubbles: true })); next.focus(); el = next }
+ } else if (code === 's') {
+ var prev = prevFocusable(el)
+ if (prev) { if (el) el.dispatchEvent(new Event('blur', { bubbles: true })); prev.focus(); el = prev }
+ } else if (code === 'n') {
+ if (el) {
+ ['keydown', 'keypress', 'keyup'].forEach(function(evType) {
+ el.dispatchEvent(new KeyboardEvent(evType, {
+ key: 'Enter', code: 'Enter', keyCode: 13, which: 13,
+ bubbles: true, cancelable: true
+ }))
+ })
+ }
+ var form = el && el.closest('form')
+ var submitBtn = (form && form.querySelector('[type=submit]')) ||
+ (form && form.querySelector('[default-button]'))
+ if (!submitBtn && form) {
+ var btns = Array.from(form.querySelectorAll('button:not([type=reset])'))
+ if (btns.length === 1) submitBtn = btns[0]
+ }
+ if (submitBtn) {
+ submitBtn.click()
+ } else if (form) {
+ try { form.requestSubmit() } catch (_) {
+ try { form.submit() } catch (_2) {}
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/pwa/src/lib/components.css b/pwa/src/lib/components.css
index 2cd1892..4a63d47 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,15 +509,15 @@
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 {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 24px 8px;
}
-.vault-app.is-desktop .record-pane-actions { display: flex; gap: 8px; }
-.vault-app.is-desktop .record-body { padding: 8px 24px 32px; max-width: 640px; }
+.vault-app.is-desktop .record-pane-actions { display: flex; align-items: center; gap: 8px; }
+.vault-app.is-desktop .record-body { padding: 8px 24px 32px; }
.vault-app.is-desktop .fab { display: none; }
.vault-app.is-desktop .desktop-new-btn {
position: absolute; bottom: 16px; left: 16px; z-index: 4;
diff --git a/pwa/src/lib/delegates.js b/pwa/src/lib/delegates.js
new file mode 100644
index 0000000..72ebe70
--- /dev/null
+++ b/pwa/src/lib/delegates.js
@@ -0,0 +1,115 @@
+import { get, set, del } from 'idb-keyval'
+
+const STORAGE_KEY = 'delegates-v1'
+const SWITCHBOARD_URL_DEFAULT = 'ws://localhost:7577'
+
+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)
+}
+
+function migrate(d) {
+ if ('useCount' in d || 'lastUsed' in d) {
+ const { useCount, lastUsed, ...rest } = d
+ return { ...rest, bcCount: 0, bcLastUsed: null, relayCount: 0, relayLastUsed: null }
+ }
+ return d
+}
+
+export async function getDelegates(vaultUuid) {
+ if (!vaultUuid) return []
+ const all = await load()
+ return (all[vaultUuid] ?? []).map(migrate)
+}
+
+export async function addDelegate(vaultUuid, name, publicKeySpki, id = crypto.randomUUID()) {
+ const all = await load()
+ const delegate = {
+ id,
+ name,
+ publicKey: Array.from(new Uint8Array(publicKeySpki)),
+ created: Date.now(),
+ bcCount: 0,
+ bcLastUsed: null,
+ relayCount: 0,
+ relayLastUsed: 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. Returns the matching delegate
+// on success, null if no delegate matches or the signature is invalid.
+export async function verifyDelegate(vaultUuid, spkiBytes, message, signatureBytes) {
+ const all = await load()
+ const list = (all[vaultUuid] ?? []).map(migrate)
+ 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) return d
+ } catch { continue }
+ }
+ return null
+}
+
+// Record a successful page fill for a delegate. Called when autofill.html shows
+// its done/checkmark screen — once per page filled, not per field or connection.
+export async function recordFill(vaultUuid, delegateId, channel) {
+ const all = await load()
+ const list = (all[vaultUuid] ?? []).map(migrate)
+ const d = list.find(x => x.id === delegateId)
+ if (!d) return
+ if (channel === 'relay') {
+ d.relayCount = (d.relayCount ?? 0) + 1
+ d.relayLastUsed = Date.now()
+ } else {
+ d.bcCount = (d.bcCount ?? 0) + 1
+ d.bcLastUsed = Date.now()
+ }
+ all[vaultUuid] = list
+ await save(all)
+}
+
+export async function getSwitchboardUrl(vaultUuid) {
+ if (!vaultUuid) return SWITCHBOARD_URL_DEFAULT
+ const key = `switchboard-url-${vaultUuid}`
+ return (await get(key)) ?? SWITCHBOARD_URL_DEFAULT
+}
+
+export async function setSwitchboardUrl(vaultUuid, url) {
+ const key = `switchboard-url-${vaultUuid}`
+ if (!url || url === SWITCHBOARD_URL_DEFAULT) await del(key)
+ else await set(key, url)
+}
+
+export async function getCrossProfileEnabled(vaultUuid) {
+ if (!vaultUuid) return false
+ return (await get(`cross-profile-enabled-${vaultUuid}`)) === true
+}
+
+export async function setCrossProfileEnabled(vaultUuid, enabled) {
+ const key = `cross-profile-enabled-${vaultUuid}`
+ if (enabled) await set(key, true)
+ else await del(key)
+}
diff --git a/pwa/src/lib/recentHandles.js b/pwa/src/lib/recentHandles.js
index 9cd8df0..52de5de 100644
--- a/pwa/src/lib/recentHandles.js
+++ b/pwa/src/lib/recentHandles.js
@@ -1,17 +1,35 @@
import { get, set } from 'idb-keyval'
-// Stores { handle, uuid } for primary vaults only — drives auto-load on startup.
+// Stores { handle, uuid, name } for primary vaults only — drives auto-load on startup.
+// handle may be null on browsers that lack the File System Access API write/permission methods.
export async function getRecentHandles() {
return (await get('recentHandles')) ?? []
}
export async function pushRecentHandle(handle, uuid = '') {
+ const name = handle?.name ?? null
const handles = await getRecentHandles()
const filtered = handles.filter(h => {
if (uuid && h.uuid === uuid) return false
- if (h.handle?.name === handle.name) return false
+ if ((h.name ?? h.handle?.name) === name) return false
return true
})
- await set('recentHandles', [{ handle, uuid }, ...filtered].slice(0, 10))
+ try {
+ await set('recentHandles', [{ handle, uuid, name }, ...filtered].slice(0, 10))
+ } catch {
+ // Browser can't serialize FileSystemFileHandle (e.g. Firefox) — store name only.
+ await set('recentHandles', [{ handle: null, uuid, name }, ...filtered].slice(0, 10))
+ }
+}
+
+// For browsers without FileSystemFileHandle persistence — store name only.
+export async function pushRecentName(name, uuid = '') {
+ const handles = await getRecentHandles()
+ const filtered = handles.filter(h => {
+ if (uuid && h.uuid === uuid) return false
+ if ((h.name ?? h.handle?.name) === name) return false
+ return true
+ })
+ await set('recentHandles', [{ handle: null, uuid, name }, ...filtered].slice(0, 10))
}
diff --git a/pwa/src/lib/theme.css b/pwa/src/lib/theme.css
index 5ea7b87..0a7da0c 100644
--- a/pwa/src/lib/theme.css
+++ b/pwa/src/lib/theme.css
@@ -56,6 +56,28 @@
.theme-light { --danger:#b3361f; --success:#2c7a4e; }
.theme-dark { --danger:#e08673; --success:#6cba8a; }
+/* Autofill UI semantic colors — fixed hues, not accent-dependent */
+.theme-light {
+ --amber: oklch(54% 0.13 72);
+ --amber-bg: oklch(93.5% 0.05 80);
+ --orange: oklch(46% 0.13 50);
+ --orange-bg-strong: oklch(91% 0.07 75);
+ --red-bg-strong: oklch(93% 0.05 18);
+ --green-bg: oklch(94% 0.045 145);
+ --wait-blue: oklch(55% 0.15 230);
+ --wait-blue-bg: oklch(94% 0.04 230);
+}
+.theme-dark {
+ --amber: oklch(71% 0.12 75);
+ --amber-bg: oklch(24% 0.07 72);
+ --orange: oklch(64% 0.15 52);
+ --orange-bg-strong: oklch(27% 0.09 60);
+ --red-bg-strong: oklch(22% 0.08 18);
+ --green-bg: oklch(21% 0.07 145);
+ --wait-blue: oklch(65% 0.15 230);
+ --wait-blue-bg: oklch(21% 0.07 230);
+}
+
.vault-app, .vault-app * { box-sizing: border-box; }
.vault-app {
font-family: var(--font-ui);
diff --git a/pwa/src/store.js b/pwa/src/store.js
index 367251b..7fa0ce1 100644
--- a/pwa/src/store.js
+++ b/pwa/src/store.js
@@ -6,3 +6,7 @@ export const secondaryVaults = writable([]) // Array of { handle, name, filen
export const toast = writable(null) // { message, action?, duration? }
export const clipboardSession = writable(null) // { token: number, expiresAt: number } | null
export const clipboardContext = writable(null) // { token, field, uuid, hash: number[] } | null
+export const switchboardUrl = writable('ws://localhost:7577')
+export const switchboardConnected = writable(false)
+export const crossProfileEnabled = writable(false)
+export const delegatesVersion = writable(0)
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/autofill.spec.ts b/pwa/tests/autofill.spec.ts
new file mode 100644
index 0000000..a7cb020
--- /dev/null
+++ b/pwa/tests/autofill.spec.ts
@@ -0,0 +1,199 @@
+import { test, expect } from '@playwright/test'
+import { createVault } from './helpers'
+
+async function switchToRawMode(page: any) {
+ await page.locator('.mode-toggle').getByText('Raw').click()
+}
+
+// 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 switchToRawMode(page)
+ 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 switchToRawMode(page)
+ await expect(page.locator('.autotype-input')).toHaveAttribute('placeholder', '\\u\\t\\p\\n')
+ })
+
+ test('valid sequence enables 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 switchToRawMode(page)
+ await page.locator('.autotype-input').fill('\\u\\t\\p\\n')
+ await expect(page.locator('.banner-error')).toHaveCount(0)
+ await expect(page.getByRole('button', { name: 'Save' })).not.toBeDisabled()
+ })
+
+ 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 switchToRawMode(page)
+ await page.locator('.autotype-input').fill('\\u\\x\\p')
+ await expect(page.locator('.banner-warn')).toBeVisible()
+ await expect(page.locator('.banner-warn')).toContainText('\\x')
+ await expect(page.getByRole('button', { name: 'Save' })).not.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 switchToRawMode(page)
+ await page.locator('.autotype-input').fill('\\u\\')
+ await expect(page.locator('.banner-error')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
+ })
+
+ 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 switchToRawMode(page)
+ await page.locator('.autotype-input').fill('\\f0')
+ await expect(page.locator('.banner-error')).toBeVisible()
+ await expect(page.locator('.banner-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 switchToRawMode(page)
+ await page.locator('.autotype-input').fill('\\w')
+ await expect(page.locator('.banner-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 switchToRawMode(page)
+ await page.locator('.autotype-input').fill('\\u\\tabc123\\t\\p')
+ await expect(page.locator('.banner-error')).toHaveCount(0)
+ await expect(page.getByRole('button', { name: 'Save' })).not.toBeDisabled()
+ })
+
+ test('empty sequence 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('.banner-error')).toHaveCount(0)
+ await expect(page.getByRole('button', { name: 'Save' })).not.toBeDisabled()
+ })
+
+ 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 switchToRawMode(page)
+ await page.locator('.autotype-input').fill('\\f0')
+ await expect(page.locator('.banner-error')).toBeVisible()
+ await page.locator('.autotype-input').fill('\\u\\p')
+ await expect(page.locator('.banner-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 switchToRawMode(page)
+ await expect(page.locator('.autotype-value')).toHaveText('\\u\\t\\p\\n')
+ })
+
+ test('default sequence shown when autotype 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('.autofill-default-badge')).toBeVisible()
+ await expect(page.locator('.chip-area')).toBeVisible()
+ })
+
+ test('default sequence shown after clearing autotype in edit', async ({ page }) => {
+ await createVault(page)
+ await createRecordWithAutotype(page, '\\u\\t\\p\\n')
+ await expect(page.locator('.chip-area')).toBeVisible()
+ await page.getByRole('button', { name: 'Edit' }).click()
+ await switchToRawMode(page)
+ await page.locator('.autotype-input').fill('')
+ await page.getByRole('button', { name: 'Save' }).click()
+ await expect(page.locator('.autofill-default-badge')).toBeVisible()
+ })
+
+})
+
+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 switchToRawMode(page)
+ 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 switchToRawMode(page)
+ await page.locator('.autotype-input').fill('\\u\\t\\p')
+ await page.getByRole('button', { name: 'Save' }).click()
+ await switchToRawMode(page)
+ 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')
+ await switchToRawMode(page)
+ 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('.banner-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..2ed20ba
--- /dev/null
+++ b/pwa/tests/autofill_popup.spec.ts
@@ -0,0 +1,330 @@
+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('.mode-toggle').getByText('Raw').click()
+ 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('.mode-toggle').getByText('Raw').click()
+ 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('.mode-toggle').getByText('Raw').click()
+ 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('.mode-toggle').getByText('Raw').click()
+ 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".
+
+})
+
+// NOTE: The autofill bridge mode tests that tested App.svelte's tryBridge() have been removed.
+// That path (Portpass opened as a popup bridging postMessage↔BC) is no longer exercised
+// by the bookmarklet — autofill.html now handles the same-profile BC bridging directly and
+// authenticates with ECDSA. The autofill.html same-profile flow is covered by bookmarklet.spec.ts.
diff --git a/pwa/tests/bookmarklet.spec.ts b/pwa/tests/bookmarklet.spec.ts
new file mode 100644
index 0000000..7b9d191
--- /dev/null
+++ b/pwa/tests/bookmarklet.spec.ts
@@ -0,0 +1,305 @@
+import { test, expect, BrowserContext, Page } from '@playwright/test'
+import { makeDelegateBookmarkletUrl } 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 = `
+
+
+`
+
+// 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.locator('.vs-bookmarklet-chip:not(.chip-inactive)').waitFor({ timeout: 5000 })
+ const url = await portpass.locator('.vs-bookmarklet-chip').getAttribute('href') ?? ''
+
+ await portpass.locator('.vs-close-btn').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
+ }
+ ;(window as any).showSaveFilePicker = async () => ({
+ name: 'new.psafe3',
+ createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }),
+ })
+ })
+
+ 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 })
+
+ 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, bookmarkletUrl }
+}
+
+// Create a record in portpass and open its detail view.
+async function createRecord(portpass: Page, opts: {
+ title: string, username?: string, password?: string, autotype: string, url?: 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)
+ }
+ if (opts.url) {
+ await portpass.getByLabel('URL').fill(opts.url)
+ }
+ await portpass.locator('.mode-toggle').getByText('Raw').click()
+ await portpass.locator('.autotype-input').fill(opts.autotype)
+ await portpass.getByRole('button', { name: 'Save' }).click()
+ await portpass.locator('.record-row', { hasText: opts.title }).click()
+}
+
+// Opens the autofill popup and optionally clicks the first record row (transitioning to
+// the waiting phase). Returns the autofill popup Page for further assertions.
+// In the new flow the popup stays open (waiting phase) until the user clicks a form
+// field; there is no page-level overlay injected into the host page.
+async function activateBookmarklet(
+ login: Page, bookmarkletUrl: string, opts: { clickRow?: boolean } = {}
+): Promise {
+ const { clickRow = true } = opts
+ const context = login.context()
+ const popupPromise = context.waitForEvent('page')
+
+ const code = bookmarkletUrl.replace('javascript:', '')
+ await login.evaluate(new Function(decodeURIComponent(code)) as any)
+
+ const popup = await popupPromise
+ await popup.waitForLoadState('domcontentloaded')
+
+ // Wait for: picker rows, waiting phase (single exact match auto-advance),
+ // no-match notice, or popup close.
+ const which = await Promise.race([
+ popup.locator('.rec-row').first().waitFor({ timeout: 10000 }).then(() => 'picker').catch(() => 'timeout'),
+ popup.locator('.selected-record-row').first().waitFor({ timeout: 10000 }).then(() => 'waiting').catch(() => 'timeout'),
+ popup.locator('.pp-notice').first().waitFor({ timeout: 10000 }).then(() => 'no-match').catch(() => 'timeout'),
+ popup.waitForEvent('close', { timeout: 10000 }).then(() => 'closed').catch(() => 'timeout'),
+ ])
+
+ if (which === 'picker' && clickRow) {
+ await popup.locator('.rec-row').first().click()
+ // Popup transitions to waiting phase (stays open — fill fires only after field click).
+ await popup.locator('.selected-record-row').waitFor({ timeout: 5000 }).catch(() => {})
+ }
+
+ await login.evaluate(() => new Promise(r => requestAnimationFrame(r)))
+ return popup
+}
+
+test.setTimeout(30000)
+
+test.describe('Bookmarklet — autofill popup phases', () => {
+
+ test('waiting phase shows selected record title', async ({ context }) => {
+ const { login, portpass, bookmarkletUrl } = await setupAutofillTest(context)
+ await createRecord(portpass, { title: 'My Bank', autotype: '\\u\\t\\p\\n' })
+
+ // activateBookmarklet clicks the row — popup transitions to waiting phase.
+ const popup = await activateBookmarklet(login, bookmarkletUrl)
+ await expect(popup.locator('.selected-record-row')).toBeVisible({ timeout: 5000 })
+ await expect(popup.locator('.selected-record-row')).toContainText('My Bank')
+ await expect(popup.locator('.pp-waiting-title')).toContainText('Click a field to begin')
+ })
+
+ test('\\u\\t\\p fills username, tabs to password, fills password', async ({ 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, bookmarkletUrl)
+ // Popup is in waiting phase. Clicking a field triggers the fill chain.
+ await login.locator('#user').click()
+ await expect(login.locator('#user')).toHaveValue('alice', { timeout: 5000 })
+ await expect(login.locator('#pass')).toHaveValue('hunter2')
+ })
+
+ test('\\u\\t\\p\\n fills both fields and submits the form', async ({ context }) => {
+ const { login, portpass, bookmarkletUrl } = await setupAutofillTest(context)
+ await createRecord(portpass, {
+ title: 'My Bank', username: 'alice', password: 'hunter2', autotype: '\\u\\t\\p\\n',
+ })
+
+ let submitted = false
+ await login.exposeFunction('__ppSubmitted', () => { submitted = true })
+ await login.evaluate(() => {
+ document.getElementById('f')!.addEventListener('submit', () => (window as any).__ppSubmitted())
+ })
+
+ await activateBookmarklet(login, bookmarkletUrl)
+ await login.locator('#user').click()
+
+ await expect(login.locator('#user')).toHaveValue('alice', { timeout: 5000 })
+ 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, bookmarkletUrl } = await setupAutofillTest(context)
+ await createRecord(portpass, {
+ title: 'Site', username: 'bob', password: 'secret', autotype: '\\u',
+ })
+
+ await activateBookmarklet(login, bookmarkletUrl)
+ await login.locator('#user').click()
+ await expect(login.locator('#user')).toHaveValue('bob', { timeout: 5000 })
+ await expect(login.locator('#pass')).toHaveValue('')
+ })
+
+ test('\\p only fills password field (starting from password input)', async ({ context }) => {
+ const { login, portpass, bookmarkletUrl } = await setupAutofillTest(context)
+ await createRecord(portpass, {
+ title: 'Site', password: 'mypassword', autotype: '\\p',
+ })
+
+ await activateBookmarklet(login, bookmarkletUrl)
+ await login.locator('#pass').click()
+ await expect(login.locator('#pass')).toHaveValue('mypassword', { timeout: 5000 })
+ await expect(login.locator('#user')).toHaveValue('')
+ })
+
+ test('\\t skips non-input elements (e.g. show-password button) to reach password field', async ({ context }) => {
+ const formWithButton = `
+
+
+`
+
+ const { login: _login, portpass, bookmarkletUrl } = await setupAutofillTest(context)
+ 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, bookmarkletUrl)
+ await login.locator('#user').click()
+ await expect(login.locator('#user')).toHaveValue('alice', { timeout: 5000 })
+ await expect(login.locator('#pass')).toHaveValue('secret')
+ })
+
+ test('popup shows done phase after fill and auto-closes', async ({ context }) => {
+ const { login, portpass, bookmarkletUrl } = await setupAutofillTest(context)
+ await createRecord(portpass, { title: 'My Bank', username: 'alice', password: 'hunter2', autotype: '\\u' })
+
+ const popup = await activateBookmarklet(login, bookmarkletUrl)
+ await login.locator('#user').click()
+ await expect(popup.locator('.pp-phase-done')).toBeVisible({ timeout: 5000 })
+ await popup.waitForEvent('close', { timeout: 5000 })
+ })
+
+ test('Cancel button in waiting phase closes the autofill popup', async ({ context }) => {
+ const { login, portpass, bookmarkletUrl } = await setupAutofillTest(context)
+ await createRecord(portpass, { title: 'Site', autotype: '\\u\\p' })
+
+ const popup = await activateBookmarklet(login, bookmarkletUrl)
+ await popup.getByRole('button', { name: 'Cancel' }).click()
+ await expect.poll(() => popup.isClosed(), { timeout: 8000 }).toBe(true)
+ })
+
+ test('search fallback shown when no URL matches', async ({ context }) => {
+ const { login, bookmarkletUrl } = await setupAutofillTest(context)
+ const popup = await activateBookmarklet(login, bookmarkletUrl)
+ await expect(popup.locator('.pp-notice')).toBeVisible({ timeout: 5000 })
+ await expect(popup.locator('.pp-search-wrap .pp-search')).toBeVisible()
+ })
+
+ test('single exact match auto-advances to waiting phase', async ({ context }) => {
+ const { login, portpass, bookmarkletUrl } = await setupAutofillTest(context)
+ await createRecord(portpass, { title: 'Login Site', autotype: '\\u\\t\\p', url: LOGIN_URL })
+
+ const popup = await activateBookmarklet(login, bookmarkletUrl)
+ // No picker shown — popup goes directly to waiting.
+ await expect(popup.locator('.rec-row')).toHaveCount(0)
+ await expect(popup.locator('.selected-record-row')).toBeVisible()
+ await expect(popup.locator('.rec-match-badge')).toBeVisible() // in SelectedRecordRow
+ })
+
+ test('fuzzy match row shows URL text and pencil; clicking transitions to waiting', async ({ context }) => {
+ const { login, portpass, bookmarkletUrl } = await setupAutofillTest(context)
+ await createRecord(portpass, {
+ title: 'Other Page', autotype: '\\u\\t\\p',
+ url: 'http://localhost:5173/different-path',
+ })
+
+ const popup = await activateBookmarklet(login, bookmarkletUrl, { clickRow: false })
+ await popup.locator('.rec-row').first().waitFor({ timeout: 5000 })
+ await expect(popup.locator('.rec-url').first()).toBeVisible()
+ await expect(popup.locator('.rec-pencil').first()).toBeVisible()
+ // Clicking the row transitions to waiting.
+ await popup.locator('.rec-row').first().click()
+ await expect(popup.locator('.selected-record-row')).toBeVisible({ timeout: 5000 })
+ })
+
+ test('record name and URL have title attributes for overflow tooltip', async ({ context }) => {
+ const { login, portpass, bookmarkletUrl } = await setupAutofillTest(context)
+ await createRecord(portpass, {
+ title: 'A Very Long Record Name That Will Overflow The Column',
+ autotype: '\\u\\t\\p',
+ url: 'http://localhost:5173/a-very-long-path-that-will-overflow',
+ })
+
+ const popup = await activateBookmarklet(login, bookmarkletUrl, { clickRow: false })
+ await popup.locator('.rec-row').first().waitFor({ timeout: 5000 })
+ const nameTitle = await popup.locator('.rec-name').first().getAttribute('title')
+ const urlTitle = await popup.locator('.rec-url').first().getAttribute('title')
+ expect(nameTitle).toBe('A Very Long Record Name That Will Overflow The Column')
+ expect(urlTitle).toBeTruthy()
+ })
+
+ test('record without autotype sequence still reaches waiting phase with default sequence', async ({ 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')
+ // 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()
+
+ const popup = await activateBookmarklet(login, bookmarkletUrl)
+ await expect(popup.locator('.selected-record-row')).toContainText('No Autotype', { timeout: 5000 })
+ })
+
+})
diff --git a/pwa/tests/clipboard.spec.ts b/pwa/tests/clipboard.spec.ts
index 33dcfe9..c23d5b7 100644
--- a/pwa/tests/clipboard.spec.ts
+++ b/pwa/tests/clipboard.spec.ts
@@ -96,8 +96,6 @@ test.describe('Clipboard autoclear', () => {
test('does not clear on return if clipboard was overwritten while backgrounded', async ({ page }) => {
await copyPassword(page)
- expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(FIRST_PASSWORD)
-
await simulateHide(page)
await page.waitForTimeout(400) // timer fires while hidden
diff --git a/pwa/tests/concurrent_save.spec.ts b/pwa/tests/concurrent_save.spec.ts
new file mode 100644
index 0000000..b13ecd9
--- /dev/null
+++ b/pwa/tests/concurrent_save.spec.ts
@@ -0,0 +1,125 @@
+import { test, expect } from '@playwright/test'
+import fs from 'fs'
+import { THREE_DB_PATH } from './helpers'
+
+// Opens the vault with a mock handle whose lastModified is controlled via
+// window.__fileState.lastModified, and write attempts are counted in
+// window.__fileState.writeCount.
+async function openVaultMutable(page: any) {
+ const b64 = fs.readFileSync(THREE_DB_PATH).toString('base64')
+
+ await page.addInitScript((b64data: string) => {
+ const bytes = Uint8Array.from(atob(b64data), c => c.charCodeAt(0))
+ ;(window as any).__fileState = { lastModified: 1000000, writeCount: 0 }
+ ;(window as any).showOpenFilePicker = async () => [{
+ name: 'three.dat',
+ getFile: async () => new File([bytes], 'three.dat', { lastModified: (window as any).__fileState.lastModified }),
+ queryPermission: async () => 'granted',
+ requestPermission: async () => 'granted',
+ createWritable: async () => ({
+ write: async () => { ;(window as any).__fileState.writeCount++ },
+ close: async () => {},
+ abort: async () => {},
+ }),
+ }]
+ ;(window as any).showSaveFilePicker = async () => ({
+ name: 'test.psafe3',
+ getFile: async () => new File([], 'test.psafe3', { lastModified: (window as any).__fileState.lastModified }),
+ createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }),
+ })
+ }, b64)
+
+ await page.goto('/portpass/')
+ await page.getByRole('button', { name: 'Open vault file' }).click()
+ await page.getByPlaceholder('Master password').fill('three3#;')
+ await page.getByRole('button', { name: 'Unlock' }).click()
+ const notNow = page.getByRole('button', { name: 'Not now' })
+ if (await notNow.isVisible({ timeout: 3000 }).catch(() => false)) await notNow.click()
+ await expect(page.getByPlaceholder('Search vault')).toBeVisible({ timeout: 10000 })
+}
+
+async function triggerSave(page: any) {
+ await page.locator('.record-row', { hasText: 'three entry 1' }).click()
+ await page.getByRole('button', { name: 'Edit' }).click()
+ await page.getByPlaceholder('e.g. Bank of America').fill('Renamed Entry')
+ await page.getByRole('button', { name: 'Save' }).click()
+ await page.waitForTimeout(300)
+}
+
+test.describe('Concurrent save protection', () => {
+
+ test('save proceeds without dialog when file is unmodified', async ({ page }) => {
+ let dialogAppeared = false
+ page.on('dialog', async (dialog: any) => { dialogAppeared = true; await dialog.accept() })
+
+ await openVaultMutable(page)
+ await triggerSave(page)
+
+ expect(dialogAppeared).toBe(false)
+ const writeCount = await page.evaluate(() => (window as any).__fileState.writeCount)
+ expect(writeCount).toBe(1)
+ })
+
+ test('shows conflict dialog when file modified externally', async ({ page }) => {
+ let dialogMessage = ''
+ page.on('dialog', async (dialog: any) => { dialogMessage = dialog.message(); await dialog.accept() })
+
+ await openVaultMutable(page)
+ await page.evaluate(() => { (window as any).__fileState.lastModified = 2000000 })
+ await triggerSave(page)
+
+ expect(dialogMessage).toContain('modified since it was loaded')
+ })
+
+ test('saves after user accepts conflict dialog', async ({ page }) => {
+ page.on('dialog', async (dialog: any) => { await dialog.accept() })
+
+ await openVaultMutable(page)
+ await page.evaluate(() => { (window as any).__fileState.lastModified = 2000000 })
+ await triggerSave(page)
+
+ const writeCount = await page.evaluate(() => (window as any).__fileState.writeCount)
+ expect(writeCount).toBe(1)
+ })
+
+ test('blocks save when user dismisses conflict dialog', async ({ page }) => {
+ page.on('dialog', async (dialog: any) => { await dialog.dismiss() })
+
+ await openVaultMutable(page)
+ await page.evaluate(() => { (window as any).__fileState.lastModified = 2000000 })
+ await triggerSave(page)
+
+ const writeCount = await page.evaluate(() => (window as any).__fileState.writeCount)
+ expect(writeCount).toBe(0)
+ })
+
+ test('updates tracked timestamp after save so a subsequent change is re-detected', async ({ page }) => {
+ let dialogCount = 0
+ page.on('dialog', async (dialog: any) => { dialogCount++; await dialog.accept() })
+
+ await openVaultMutable(page)
+
+ // First conflict — accept
+ await page.evaluate(() => { (window as any).__fileState.lastModified = 2000000 })
+ await triggerSave(page)
+ expect(dialogCount).toBe(1)
+
+ // getFile still returns 2000000 — same as what we just saved with, no new conflict
+ await page.locator('.record-row', { hasText: 'Renamed Entry' }).click()
+ await page.getByRole('button', { name: 'Edit' }).click()
+ await page.getByPlaceholder('e.g. Bank of America').fill('Second rename')
+ await page.getByRole('button', { name: 'Save' }).click()
+ await page.waitForTimeout(300)
+ expect(dialogCount).toBe(1) // no new dialog
+
+ // Bump again — third save should conflict again
+ await page.evaluate(() => { (window as any).__fileState.lastModified = 3000000 })
+ await page.locator('.record-row', { hasText: 'Second rename' }).click()
+ await page.getByRole('button', { name: 'Edit' }).click()
+ await page.getByPlaceholder('e.g. Bank of America').fill('Third rename')
+ await page.getByRole('button', { name: 'Save' }).click()
+ await page.waitForTimeout(300)
+ expect(dialogCount).toBe(2)
+ })
+
+})
diff --git a/pwa/tests/helpers.ts b/pwa/tests/helpers.ts
index 9cb2f32..4d82323 100644
--- a/pwa/tests/helpers.ts
+++ b/pwa/tests/helpers.ts
@@ -34,7 +34,7 @@ export async function openVault(
;(window as any).showOpenFilePicker = async () => [{
name: 'three.dat',
- getFile: async () => new File([bytes], 'three.dat'),
+ getFile: async () => new File([bytes], 'three.dat', { lastModified: 1000000 }),
queryPermission: async () => 'granted',
requestPermission: async () => 'granted',
createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }),
diff --git a/pwa/tests/multi_vault.spec.ts b/pwa/tests/multi_vault.spec.ts
index 9fa017a..292ce1f 100644
--- a/pwa/tests/multi_vault.spec.ts
+++ b/pwa/tests/multi_vault.spec.ts
@@ -112,11 +112,20 @@ test.describe('Multi-vault', () => {
await expect(page.locator('.vault-detail-stat-label').first()).toContainText('password')
})
- test('read-only secondary vault shows no Edit button for its records', async ({ page }) => {
+ test('read-only secondary vault shows Edit button with read-only chip', async ({ page }) => {
await openWithSecondary(page, /* readonly */ true)
await page.locator('.record-row', { hasText: 'Test entry' }).click()
await expect(page.locator('.record-title')).toHaveText('Test entry', { timeout: 3000 })
- await expect(page.getByRole('button', { name: 'Edit' })).not.toBeVisible()
+ await expect(page.getByRole('button', { name: 'Edit' })).toBeVisible()
+ await expect(page.locator('.record-pane-header .ro-chip')).toBeVisible()
+ })
+
+ test('read-only secondary vault edit form shows Save as button', async ({ page }) => {
+ await openWithSecondary(page, /* readonly */ true)
+ await page.locator('.record-row', { hasText: 'Test entry' }).click()
+ await expect(page.locator('.record-title')).toHaveText('Test entry', { timeout: 3000 })
+ await page.getByRole('button', { name: 'Edit' }).click()
+ await expect(page.getByRole('button', { name: 'Save as' })).toBeVisible()
})
test('primary vault records remain editable when secondary is read-only', async ({ page }) => {
diff --git a/pwa/tests/readonly_vault.spec.ts b/pwa/tests/readonly_vault.spec.ts
new file mode 100644
index 0000000..863bf90
--- /dev/null
+++ b/pwa/tests/readonly_vault.spec.ts
@@ -0,0 +1,245 @@
+import { test, expect, type Page } from '@playwright/test'
+import fs from 'fs'
+import { THREE_DB_PATH } from './helpers'
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+async function setupBiometricMock(page: Page) {
+ await page.addInitScript(() => {
+ const PRF = new Uint8Array(32).fill(0x42)
+ const CID = new Uint8Array(16).fill(0x01)
+ if (window.PublicKeyCredential) {
+ ;(window.PublicKeyCredential as any).isUserVerifyingPlatformAuthenticatorAvailable =
+ async () => true
+ }
+ navigator.credentials.create = async () => ({
+ rawId: CID.buffer,
+ getClientExtensionResults: () => ({ prf: { results: { first: PRF.buffer } } }),
+ }) as any
+ navigator.credentials.get = async () => ({
+ getClientExtensionResults: () => ({ prf: { results: { first: PRF.buffer } } }),
+ }) as any
+ })
+}
+
+// Opens three.dat via fallback file input (simulates iOS / no showOpenFilePicker).
+// Expects showOpenFilePicker to already be deleted via a prior addInitScript call,
+// or deletes it itself when no init scripts have been queued yet.
+// Dismisses the biometric offer if it appears.
+async function openFallbackVault(page: Page) {
+ await page.addInitScript(() => {
+ delete (window as any).showOpenFilePicker
+ })
+ await page.goto('/portpass/')
+
+ const [fileChooser] = await Promise.all([
+ page.waitForEvent('filechooser'),
+ page.getByRole('button', { name: 'Open vault file' }).click(),
+ ])
+ await fileChooser.setFiles(THREE_DB_PATH)
+
+ await page.getByPlaceholder('Master password').fill('three3#;')
+ await page.getByRole('button', { name: 'Unlock' }).click()
+
+ const notNow = page.getByRole('button', { name: 'Not now' })
+ if (await notNow.isVisible({ timeout: 3000 }).catch(() => false)) {
+ await notNow.click()
+ }
+
+ await expect(page.getByPlaceholder('Search vault')).toBeVisible({ timeout: 10000 })
+}
+
+async function lockVault(page: Page) {
+ await page.locator('.vault-pill').click()
+ await expect(page.locator('.vault-settings-body')).toBeVisible()
+ await page.getByRole('button', { name: /Lock vault/ }).click()
+ await expect(page.getByRole('button', { name: 'Open vault file' })).toBeVisible({ timeout: 5000 })
+}
+
+// ── tests ─────────────────────────────────────────────────────────────────────
+
+test.describe('Read-only vault (fallback file input)', () => {
+
+ test('landing page shows read-only note when showOpenFilePicker is unavailable', async ({ page }) => {
+ await page.addInitScript(() => {
+ delete (window as any).showOpenFilePicker
+ })
+ await page.goto('/portpass/')
+ await expect(page.getByText('Read-only', { exact: false })).toBeVisible()
+ })
+
+ test('records show Edit button with read-only chip', async ({ page }) => {
+ await openFallbackVault(page)
+ await page.locator('.record-row').first().click()
+ await expect(page.getByRole('button', { name: 'Edit' })).toBeVisible()
+ await expect(page.locator('.record-pane-header .ro-chip')).toBeVisible()
+ })
+
+ test('edit form shows Save as button instead of Save', async ({ page }) => {
+ await openFallbackVault(page)
+ await page.locator('.record-row').first().click()
+ await page.getByRole('button', { name: 'Edit' }).click()
+ await expect(page.getByRole('button', { name: 'Save as' })).toBeVisible()
+ })
+
+ test('edit form shows delete note for read-only vault', async ({ page }) => {
+ await openFallbackVault(page)
+ await page.locator('.record-row').first().click()
+ await page.getByRole('button', { name: 'Edit' }).click()
+ await expect(page.locator('.delete-ro-note')).toBeVisible()
+ })
+
+ test('desktop new button is visible for read-only vault', async ({ page }) => {
+ await openFallbackVault(page)
+ await expect(page.locator('.desktop-new-btn')).toBeVisible()
+ })
+
+ test('Save as calls showSaveFilePicker and vault becomes writable', async ({ page }) => {
+ await page.addInitScript(() => {
+ delete (window as any).showOpenFilePicker
+ ;(window as any).showSaveFilePicker = async () => {
+ ;(window as any).__savePickerCalled = true
+ return {
+ name: 'three-saved.psafe3',
+ createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }),
+ getFile: async () => new File([], 'three-saved.psafe3', { lastModified: Date.now() }),
+ }
+ }
+ })
+ await page.goto('/portpass/')
+ const [fc] = await Promise.all([
+ page.waitForEvent('filechooser'),
+ page.getByRole('button', { name: 'Open vault file' }).click(),
+ ])
+ await fc.setFiles(THREE_DB_PATH)
+ await page.getByPlaceholder('Master password').fill('three3#;')
+ await page.getByRole('button', { name: 'Unlock' }).click()
+ const notNow = page.getByRole('button', { name: 'Not now' })
+ if (await notNow.isVisible({ timeout: 3000 }).catch(() => false)) await notNow.click()
+ await expect(page.getByPlaceholder('Search vault')).toBeVisible({ timeout: 10000 })
+
+ await page.locator('.record-row').first().click()
+ await expect(page.locator('.record-pane-header .ro-chip')).toBeVisible()
+ await page.getByRole('button', { name: 'Edit' }).click()
+ await page.getByPlaceholder('e.g. Bank of America').fill('Saved entry')
+ await page.getByRole('button', { name: 'Save as' }).click()
+
+ // Edit closes and vault is no longer read-only after Save As
+ await expect(page.getByRole('button', { name: 'Edit' })).toBeVisible({ timeout: 5000 })
+ await expect(page.locator('.ro-chip')).not.toBeVisible()
+ const pickerCalled = await page.evaluate(() => !!(window as any).__savePickerCalled)
+ expect(pickerCalled).toBe(true)
+ })
+
+ test('triggers download when showSaveFilePicker is also unavailable', async ({ page }) => {
+ await page.addInitScript(() => {
+ delete (window as any).showOpenFilePicker
+ delete (window as any).showSaveFilePicker
+ })
+ await page.goto('/portpass/')
+ const [fc] = await Promise.all([
+ page.waitForEvent('filechooser'),
+ page.getByRole('button', { name: 'Open vault file' }).click(),
+ ])
+ await fc.setFiles(THREE_DB_PATH)
+ await page.getByPlaceholder('Master password').fill('three3#;')
+ await page.getByRole('button', { name: 'Unlock' }).click()
+ const notNow = page.getByRole('button', { name: 'Not now' })
+ if (await notNow.isVisible({ timeout: 3000 }).catch(() => false)) await notNow.click()
+ await expect(page.getByPlaceholder('Search vault')).toBeVisible({ timeout: 10000 })
+
+ await page.locator('.record-row').first().click()
+ await page.getByRole('button', { name: 'Edit' }).click()
+ await page.getByPlaceholder('e.g. Bank of America').fill('iOS save test')
+
+ const downloadPromise = page.waitForEvent('download')
+ await page.getByRole('button', { name: 'Save as' }).click()
+ const dl = await downloadPromise
+ expect(dl.suggestedFilename()).toMatch(/three\.dat$/)
+ })
+
+})
+
+test.describe('Biometric/PIN unlock on fallback path (iOS)', () => {
+
+ test('biometric offer appears after fallback vault unlock', async ({ page }) => {
+ await setupBiometricMock(page)
+ await page.addInitScript(() => {
+ delete (window as any).showOpenFilePicker
+ })
+ await page.goto('/portpass/')
+
+ const [fc] = await Promise.all([
+ page.waitForEvent('filechooser'),
+ page.getByRole('button', { name: 'Open vault file' }).click(),
+ ])
+ await fc.setFiles(THREE_DB_PATH)
+ await page.getByPlaceholder('Master password').fill('three3#;')
+ await page.getByRole('button', { name: 'Unlock' }).click()
+
+ await expect(page.getByText('Enable biometric/PIN unlock?')).toBeVisible({ timeout: 10000 })
+ })
+
+ test('biometric button appears on file re-selection when enrolled', async ({ page }) => {
+ await setupBiometricMock(page)
+ await page.addInitScript(() => {
+ delete (window as any).showOpenFilePicker
+ })
+ await page.goto('/portpass/')
+
+ const [fc1] = await Promise.all([
+ page.waitForEvent('filechooser'),
+ page.getByRole('button', { name: 'Open vault file' }).click(),
+ ])
+ await fc1.setFiles(THREE_DB_PATH)
+ await page.getByPlaceholder('Master password').fill('three3#;')
+ await page.getByRole('button', { name: 'Unlock' }).click()
+ await expect(page.getByText('Enable biometric/PIN unlock?')).toBeVisible({ timeout: 10000 })
+ await page.getByRole('button', { name: 'Enable biometric/PIN unlock' }).click()
+ await expect(page.getByPlaceholder('Search vault')).toBeVisible({ timeout: 10000 })
+
+ await lockVault(page)
+
+ // Re-select the same file — biometric button should appear
+ const [fc2] = await Promise.all([
+ page.waitForEvent('filechooser'),
+ page.getByRole('button', { name: 'Open vault file' }).click(),
+ ])
+ await fc2.setFiles(THREE_DB_PATH)
+
+ await expect(page.locator('.btn-biometric')).toBeVisible({ timeout: 5000 })
+ })
+
+ test('biometric unlock succeeds on fallback path', async ({ page }) => {
+ await setupBiometricMock(page)
+ await page.addInitScript(() => {
+ delete (window as any).showOpenFilePicker
+ })
+ await page.goto('/portpass/')
+
+ const [fc1] = await Promise.all([
+ page.waitForEvent('filechooser'),
+ page.getByRole('button', { name: 'Open vault file' }).click(),
+ ])
+ await fc1.setFiles(THREE_DB_PATH)
+ await page.getByPlaceholder('Master password').fill('three3#;')
+ await page.getByRole('button', { name: 'Unlock' }).click()
+ await expect(page.getByText('Enable biometric/PIN unlock?')).toBeVisible({ timeout: 10000 })
+ await page.getByRole('button', { name: 'Enable biometric/PIN unlock' }).click()
+ await expect(page.getByPlaceholder('Search vault')).toBeVisible({ timeout: 10000 })
+
+ await lockVault(page)
+
+ const [fc2] = await Promise.all([
+ page.waitForEvent('filechooser'),
+ page.getByRole('button', { name: 'Open vault file' }).click(),
+ ])
+ await fc2.setFiles(THREE_DB_PATH)
+
+ await expect(page.locator('.btn-biometric')).toBeVisible({ timeout: 5000 })
+ await page.locator('.btn-biometric').click()
+
+ await expect(page.getByPlaceholder('Search vault')).toBeVisible({ timeout: 10000 })
+ })
+
+})
diff --git a/pwa/tests/records.spec.ts b/pwa/tests/records.spec.ts
index 4756128..d379639 100644
--- a/pwa/tests/records.spec.ts
+++ b/pwa/tests/records.spec.ts
@@ -1,5 +1,5 @@
-import { test, expect } from '@playwright/test'
-import { openVault } from './helpers'
+import { test, expect, type Page } from '@playwright/test'
+import { openVault, createVault } from './helpers'
test.describe('Record list', () => {
@@ -221,3 +221,48 @@ test.describe('Context menu', () => {
})
})
+
+test.describe('URL scheme normalization', () => {
+
+ async function createBareUrlRecord(page: Page) {
+ await createVault(page)
+ await page.getByRole('button', { name: 'New', exact: true }).click()
+ await page.getByPlaceholder('e.g. Bank of America').fill('Bare URL Entry')
+ await page.getByLabel('URL').fill('yahoo.com')
+ await page.locator('input.mono').first().fill('secret')
+ await page.getByRole('button', { name: 'Save' }).click()
+ }
+
+ test('"Open URL" link in record detail prepends https:// to schemeless URL', async ({ page }) => {
+ await createBareUrlRecord(page)
+ await page.locator('.record-row', { hasText: 'Bare URL Entry' }).click()
+ await expect(page.getByRole('link', { name: 'Open URL' }))
+ .toHaveAttribute('href', 'https://yahoo.com')
+ })
+
+ test('context menu "Visit URL" opens schemeless URL with https://', async ({ page, context }) => {
+ await createBareUrlRecord(page)
+ await page.locator('.record-row', { hasText: 'Bare URL Entry' }).click({ button: 'right' })
+ await expect(page.locator('.ctx-menu')).toBeVisible()
+ const [newPage] = await Promise.all([
+ context.waitForEvent('page'),
+ page.locator('.ctx-menu').getByText('Visit URL').click(),
+ ])
+ expect(newPage.url()).toContain('yahoo.com')
+ await newPage.close()
+ })
+
+ test('Enter key on selected record opens schemeless URL with https://', async ({ page, context }) => {
+ await createBareUrlRecord(page)
+ await page.getByPlaceholder('Search vault').click()
+ await page.keyboard.press('ArrowDown')
+ await expect(page.locator('.record-title')).toHaveText('Bare URL Entry')
+ const [newPage] = await Promise.all([
+ context.waitForEvent('page'),
+ page.keyboard.press('Enter'),
+ ])
+ expect(newPage.url()).toContain('yahoo.com')
+ await newPage.close()
+ })
+
+})
diff --git a/pwa/tests/unlock.spec.ts b/pwa/tests/unlock.spec.ts
index 1543beb..ee57baf 100644
--- a/pwa/tests/unlock.spec.ts
+++ b/pwa/tests/unlock.spec.ts
@@ -44,6 +44,30 @@ 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 })
+ // Vault should be marked read-only
+ await page.locator('.vault-pill').click()
+ await expect(page.locator('.vault-badge-ro')).toBeVisible()
+ })
+
test('Enter key submits password', async ({ page }) => {
const data = [...fs.readFileSync(THREE_DB_PATH)]
await page.addInitScript((fileData: number[]) => {
diff --git a/pwa/tests/vault_sheet.spec.ts b/pwa/tests/vault_sheet.spec.ts
index 3a1630d..73a5ef6 100644
--- a/pwa/tests/vault_sheet.spec.ts
+++ b/pwa/tests/vault_sheet.spec.ts
@@ -105,6 +105,130 @@ 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()
+ })
+
+ async function createBookmarklet(page) {
+ await page.locator('.vault-pill').click()
+ await page.getByRole('button', { name: '+ New bookmarklet' }).click()
+ // wait for key generation (chip activates once keys are ready + name is entered)
+ await page.getByPlaceholder('e.g. Chrome — work profile').fill('Test')
+ await expect(page.locator('.vs-bookmarklet-chip:not(.chip-inactive)')).toBeVisible({ timeout: 5000 })
+ }
+
+ test('bookmarklet chip has a javascript: href', async ({ page }) => {
+ await openVault(page)
+ await createBookmarklet(page)
+ const href = await page.locator('.vs-bookmarklet-chip').getAttribute('href')
+ expect(href).toMatch(/^javascript:/)
+ })
+
+ test('bookmarklet href contains the Portpass origin', async ({ page }) => {
+ await openVault(page)
+ await createBookmarklet(page)
+ const href = await page.locator('.vs-bookmarklet-chip').getAttribute('href')
+ expect(decodeURIComponent(href ?? '')).toContain('localhost:5173')
+ })
+
+ test('clicking the chip does not navigate away', async ({ page }) => {
+ await openVault(page)
+ await createBookmarklet(page)
+ await page.locator('.vs-bookmarklet-chip').click()
+ await expect(page.locator('.modal-title', { hasText: 'New autofill bookmarklet' })).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 createBookmarklet(page)
+ await page.locator('.vs-copy-link-btn').click()
+ await expect(page.locator('.vs-copy-link-btn')).toContainText('Copied!')
+ 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 createBookmarklet(page)
+ await page.locator('.vs-copy-link-btn').click()
+ await expect(page.locator('.vs-copy-link-btn')).toContainText('Copied!')
+ await expect(page.locator('.vs-copy-link-btn')).toHaveText('Copy link', { timeout: 3000 })
+ })
+
+ test('modal shows name input, warning banner, and two-column install layout', async ({ page }) => {
+ await openVault(page)
+ await page.locator('.vault-pill').click()
+ await page.getByRole('button', { name: '+ New bookmarklet' }).click()
+ await expect(page.locator('.modal-title', { hasText: 'New autofill bookmarklet' })).toBeVisible()
+ await expect(page.getByPlaceholder('e.g. Chrome — work profile')).toBeVisible()
+ await expect(page.locator('.vs-install-warning')).toBeVisible()
+ await expect(page.locator('.vs-install-col-drag')).toBeVisible()
+ await expect(page.locator('.vs-install-col-copy')).toBeVisible()
+ })
+
+ test('chip is inactive and copy button disabled before name is entered', async ({ page }) => {
+ await openVault(page)
+ await page.locator('.vault-pill').click()
+ await page.getByRole('button', { name: '+ New bookmarklet' }).click()
+ await expect(page.locator('.vs-bookmarklet-chip.chip-inactive')).toBeVisible({ timeout: 5000 })
+ await expect(page.locator('.vs-copy-link-btn')).toBeDisabled()
+ await page.getByPlaceholder('e.g. Chrome — work profile').fill('test')
+ await expect(page.locator('.vs-bookmarklet-chip:not(.chip-inactive)')).toBeVisible({ timeout: 5000 })
+ await expect(page.locator('.vs-copy-link-btn')).toBeEnabled()
+ })
+
+ test('"Save and Close" is disabled until a name is entered', async ({ page }) => {
+ await openVault(page)
+ await page.locator('.vault-pill').click()
+ await page.getByRole('button', { name: '+ New bookmarklet' }).click()
+ await expect(page.locator('.vs-close-btn')).toBeDisabled()
+ await page.getByPlaceholder('e.g. Chrome — work profile').fill('My bookmark')
+ await expect(page.locator('.vs-close-btn')).toBeEnabled({ timeout: 5000 })
+ })
+
+ test('X button cancels without saving when chip unused', async ({ page }) => {
+ await openVault(page)
+ await page.locator('.vault-pill').click()
+ await page.getByRole('button', { name: '+ New bookmarklet' }).click()
+ await page.locator('.vs-modal-x').click()
+ await expect(page.locator('.modal-title', { hasText: 'New autofill bookmarklet' })).not.toBeVisible()
+ await expect(page.locator('.delegate-name')).not.toBeVisible()
+ })
+
+ test('globe tip disclosure expands and collapses', async ({ page }) => {
+ await openVault(page)
+ await createBookmarklet(page)
+ await expect(page.locator('.vs-globe-tip-body')).not.toBeVisible()
+ await page.locator('.vs-globe-tip-toggle').click()
+ await expect(page.locator('.vs-globe-tip-body')).toBeVisible()
+ await page.locator('.vs-globe-tip-toggle').click()
+ await expect(page.locator('.vs-globe-tip-body')).not.toBeVisible()
+ })
+
+ test('"Save and Close" saves the delegate and dismisses the modal', async ({ page }) => {
+ await openVault(page)
+ await createBookmarklet(page)
+ await expect(page.locator('.modal-title', { hasText: 'New autofill bookmarklet' })).toBeVisible()
+ await page.locator('.vs-close-btn').click()
+ await expect(page.locator('.modal-title', { hasText: 'New autofill bookmarklet' })).not.toBeVisible()
+ await expect(page.locator('.delegate-name', { hasText: 'Test' })).toBeVisible()
+ })
+
+ 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/pwa/tests/write_ops.spec.ts b/pwa/tests/write_ops.spec.ts
index b32aa29..ebc1e28 100644
--- a/pwa/tests/write_ops.spec.ts
+++ b/pwa/tests/write_ops.spec.ts
@@ -90,3 +90,57 @@ test.describe('Record write operations', () => {
})
})
+
+test.describe('Notes reveal in edit view', () => {
+
+ test('notes are masked when opening edit view for a record with notes', async ({ page }) => {
+ await openVault(page)
+ await page.locator('.record-row', { hasText: 'three entry 1' }).click()
+ await page.getByRole('button', { name: 'Edit' }).click()
+
+ await expect(page.locator('.notes-masked')).toBeVisible()
+ const text = await page.locator('.notes-masked').textContent()
+ expect(text).toMatch(/^•+$/)
+ })
+
+ test('reveal button loads and shows actual notes content', async ({ page }) => {
+ await openVault(page)
+ await page.locator('.record-row', { hasText: 'three entry 1' }).click()
+ await page.getByRole('button', { name: 'Edit' }).click()
+
+ await page.getByLabel('Reveal notes').click()
+
+ const textarea = page.locator('textarea')
+ await expect(textarea).toBeVisible()
+ const value = await textarea.inputValue()
+ expect(value.length).toBeGreaterThan(0)
+ expect(value).not.toMatch(/^•+$/)
+ })
+
+ test('hide button returns notes to masked state', async ({ page }) => {
+ await openVault(page)
+ await page.locator('.record-row', { hasText: 'three entry 1' }).click()
+ await page.getByRole('button', { name: 'Edit' }).click()
+
+ await page.getByLabel('Reveal notes').click()
+ await expect(page.locator('textarea')).toBeVisible()
+
+ await page.getByLabel('Hide notes').click()
+ await expect(page.locator('.notes-masked')).toBeVisible()
+ await expect(page.locator('textarea')).not.toBeVisible()
+ })
+
+ test('new record with no notes shows textarea directly with no eye icon', async ({ page }) => {
+ await createVault(page)
+ await page.getByRole('button', { name: 'New', exact: true }).click()
+ await page.getByPlaceholder('e.g. Bank of America').fill('No Notes Entry')
+ const pwInput = page.locator('input.mono').first()
+ await pwInput.fill('secret')
+
+ // Notes textarea should be present without any masking or eye icon
+ await expect(page.locator('textarea')).toBeVisible()
+ await expect(page.locator('.notes-masked')).not.toBeVisible()
+ await expect(page.getByLabel('Reveal notes')).not.toBeVisible()
+ })
+
+})
diff --git a/pwa/vite.config.js b/pwa/vite.config.js
index a897de4..798312f 100644
--- a/pwa/vite.config.js
+++ b/pwa/vite.config.js
@@ -3,6 +3,24 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
import { VitePWA } from 'vite-plugin-pwa'
import { execSync } from 'child_process'
import { copyFileSync, existsSync } from 'fs'
+import { minify } from 'terser'
+
+function bookmarkletPlugin() {
+ return {
+ name: 'bookmarklet-iife-minify',
+ async transform(code, id) {
+ if (!id.endsWith('bookmarklet.js')) return null
+ const marker = 'function DELEGATE_BOOKMARKLET_IIFE'
+ const fnStart = code.indexOf(marker)
+ if (fnStart === -1) return null
+ const result = await minify(code.slice(fnStart), { compress: true, mangle: true })
+ return {
+ code: code.replace('DELEGATE_BOOKMARKLET_IIFE.toString()', JSON.stringify(result.code)),
+ map: null,
+ }
+ }
+ }
+}
let version = '0.0.0-dev'
try {
@@ -13,6 +31,7 @@ try {
export default defineConfig({
plugins: [
+ bookmarkletPlugin(),
svelte(),
VitePWA({
registerType: 'autoUpdate',
@@ -50,11 +69,35 @@ 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}'],
+ globPatterns: ['**/*.{js,css,html,ico,png,svg,wasm,gz,webmanifest}'],
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 }
+ }]
}
})
],
diff --git a/pwsafe/db.go b/pwsafe/db.go
index 26e05bc..64cc7d3 100644
--- a/pwsafe/db.go
+++ b/pwsafe/db.go
@@ -25,7 +25,7 @@ type V3 struct {
Iter uint32 //the number of iterations on the hash function to create the stretched key
LastMod time.Time
LastSavePath string
- Records map[string]Record //the key is the record title
+ Records map[string]Record // keyed by UUID hex (see uuidKey)
Salt [32]byte
StretchedKey [sha256.Size]byte
}
@@ -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"}
diff --git a/pwsafe/encrypt.go b/pwsafe/encrypt.go
index 56356ba..1dfdd66 100644
--- a/pwsafe/encrypt.go
+++ b/pwsafe/encrypt.go
@@ -116,37 +116,36 @@ func (db *V3) marshalRecords() (records []byte, dataBytes []byte, err error) {
}
// pseudoRandomBytes generates a slice of bytes filled with pseudo random data
-func pseudoRandomBytes(size int) (r []byte) {
- r = make([]byte, size)
- _, err := rand.Read(r)
- if err != nil {
- // Fallback to zero padding if rand fails, though this should be rare/impossible in most envs
- // Best effort for padding
- return r
+func pseudoRandomBytes(size int) []byte {
+ r := make([]byte, size)
+ if _, err := rand.Read(r); err != nil {
+ panic("crypto/rand unavailable: " + err.Error())
}
return r
}
// re-calculate and add to the db new encryption key and hmac key then encrypt with and return the encrypted bytes
func (db *V3) refreshEncryptedKeys(buf io.Writer) error {
- _, err := rand.Read(db.EncryptionKey[:])
- if err != nil {
+ var encKey [32]byte
+ var hmacKey [32]byte
+ if _, err := rand.Read(encKey[:]); err != nil {
return err
}
- _, err = rand.Read(db.HMACKey[:])
- if err != nil {
+ if _, err := rand.Read(hmacKey[:]); err != nil {
return err
}
keyTwoFish, err := twofish.NewCipher(db.StretchedKey[:])
if err != nil {
return err
}
- for _, block := range [][]byte{db.EncryptionKey[:16], db.EncryptionKey[16:], db.HMACKey[:16], db.HMACKey[16:]} {
+ for _, block := range [][]byte{encKey[:16], encKey[16:], hmacKey[:16], hmacKey[16:]} {
encrypted := make([]byte, 16)
keyTwoFish.Encrypt(encrypted, block)
if err := binary.Write(buf, binary.LittleEndian, encrypted); err != nil {
return err
}
}
+ db.EncryptionKey = encKey
+ db.HMACKey = hmacKey
return nil
}
diff --git a/pwsafe/record.go b/pwsafe/record.go
index dbe2ed3..a055773 100644
--- a/pwsafe/record.go
+++ b/pwsafe/record.go
@@ -50,11 +50,18 @@ const (
recordEndOfEntry = 0xff
)
+// customFieldProp holds one raw property from a custom field entry (for unknown IDs).
+type customFieldProp struct {
+ id byte
+ val string
+}
+
// CustomField is one entry in a record's custom text fields (field 0x30).
type CustomField struct {
- Name string `json:"Name"`
- Value string `json:"Value"`
- Sensitive bool `json:"Sensitive"`
+ Name string `json:"Name"`
+ Value string `json:"Value"`
+ Sensitive bool `json:"Sensitive"`
+ UnknownProps []customFieldProp `json:"-"` // forward compatibility: unknown property IDs
}
// Record The primary type for password DB entries
@@ -290,11 +297,7 @@ func (r *Record) marshal() ([]byte, []byte, error) {
}
if len(r.CustomFields) > 0 {
- cfs := r.CustomFields
- if len(cfs) > 9 {
- cfs = cfs[:9]
- }
- appendField(recordCustomTextField, marshalCustomFields(cfs))
+ appendField(recordCustomTextField, marshalCustomFields(r.CustomFields))
}
if len(r.UnknownFields) > 0 {
@@ -349,14 +352,13 @@ func parseCustomFields(s string) []CustomField {
cur.Value = v
case 0x03:
cur.Sensitive = len(v) == 1 && v[0] == '1'
+ default:
+ cur.UnknownProps = append(cur.UnknownProps, customFieldProp{byte(pID), v})
}
}
if cur != nil && cur.Name != "" {
fields = append(fields, *cur)
}
- if len(fields) > 9 {
- fields = fields[:9]
- }
return fields
}
@@ -374,6 +376,9 @@ func marshalCustomFields(fields []CustomField) []byte {
} else {
sb.WriteString("0300010")
}
+ for _, p := range cf.UnknownProps {
+ sb.WriteString(fmt.Sprintf("%02x%04x%s", p.id, len(p.val), p.val))
+ }
}
return []byte(sb.String())
}
diff --git a/pwsafe/record_test.go b/pwsafe/record_test.go
index 0201d7b..a263d38 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,103 @@ 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 TestCustomField_UnknownPropsRoundTrip(t *testing.T) {
+ t.Run("unknown property ID survives parse/marshal cycle", func(t *testing.T) {
+ // Encode a custom field entry that includes a future property 0x04.
+ raw := "010005hello020005world0300010" + "040003abc"
+ fields := parseCustomFields(raw)
+ assert.Len(t, fields, 1)
+ assert.Equal(t, "hello", fields[0].Name)
+ assert.Equal(t, "world", fields[0].Value)
+ assert.False(t, fields[0].Sensitive)
+ assert.Len(t, fields[0].UnknownProps, 1)
+ assert.Equal(t, byte(0x04), fields[0].UnknownProps[0].id)
+ assert.Equal(t, "abc", fields[0].UnknownProps[0].val)
+
+ marshaled := string(marshalCustomFields(fields))
+ reparsed := parseCustomFields(marshaled)
+ assert.Len(t, reparsed, 1)
+ assert.Equal(t, fields[0].Name, reparsed[0].Name)
+ assert.Equal(t, fields[0].Value, reparsed[0].Value)
+ assert.Equal(t, fields[0].Sensitive, reparsed[0].Sensitive)
+ assert.Equal(t, fields[0].UnknownProps, reparsed[0].UnknownProps)
+ })
+
+ t.Run("unknown property survives vault file write-read cycle", func(t *testing.T) {
+ db := NewV3("test", "password")
+ cf := CustomField{
+ Name: "key",
+ Value: "val",
+ UnknownProps: []customFieldProp{{0x04, "future"}},
+ }
+ rec := Record{Title: "Site", Password: "pass", CustomFields: []CustomField{cf}}
+ uuid := db.SetRecord(rec)
+
+ savePath := "./test_dbs/unknown_prop_test.dat"
+ err := WritePWSafeFile(db, savePath)
+ defer os.Remove(savePath)
+ assert.NoError(t, err)
+
+ loaded, err := OpenPWSafeFile(savePath, "password")
+ assert.NoError(t, err)
+ got := loaded.Records[uuid].CustomFields
+ assert.Len(t, got, 1)
+ assert.Equal(t, "key", got[0].Name)
+ assert.Equal(t, "val", got[0].Value)
+ assert.Len(t, got[0].UnknownProps, 1)
+ assert.Equal(t, byte(0x04), got[0].UnknownProps[0].id)
+ assert.Equal(t, "future", got[0].UnknownProps[0].val)
+ })
+}
+
+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")
+ })
+}