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
passwordssecret key holds onerootwrapping, 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 norootwrapping yet has unlockers of its own (a passkey's PRF output under contextvault-passwords-kek, a recovery key, a passphrase); its first unlock by them gives it therootwrapping. - 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-extractableCryptoKey. - Each entry is one AES-256-GCM blob over a JSON record (
type,title,username,password,url,notes,totp_seed, …). Everything — even the coarsetype— lives inside the blob; there is deliberately no searchable plaintext column, so the list renders only after client-side decryption.
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/uewactions live in corelogic/and are reused by Drive with scopedrive: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, andvault_client_consume_recovery.includes/VaultClientCustody.phpis 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 isrequires_browser_session.
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.