This document describes the security model for PAM, the known risks, and the mitigations in place.
PAM is a client-side web application. All encryption and decryption
happens in the browser using the Web Crypto API
(SubtleCrypto). The encrypted file is stored locally by the
user.
Your records are never sent to a server. There is exactly one feature that contacts anything at all, it is off by default, and it does not transmit your data:
As of v2.5.0 each saved file also carries a SHA-256 digest of its
records and preferences (meta.integrity), inside the
encrypted payload. It is verified on load before any content or
preference is applied, and a file that fails is refused. This is tamper
evidence, not authenticated encryption: it is checked after
decryption rather than before, the digest is unkeyed and could be
stripped by anyone able to rewrite the file, and it does not detect
rollback to a genuine older copy. AES-GCM remains the correct fix and
requires a format change.
enablePasswordBreachCheck, disabled by default) sends the
first five characters of a password’s SHA-1 hash — twenty bits — to
api.pwnedpasswords.com, which returns every hash in its
corpus beginning with that prefix. The comparison happens in the
browser. The password, its full hash, the record it belongs to, and the
rest of the vault are never transmitted. A ⚠ BREACH
CHECK badge appears in the toolbar while it is enabled.The Content-Security-Policy in
www/index.html names that one host in
connect-src and permits no other, so what PAM is
able to contact is verifiable from the page source rather than
from this document. Note that the policy permits the host whether or not
the preference is enabled: a <meta> policy is fixed
when the page is parsed and cannot depend on a runtime setting, and
multiple policies compose by intersection, so a second one could only
tighten the first. The preference controls whether PAM makes the
request, not whether it could.
The primary threat vectors are:
.pam file and attempts to brute-force the
master password offline.html-type field.PAM supports an html field type that allows rich content
(formatted notes, links, instructions). This is an intentional feature
designed for controlled internal deployments where a security team
authors PAM data and distributes it on a read-only volume to a defined
cohort.
Risk: If a user loads a PAM file from an untrusted
source, html fields could contain hostile markup.
Historically this was described as an XSS risk. Since v2.4.0 the
Content-Security-Policy makes script execution the less likely
outcome; the live risk is content injection. Both are covered below.
Mitigation: - HTML field rendering is
disabled by default. Fields of type html
render as escaped plain text with a </> badge. - The
preference is on the Administration tab of Preferences,
reachable only after unlocking preferences with the prefs password. -
When HTML rendering is enabled, a persistent ⚠ HTML ON
warning badge is shown in the toolbar so users always know they are in a
mode where HTML fields render live. - The
Content-Security-Policy in www/index.html
blocks the usual script paths. script-src carries no
'unsafe-inline', so an injected
<img onerror=…> or javascript: URL does
not run, and markup inserted through innerHTML never
executes <script> tags regardless. Exfiltration is
constrained too: img-src 'self' data: blocks the classic
beacon, style-src 'self' blocks CSS-based leaks, and
connect-src permits only PAM’s own origin and the HIBP
range API.
The preference is not only reachable through the Preferences
dialogue. A loaded PAM file carries a prefs block,
and PAM applies it. A file can therefore switch HTML rendering on by
itself. The ⚠ HTML ON badge appears when it does, but it reports the new
state rather than asking first. See Residual risk below.
When is it safe to enable? - The PAM file was authored by a trusted party (e.g. your own security team). - The file is distributed on a read-only volume and cannot be modified by end users. - You are not loading PAM files from untrusted URLs.
Residual risk. Three items, in decreasing order of concern:
confirm your master passwordprompt, for example — inside a page the user already trusts. The CSP does not prevent this, because nothing about it requires script.
form-action 'self' is now in the policy. It was previously
absent, and unlike most directives form-action does
not fall back to default-src — so a form
in injected markup could submit to any origin. That was the one
exfiltration channel the rest of the policy left open. A unit test now
asserts the directive is present, because its absence is otherwise
completely silent.prefs block turns it on for whoever loads it. The
mitigations above protect against foreign malicious files; they
do not protect against a trusted file that has been tampered with, nor
against a user loading a file whose provenance they have not
checked.Note on the CSP. It is a strong mitigation and not a
guarantee. script-src allows
https://cdn.jsdelivr.net for the Bootstrap bundle, and that
host serves arbitrary npm and GitHub content; the allowance is safe here
only because markup inserted via innerHTML cannot execute
script tags at all. Do not treat the CSP as licence to relax the
default.
The master file password can be cached in browser storage to avoid re-entry on every save/load. The cache strategy is controlled in Preferences → Administration → filePass Cache Strategy.
Options: - session (default) — password
is cached in sessionStorage for the current browser tab
only and cleared when the tab is closed. - local — password
persists in localStorage across sessions and power cycles.
Use with caution on shared devices. A ⚠ PASS:
LOCAL warning badge appears in the toolbar while this is
active. - global — password is held in a JavaScript
variable only (lost on page reload). - none — password is
never cached; must be re-entered on every load/save.
Per-device strategy persistence:
The chosen strategy is stored per-device in localStorage
under the key pamCacheStrategy. On startup, PAM reads this
key and applies it before loading any file, solving the chicken-and-egg
problem where the PAM file (which contains preferences) cannot be opened
without first knowing the cache strategy.
This means the user’s strategy choice persists across page reloads, browser restarts, and power cycles, independently of the PAM file. It can be changed at any time in Preferences and takes effect immediately. When the strategy is changed, the password is cleared from the previous storage location before the new strategy is applied.
PWA note:
When PAM is installed as a Progressive Web App (home screen shortcut)
on iOS Safari, each launch may start a fresh browser session, wiping
sessionStorage. If your PAM file has
filePassCache set to local in its preferences,
PAM now correctly detects this after decrypting the file and re-stores
the password in localStorage, persisting the strategy as
pamCacheStrategy so subsequent launches use the same
bucket. (BUG-002)
History: - v2.0.3 — default changed from
local to session as a security improvement
(SEC-002). - v2.0.5 — default reverted to local due to PWA
reload behaviour causing excessive re-entry friction;
⚠ PASS: LOCAL badge added as compensating control. - v2.0.7
— default changed back to session; per-device
pamCacheStrategy persistence introduced so the user’s
choice survives reloads without being tied to the PAM file default. -
v1.3.1 — BUG-002 fixed: password was stored in the wrong storage bucket
when the loaded file’s filePassCache pref differed from the
startup default. Manifested as lost password on iOS PWA relaunch when
the file specified local but the device had no prior
pamCacheStrategy set.
Recommendation: Use session (the
default) for most environments. Use local only on a trusted
personal device where re-entry friction is a genuine concern.
PAM v1.x uses AES-256-CBC with PBKDF2-SHA-256 key derivation. The v1 implementation has two known weaknesses:
TextEncoder.encode() which calls
.toString() first, producing an ASCII string like
"0,34,211,..." instead of raw bytes, dramatically reducing
salt entropy.Both bugs define the key derivation for all existing v1 files. Fixing them requires a new file format (v2).
Migration: v2 format support is planned for PAM
v1.3. The migration is non-breaking — v1 files can always be decrypted;
users choose when to re-save in v2 format. See MIGRATION.md
(published with v2.0).
The preferences lock password (lockPreferencesPassword)
is stored as plaintext in the PAM file’s prefs
block.
This is intentional. The prefs lock is a convenience feature whose sole purpose is to prevent casual or accidental modification of preferences in a shared deployment. It is not a cryptographic boundary.
The entire PAM file — including the prefs block and
lockPreferencesPassword — is encrypted with the master
password (AES-256-CBC). An attacker who has the master password can read
everything. Hashing lockPreferencesPassword would add no
security benefit in this threat model, and would silently break existing
files where the value was stored as plaintext.
Threat model: The prefs lock protects against an authorised user (who has the master password) accidentally changing deployment-managed settings. It does not protect against a determined attacker.
The Load → URL feature only accepts
https:// and http:// URLs.
javascript:, data:, ftp:, and
other URI schemes are rejected to prevent protocol-based injection
attacks.
PAM sets a Content-Security-Policy meta tag in
index.html:
default-src 'self';
script-src 'self' https://cdn.jsdelivr.net;
style-src 'self';
img-src 'self' data:;
font-src 'self'
This prevents loading of scripts, styles, and resources from
untrusted origins. The cdn.jsdelivr.net exception covers
the DOMPurify library used for HTML sanitization.
This is an open-source personal password manager. If you find a security issue please open a GitHub issue at jlinoff/pam.