Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 22 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ You decide where to store your vault file: on-device, self-hosted, or in a cloud
* runs on all your devices: **mobile, tablet, and desktop**
* stores each vault as a file on your device, for easy **sync/backup/sharing**
* unlocks vault files using WebAuthn methods: **fingerprint, face recognition, and PIN**
* **fills login forms on websites** using a bookmarklet in two clicks (desktop only). No browser extension with excessive permissions, no copying secrets to the system clipboard
* **fills login forms on websites** using a bookmarklet picker (desktop only). No browser extension with excessive permissions, no copying secrets to the system clipboard
* generates strong passwords
* keeps a history of previous password values
* generates **one-time codes (TOTP)** for two-factor authentication (2FA)
Expand All @@ -32,7 +32,7 @@ You decide where to store your vault file: on-device, self-hosted, or in a cloud

## Installation

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.
Portpass runs in a browser (Chrome, Safari, Firefox, Edge) and can be installed as an app 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)
Expand Down Expand Up @@ -130,52 +130,54 @@ The Chrome browser routes biometric/PIN unlock setup through [Google Password Ma

## 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.
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 picker stays inside 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
2. **Click the bookmarklet** in your browser's bookmarks bar
3. Choose a matching password if Portpass does not select one automatically
4. In the popup, leave **Autofill** selected or choose one field value to insert
5. Click the destination field on the webpage

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.
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. You can also insert one field at a time, reveal sensitive values when needed, or search all unlocked vaults from the picker.

<img src="https://github.com/user-attachments/assets/b8c833f3-39a8-412b-bbd3-ac957f675861" width="80%" alt="Screenshots of autofill operation">
<img src="https://github.com/user-attachments/assets/46de7dff-c3e1-4dc4-a6e4-fd9088417033" width="80%" alt="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).
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 and automatically opens the credential panel when there is one exact match. The panel defaults to the record's Autofill sequence (fill username -> Tab -> fill password -> Submit), but you can select an individual field instead. After you click the destination field on the webpage, Portpass fills the selected value or sequence directly.

The bookmarklet itself is not a secret and contains no private key. It opens Portpass's `autofill.html` popup, which holds a non-extractable signing key in Portpass-origin browser storage for that profile. Portpass stores the matching public key as a revocable autofill delegate. Requests are signed by the popup, and credential replies are encrypted to a fresh per-session key.
The bookmarklet itself is not a secret and contains no private key. It opens Portpass's `autofill.html` popup, which holds a non-extractable signing key in Portpass-origin browser storage for that profile. Portpass stores the matching public key as a revocable autofill delegate. Requests are signed by the popup, and credential replies are encrypted to a fresh per-session key. The popup initially receives field metadata; sensitive values are requested only when you reveal them or insert them.

No credentials pass through the clipboard at any point -- this matters on Windows and Linux, where clipboard contents can be read by any running process, and in browsers where extensions with clipboard permission could read a copied password before it is pasted.

In same-profile autofill, Portpass searches all unlocked vaults for URLs that match the current web page. It compares the canonical version, removing "www." as well as url parameters after the "?" and "#" characters. It looks for exact matches first, then falls back to offering the current open record (if one is open) as well as up to 5 near matches. If one of the non-exact matches is chosen, you can instruct Portpass to update that entry's URL in the vault to match the current webpage URL to accelerate future Autofill requests on this webpage. The "near match" method uses edit distance (Levenshtein) showing the five closest matches within a distance of 5 edits.

In cross-profile autofill, credential release is stricter: Portpass only sends credentials over the relay for exact authorized URL matches. If there is no exact match, it returns metadata only, such as the count of near matches, and prompts you to view or update the record inside Portpass.

### Setting up autofill
### Setting up an autofill bookmarklet

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.
3. Under **Autofill**, click **+ Create a new autofill bookmarklet**, then **+ Add same-profile bookmarklet**.
4. Give it a name (e.g. "Chrome — main profile"), drag the chip to your browser's bookmarks bar, and click **Save and Close**. If the bar is hidden, click **Copy link** and add the bookmark manually.

<img src="https://github.com/user-attachments/assets/468bd877-581e-4d07-a350-713953aac0c1" width="35%" alt="Screenshot of autofill sequence configuration">

For cross-profile setup, start the [switchboard](https://github.com/dbro/switchboard) as a background service on your machine before using the bookmarklet. See the repo README for instructions to run switchboard automatically in the background.

Cross-profile setup uses a separate pairing ceremony because the filling profile and the clean Portpass profile do not share browser storage:

1. In the filling browser profile, open `https://dbro.github.io/portpass/autofill.html?pair=1`.
2. Click **Create pairing token** and copy the `ppair1_...` token.
3. In the clean Portpass profile, open vault settings -> **Autofill** -> **Add autofill profile**.
4. Paste the token, compare the short pairing code, and click **Pair profile**.
5. Drag or copy the generated bookmarklet into the filling profile's bookmarks bar.
1. In the clean Portpass profile, open vault settings -> **Autofill** -> **+ Create a new autofill bookmarklet**.
2. Under **Cross-profile pairing**, open the pairing-page URL shown in your everyday browser profile.
3. In the everyday profile, name and install the bookmarklet, then copy the `ppair1_...` token.
4. Back in the clean Portpass profile, click **+ Pair everyday profile**, paste the token, compare the short pairing code, and click **Pair everyday profile**.

<img src="https://github.com/user-attachments/assets/677ce196-f282-4de6-8506-407905e077c4" width="35%" alt="Screenshot of autofill bookmarklet creation">

### Autofill form field configuration
### Autofill sequence configurations for websites

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:

<img src="https://github.com/user-attachments/assets/904b1e76-d650-4673-9054-6d6a17ae431a" width="35%" alt="Screenshot of autofill sequence configuration">
<img src="https://github.com/user-attachments/assets/677ce196-f282-4de6-8506-407905e077c4" width="35%" alt="Screenshot of autofill bookmarklet creation">

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).

Expand Down
13 changes: 8 additions & 5 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Both modes use the **delegate model**: each paired autofill profile has an ECDSA

## Biometric/PIN unlock

The optional biometric/PIN unlock feature uses your fingerprint, face, or PIN to encrypt your master password on-device. The encrypted password is stored in your browser's local storage (IndexedDB), and the decryption key is derived from your biometric via the WebAuthn PRF extension and it never leaves your device. When biometric/PIN unlock is used, the master password is decrypted from your device's secure storage and passed directly to the vault-opening function. Portpass explicitly clears the master password variable immediately after the vault opens and it is not retained in JavaScript memory beyond that single call.
The optional biometric/PIN unlock feature uses your fingerprint, face, or PIN to encrypt your master password on-device. The encrypted password is stored in your browser's local storage (IndexedDB), and the decryption key is derived from your biometric via the WebAuthn PRF extension and it never leaves your device. When biometric/PIN unlock is used, the master password is decrypted from your device's secure storage and passed directly to the vault-opening function. Portpass drops JavaScript references to the master password immediately after use, though JavaScript strings cannot be wiped in place by application code.

An attacker with physical access to your device and browser profile could extract the ciphertext from IndexedDB, but cannot decrypt it without the biometric credential held in your device's secure hardware.

Expand All @@ -109,7 +109,7 @@ If your master password changes, re-enroll biometric/PIN unlock. The old enrollm

## Secondary vault associations

When secondary vaults are linked to a primary vault, their master passwords are stored encrypted in your browser's IndexedDB. The encryption uses AES-256-GCM with a key derived inside the WASM engine from the primary vault's stretched key and this key never appears in JavaScript. JavaScript only ever handles ciphertext and nonces, which are not sensitive.
When secondary vaults are linked to a primary vault, their master passwords are stored encrypted in your browser's IndexedDB. The encryption uses AES-256-GCM with a key derived inside the WASM engine from the primary vault's stretched key and this key never appears in JavaScript. JavaScript receives a decrypted secondary master password only transiently when opening that secondary vault, then drops references after use; the reactive application state stores vault metadata, not secondary master passwords.

**Changing the primary vault's master password** generates a new encryption key and any previously linked secondary vaults will no longer auto-unlock and must be re-linked.

Expand All @@ -123,7 +123,7 @@ Portpass automatically clears the clipboard 30 seconds after you copy a password

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.
Portpass's 30-second autoclear reduces the exposure window, but it cannot prevent an actively polling adversary from reading the clipboard before the clear fires. Autofill avoids this clipboard exposure entirely by writing values directly into webpage fields.

**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.

Expand Down Expand Up @@ -161,6 +161,8 @@ This prevents the masquerade attack: a malicious page script can open the popup

After authentication, credentials are encrypted with ECDH P-256 + AES-256-GCM. The session key is ephemeral -- a fresh ECDH key pair is generated for each autofill session. Credential replies are bound to the request ID, previous message hash, delegate ID, and recipient before encryption. Credentials in transit are ciphertext only; no key material appears on the channel.

The picker initially receives field metadata rather than every secret value. Sensitive field values are fetched only when the user reveals a field or arms it for insertion. This reduces the time secrets are retained by the popup.

### Cross-profile relay server

The switchboard (`localhost:7577`) is a dumb pipe: it stores and forwards signed requests and encrypted replies without needing to inspect their contents. It binds to `127.0.0.1` only and is not accessible over the network. Portpass treats the relay as untrusted transport that may observe metadata, delay, drop, replay, reorder, or inject packets. Signatures, timestamps, nonces, reply binding, and encryption provide the security properties; the relay itself is not trusted.
Expand All @@ -177,9 +179,10 @@ Cross-profile credential release is intentionally stricter than same-profile aut

## Implementation notes

- **Memory**: the salted password buffer used during key stretching is zeroed immediately after use.
- **Memory**: the salted password buffer used during key stretching is zeroed immediately after use. When a vault closes or is replaced, Portpass also best-effort wipes mutable key buffers and byte slices before releasing the in-memory database. Go and JavaScript strings cannot be wiped in place by application code.
- **Timing**: password comparison uses a constant-time XOR accumulator to prevent timing side-channel attacks.
- **KDF**: the vault key is derived using SHA-256 iterated 262,144 times (the [pwsafe v3 format minimum](https://github.com/pwsafe/pwsafe/blob/master/docs/formatV3.txt)), making offline brute-force attacks significantly more costly.
- **Authentication before replacement**: Portpass parses and authenticates an opened vault into a temporary database, then replaces the live in-memory database only after the file HMAC has been verified.
- **KDF**: new vaults derive keys using SHA-256 iterated 262,144 times (the [pwsafe v3 format minimum](https://github.com/pwsafe/pwsafe/blob/master/docs/formatV3.txt)). Existing vaults retain their stored iteration count. Portpass rejects zero iterations and counts above 10,000,000 to prevent malformed files from causing excessive work during unlock.

---

Expand Down
28 changes: 27 additions & 1 deletion cmd/wasm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ func openDB(this js.Value, args []js.Value) interface{} {
uuid := vaultUUID(newDB)
if _, exists := databases[uuid]; !exists {
databases[uuid] = newDB
} else {
newDB.Wipe()
}

result, _ := json.Marshal(map[string]string{"uuid": uuid})
Expand All @@ -73,6 +75,9 @@ func createDatabase(this js.Value, args []js.Value) interface{} {
}
newDB := pwsafe.NewV3("", args[0].String())
uuid := vaultUUID(newDB)
if old, exists := databases[uuid]; exists {
old.Wipe()
}
databases[uuid] = newDB
result, _ := json.Marshal(map[string]string{"uuid": uuid})
return string(result)
Expand All @@ -83,7 +88,11 @@ func closeDB(this js.Value, args []js.Value) interface{} {
if len(args) != 1 {
return "invalid arguments: expected (vaultUuid)"
}
delete(databases, args[0].String())
uuid := args[0].String()
if db, ok := databases[uuid]; ok {
db.Wipe()
delete(databases, uuid)
}
return nil
}

Expand Down Expand Up @@ -452,6 +461,22 @@ func searchRecords(this js.Value, args []js.Value) interface{} {
return string(jsonData)
}

func searchRecordResults(this js.Value, args []js.Value) interface{} {
db, _, ok := getDB(args)
if !ok {
return "database not open"
}
if len(args) != 3 {
return "invalid arguments: expected (vaultUuid, query, mode)"
}
result := db.SearchWithAutoSelect(args[1].String(), args[2].Int())
jsonData, err := json.Marshal(result)
if err != nil {
return fmt.Sprintf("json marshal error: %s", err)
}
return string(jsonData)
}

func getSuggestion(this js.Value, args []js.Value) interface{} {
db, _, ok := getDB(args)
if !ok {
Expand Down Expand Up @@ -863,6 +888,7 @@ func main() {
js.Global().Set("deleteRecord", js.FuncOf(deleteRecord))
js.Global().Set("UpdateDBFields", js.FuncOf(updateDBFields))
js.Global().Set("searchRecords", js.FuncOf(searchRecords))
js.Global().Set("searchRecordResults", js.FuncOf(searchRecordResults))
js.Global().Set("getSuggestion", js.FuncOf(getSuggestion))
js.Global().Set("getTOTP", js.FuncOf(getTOTP))
js.Global().Set("copyFieldToClipboard", js.FuncOf(copyFieldToClipboard))
Expand Down
Loading
Loading