Password Manager (Vault plugin)

A zero-knowledge password manager at /profile/vault. Passwords are encrypted and decrypted only in the browser; the server stores opaque ciphertext and never holds the key. It is a *client-custody consumer of the Sealed Vault — the encryption identity, the unlockers, the recovery scheme, and the browser crypto module all come from the vault. This plugin adds only the entry storage and the manager UI on top.

The threat model is the design

Designed around one deliberately chosen threat: a Joinery vulnerability gets mass-exploited and the attacker reads files, dumps the database, or steals a backup. On a full Linux + Apache + Joinery box that surface is large and exploitation is automated, so any key the OS or PHP process can reach is, for password-manager purposes, already compromised.

The conclusion: the server must never hold the key or see plaintext. This is why the manager is client-custody, not server-custody — the server-side unlock window that serves mail and chat would put a decryptable key in server RAM, which for passwords is categorically wrong. Server-side SecretBox is not used here. A DB dump, file read, stolen backup, or SQL-injection exfiltration yields only ciphertext plus a wrapped key; security then reduces to the cost of brute-forcing the passphrase offline, which is why the KDF choice is load-bearing (Argon2id, memory-hard).

What it does not fully defend, stated plainly: a web vault's crypto is served by the same server being defended. An attacker who compromises Joinery deeply enough to alter the JavaScript it serves can capture credentials the next time they are entered — forward-looking, never retroactive (a snapshot still yields nothing). This is the served-JS residual every web vault shares; the mitigation is a native/extension client that ships the unlock code in the installed artifact, a hardening phase shared with Drive. Endpoint compromise (a keylogger on your own machine) defeats any password manager and is out of scope.

Crypto architecture

The identity is the vault's own passwords client-custody keypair (uev_scope='passwords', uev_custody='client', X25519) — separate from Drive's, Fortress mail's and the server-custody account vault's, so no other scope's key opens it. The key hierarchy, performed entirely in the browser:

unlocker --> root secret --scope KEK--> vault X25519 secret key --seals--> store DEK --encrypts--> each entry

  • The vault key opens through the root vault (One vault). The passwords secret key holds one root wrapping, under a key the browser derives from the root's secret for this scope, and no unlockers of its own: the person's passkeys, recovery codes and optional passphrase are the root's, so one touch opens the root and this vault with it. Adding or changing an unlocker changes the root's wrappings only; this vault's key, the store DEK and the entries are untouched. A passwords vault with no root wrapping yet has unlockers of its own (a passkey's PRF output under context vault-passwords-kek, a recovery key, a passphrase); its first unlock by them gives it the root wrapping.
  • Store DEK — a random 256-bit AES-GCM key sealed once (ECIES over X25519) to the vault public key and held as one blob (vlk_wrapped_dek). On unlock the browser unwraps the secret key, opens the sealed DEK, and holds it as a non-extractable CryptoKey.
  • Each entry is one AES-256-GCM blob over a JSON record (type, title, username, password, url, notes, totp_seed, …). Everything — even the coarse type — lives inside the blob; there is deliberately no searchable plaintext column, so the list renders only after client-side decryption.
No server-side search. The client fetches all entry blobs on unlock and searches/decrypts in memory (a password store is small).

Server surface (opaque storage only)

The server stores and returns blobs and never inspects, validates, or logs their contents. Two layers:

  • Identity (core, shared, scope-parameterized). The client-custody uev/uew actions live in core logic/ and are reused by Drive with scope drive: vault_client_status (keyring view), vault_client_prf_options (mint a PRF assertion; the browser reads the output locally and never posts it), vault_client_setup, vault_client_add_wrapping, vault_client_remove_wrapping (unlocker-floor enforced), vault_client_replace_recovery, and vault_client_consume_recovery. includes/VaultClientCustody.php is the shared helper.
  • Password-specific (this plugin). /api/v1/action/vault/*: keyring_get, keyring_save (the sealed store DEK — create-only: the blob is the sole copy of the store key, so an existing row is never overwritten), keyring_replace (the one overwrite: accepted only while the passwords vault key is being rotated, see below), entries_list, entry_save, entry_delete (trash), entry_restore. Every action is requires_browser_session.
Data classes: VaultKeyring (vlk_vault_keyring, one row per user — the sealed store DEK) and VaultEntry (vle_vault_entries, one opaque blob per entry, soft-deleted for trash/restore).

Setup, unlock and lock

The page opens the vault through core: JoinerySealed.session('passwords') runs the shared ceremony in a modal on load and the page keeps no setup or unlock screen of its own. The vault key is made through the root on a first visit, so there is nothing of its own to set up: a person who has not set up their vault yet sets up the root (a passkey with a PRF capability check, or a passphrase, and the recovery codes shown once, proven kept by re-typing the last one or downloading the file). A first visit then mints the store DEK and opens the editor for the first entry. Closing the modal leaves a small "Your password vault is closed" card with an Open your vault* button.

Unlocking is the one vault unlock: the touch that opens the root opens this vault with it, and the lock chip's unlock does the same from any page. Its keypair is still its own, so its key and its content never open through any other scope's.

Locking discards all plaintext, including unsaved edits. Core locks the session — after vault_client_autolock_minutes of no keyboard or pointer activity (a core setting, 15 by default; the Auto-lock select sets this browser's own choice for every vault it holds), on Lock now, when the tab is left, and when a back/forward-cache restore brings the page back — and the page's onLock handler drops the store DEK and every decrypted entry, empties the editor inputs, the list and the detail pane, and clears the clipboard if it still holds a value the page copied.

Rotating the vault key runs from the security page (under Vault Keys) and, for a vault that opens through the root, costs nothing more than the root being open; a vault that still has unlockers of its own costs new recovery codes, the passphrase again and a tap per passkey (docs/sealed_vault.md § Rotating a client-custody key). The entries are not touched — they are encrypted under the store DEK, which does not change — only the store DEK's sealed copy moves to the new key, through plugins/vault/assets/js/vault-reseal.js and vault/keyring_replace. The plugin's bootstrap registers that hook (VaultUnlock::clientReseal()) and plugin.json declares "client_reseals": ["passwords"], so a rotation cannot run without it.

Entry types and field handling

v1 ships Login (username, password, url, totp_seed, notes) and Secure Note (notes). The blob's type tag lets later types (card, identity) be added without migration. Passwords and TOTP seeds render masked with a per-field reveal; every credential field has a copy button, and copied secrets are cleared from the clipboard after 30 seconds (best-effort — the Clipboard API only permits clearing while the page holds focus, and only if the clipboard still holds the copied value). TOTP entries show the current code with a countdown, generated in the browser.

Quality-of-life

  • Password generator in the entry editor.
  • TOTP seed storage with in-browser code generation.
  • Import an encrypted Joinery backup, a Bitwarden JSON export, or a CSV (the common 1Password / generic shape); export an encrypted backup (all entries encrypted under a passphrase you choose, independent of the vault).
  • User-configurable auto-lock timeout, remembered per browser.

The vendored Argon2id WASM

The passphrase-fallback KDF is Argon2id via a vendored, hash-pinned WASM (assets/vendor/argon2/argon2-bundled.min.js — self-contained, no CDN, no runtime npm). This is the Sealed Vault's sanctioned exception to the platform's vanilla-JS-only rule. Recovery keys carry ≥128 bits of entropy, so their KEK is a fast SHA-256, never Argon2id.

Tests

plugins/vault/tests/vault_client_custody_test.php covers the server contract: scope isolation, byte-for-byte opaque storage (the zero-knowledge property), the passkey-resolution guards, the keyring status view, the unlocker floor across scopes, and the entry/keyring models. The browser crypto round-trips (WebCrypto / Argon2, which cannot run in CLI) are verified end-to-end in a browser via the passphrase unlocker and the recovery-key path.