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* Screenshots of mobile version **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. + +Screenshots of autofill operation + +### 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. + +Screenshot of autofill bookmarklet creation + +### 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: + +Screenshot of autofill sequence configuration + +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 + + + + + +
+
+ + Portpass + +
+ +
+ CLICK TO 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 @@
{isNew ? 'New' : 'Edit'}
- +
{#if isDesktop} @@ -268,7 +406,7 @@
+ 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} +
+
+
+ Autofill sequence + {#if !hasDelegates} + + {/if} +
+
+ {#if draft.Autotype !== savedAutotype} + + {/if} +
+ + +
+
+
+ {#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} + +
+ {#if i < tokens.length - 1} + { e.preventDefault(); e.stopPropagation(); dropIdx = i + 1 }}>→ + {/if} + {/each} + {#if dragIdx !== -1 && dropIdx === tokens.length} +
+ {/if} +
+ +
+
+ Fields +
+ + + + + {#each customFields as cf, cfi} + {#if cf.Name.trim()} + + {/if} + {/each} +
+
+
+ Navigate +
+ + + +
+
+
+ Other +
+ + +
+
+ {#if activeMiniForm === 'literal'} +
+ Text: + e.key === 'Enter' && (e.preventDefault(), addLiteralToken())} + autocomplete="off" spellcheck="false"/> + + +
+ {/if} + {#if activeMiniForm === 'wait'} +
+ Wait + e.key === 'Enter' && (e.preventDefault(), addWaitToken())}/> +
+ + +
+ + +
+ {/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} + + {:else if autotypeWarning} + + {/if} +
+ {/if} +