46 KiB
PassKeeper — Password Manager Web App & Browser Extension
Project Overview
A full-featured password manager web app and browser extension modelled after LastPass. Users can store, organise, and autofill credentials securely with a zero-knowledge architecture.
Tech Stack
Development (Windows)
- Backend: Python 3.12, Flask 3.x
- Database: MySQL 8.x
- Frontend: Vanilla JS (Web Crypto API) + Jinja2 templates
- Dev server:
python run.py
Production (Ubuntu)
- Web server: Nginx (reverse proxy, TLS termination)
- WSGI server: Gunicorn
- Process manager: systemd
- Database: MySQL 8.x
- TLS: Let's Encrypt / Certbot
Architecture
Browser Extension <──────────────────────────────────────┐
Web App <──> Nginx ──> Gunicorn ──> Flask App │
│ │
MySQL DB │
│
REST API (JSON, HTTPS) ───────┘
Project File Structure
passkeeper/
├── app/
│ ├── __init__.py # App factory, blueprints, CSRF exemptions, security headers
│ ├── config.py # DevelopmentConfig / ProductionConfig
│ ├── models/
│ │ ├── user.py # Argon2id, TOTP encrypted, ECDH keys, recovery
│ │ ├── vault_item.py # enc_data, iv, enc_name, iv_name
│ │ ├── folder.py
│ │ ├── token_blacklist.py # JWT revocation (jti + expires_at)
│ │ ├── totp_used_code.py # TOTP replay prevention (one-time use per user)
│ │ ├── shared_item.py # ECDH-encrypted cross-user shares; enc_name/iv_name
│ │ ├── emergency_access.py # State machine
│ │ ├── recovery_challenge.py # Server-side recovery challenge (multi-worker safe)
│ │ ├── webauthn_credential.py # Passkey / WebAuthn credentials (one row per key)
│ │ └── audit_log.py
│ ├── routes/
│ │ ├── auth.py # Register, login, MFA, logout, refresh, change-password, recovery
│ │ ├── vault.py # CRUD + GET /export + POST /import
│ │ ├── folders.py
│ │ ├── sharing.py
│ │ ├── emergency.py
│ │ └── webauthn.py # Passkey registration, authentication, credential management
│ ├── services/
│ │ └── auth_service.py # Argon2id, JWT, blacklist, @require_jwt, TOTP encrypt/decrypt,
│ │ # TOTP replay helpers (is_totp_code_used / mark_totp_code_used)
│ ├── static/
│ │ ├── css/app.css # Full responsive stylesheet; collapsible group styles; tag badge styles
│ │ └── js/
│ │ ├── crypto.js # deriveAuthHash, deriveVaultKey, encryptItem, decryptItem,
│ │ │ # encryptName, decryptName, generateSalt
│ │ ├── auth.js # Login/register + TOTP MFA + VaultSession
│ │ ├── recover.js
│ │ ├── sharing.js # ECDH P-256; encryptName/decryptName for shared item names
│ │ └── vault.js # Full vault UI — see "Key Features" below
│ └── templates/vault/
│ └── index.html # Tags field, Auto-Lock setting, Import/Export view + sidebar
├── extension/
│ ├── manifest.json # MV3 (Chrome/Edge); web_accessible_resources: []
│ ├── manifest.firefox.json # MV2 (Firefox); web_accessible_resources: []
│ ├── background.js # Chrome SW: badges, pending-save "!" badge, CLEAR_SAVE_BADGE
│ ├── background.firefox.js # Firefox: in-memory session shim, setTimeout idle lock
│ ├── shared/
│ │ ├── crypto.js # PBKDF2+AES-GCM; encryptName/decryptName; extractable key
│ │ └── browser-polyfill.js # chrome=browser alias for Firefox content scripts
│ ├── popup/
│ │ ├── popup.html # Tabs: All relevant / All items / Favorites / Recents
│ │ ├── popup.css # .pk-group-* collapsible styles; .pk-tag badge; .pk-flyout menu
│ │ └── popup.js # Vault+folder fetch; collapsible groups; Favorites tab;
│ │ # tag display; clipboard auto-clear; CSPRNG generator;
│ │ # save badge clear; three-dot flyout; copy-username button
│ ├── content/
│ │ └── content.js # _isLikelyUsernameField; _normaliseUrl; debounced input (150ms);
│ │ # MutationObserver guard; vault_items_cs from chrome.storage.session
│ ├── bridge/
│ │ └── bridge.js # SSO bridge (vault domain only)
│ └── icons/
├── migrations/versions/
│ ├── 71d7158dd3b9_add_audit_log_tables_fix_sharing_public_.py
│ ├── a1b2c3d4e5f6_encrypt_totp_secret_at_rest.py
│ ├── b2c3d4e5f6a7_add_account_recovery_columns.py
│ ├── c3d4e5f6a7b8_encrypt_vault_item_name.py
│ ├── d4e5f6a7b8c9_add_lockout_and_mfa_backup_codes.py
│ ├── e5f6a7b8c9d0_add_recovery_challenges_table.py
│ ├── f6a7b8c9d0e1_add_totp_used_codes_table.py # TOTP replay prevention
│ ├── g7h8i9j0k1l2_encrypt_shared_item_name.py # enc_name/iv_name on shared_items
│ └── h8i9j0k1l2m3_add_webauthn_credentials_table.py # Passkey / WebAuthn credentials
├── scripts/
│ ├── reencrypt_totp_secrets.py
│ ├── backup_db.sh / backup.cron / passkeeper-logrotate
│ ├── passkeeper-nginx.conf / passkeeper.service
├── reset_db.py
├── requirements.txt
├── wsgi.py / run.py
└── CLAUDE.md
Database Schema (MySQL)
-- Users
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
master_hash VARCHAR(255) NOT NULL, -- Argon2id(authHash)
enc_key_salt VARCHAR(64) NOT NULL,
created_at / last_login DATETIME,
totp_secret VARCHAR(255), -- AES-256-GCM ciphertext
totp_iv VARCHAR(64),
totp_enabled TINYINT(1) DEFAULT 0,
mfa_backup_codes TEXT, -- JSON array of Argon2id-hashed codes
sharing_public_key VARCHAR(128),
sharing_private_key_enc TEXT,
sharing_private_key_iv VARCHAR(64),
recovery_enc_salt VARCHAR(128),
recovery_iv VARCHAR(64)
);
-- Vault Items
CREATE TABLE vault_items (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
folder_id INT UNSIGNED,
item_type VARCHAR(20) NOT NULL DEFAULT 'password',
name VARCHAR(255) NOT NULL, -- server label only (item type string)
enc_data TEXT NOT NULL, -- AES-256-GCM ciphertext of payload + tags
iv VARCHAR(64) NOT NULL,
enc_name TEXT, -- AES-256-GCM ciphertext of item name (nullable)
iv_name VARCHAR(64),
created_at DATETIME DEFAULT NOW(),
updated_at DATETIME DEFAULT NOW() ON UPDATE NOW(),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL
);
-- Shared Items
CREATE TABLE shared_items (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id INT UNSIGNED NOT NULL,
owner_id INT UNSIGNED NOT NULL,
recipient_email VARCHAR(255) NOT NULL,
recipient_id INT UNSIGNED,
item_name VARCHAR(255) NOT NULL, -- non-sensitive fallback label (= item_type)
item_type VARCHAR(20) NOT NULL DEFAULT 'password',
enc_data TEXT NOT NULL, -- ECDH-encrypted item payload
iv VARCHAR(64) NOT NULL,
enc_name VARCHAR(512), -- ECDH-encrypted display name (nullable for legacy)
iv_name VARCHAR(64),
accepted TINYINT(1) DEFAULT 0,
created_at DATETIME NOT NULL
);
-- TOTP Used Codes (replay prevention)
CREATE TABLE totp_used_codes (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
code VARCHAR(6) NOT NULL,
expires_at DATETIME NOT NULL,
UNIQUE KEY uq_totp_used_user_code (user_id, code),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Recovery Challenges (multi-worker safe)
CREATE TABLE recovery_challenges (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL UNIQUE,
nonce VARCHAR(64) NOT NULL,
expected_proof VARCHAR(64) NOT NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- (folders, token_blacklist, emergency_access, audit_logs — standard schemas)
-- WebAuthn / Passkey Credentials
CREATE TABLE webauthn_credentials (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
credential_id VARCHAR(512) NOT NULL UNIQUE, -- base64url authenticator credential ID
public_key TEXT NOT NULL, -- COSE public key, base64url
sign_count BIGINT NOT NULL DEFAULT 0, -- clone detection counter
transports VARCHAR(255), -- JSON list e.g. '["internal","hybrid"]'
aaguid VARCHAR(64), -- authenticator AAGUID
name VARCHAR(128) NOT NULL DEFAULT 'Passkey', -- user-assigned label
created_at DATETIME NOT NULL,
last_used_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
Migration History
| Revision | Description |
|---|---|
71d7158dd3b9 |
Add audit_logs; fix sharing_public_key type |
a1b2c3d4e5f6 |
Encrypt TOTP secret at rest |
b2c3d4e5f6a7 |
Add account recovery columns |
c3d4e5f6a7b8 |
Add enc_name + iv_name to vault_items |
d4e5f6a7b8c9 |
Add lockout columns + MFA backup codes |
e5f6a7b8c9d0 |
Add recovery_challenges table |
f6a7b8c9d0e1 |
Add totp_used_codes table (TOTP replay prevent.) |
g7h8i9j0k1l2 |
Add enc_name/iv_name to shared_items |
h8i9j0k1l2m3 |
Add webauthn_credentials table (passkeys) |
Security Model
- Zero-knowledge: master password never sent to server
authHash = PBKDF2(masterPassword, email, 100k iter)→ auth onlyvaultKey = PBKDF2(masterPassword, enc_key_salt, 600k iter)→ browser memory only- All vault data (payload + name + tags) encrypted client-side (AES-256-GCM)
- Item name:
enc_name/iv_nameinvault_items; servernamecolumn = item type only - Shared item name:
enc_name/iv_nameencrypted with ECDH shared key; serveritem_name= item type only - Tags:
plain.tags: string[]insideenc_data; server never sees them - Argon2id: double-hashes
authHashserver-side; transparently rehashes on login if parameters are upgraded - JWT: HS256, 15 min access / 7 day refresh, JTI blacklisted on logout
- MFA: TOTP secret AES-256-GCM encrypted at rest; each code is single-use (replay prevented via
totp_used_codestable, 120s TTL) - Passkeys / WebAuthn: server authentication via FIDO2; ZK model preserved — WebAuthn proves identity to the server but the vault key is still derived from the master password client-side;
sign_countupdated on every assertion for clone detection - Sharing: ECDH P-256 zero-knowledge re-encryption; item name also encrypted with shared key
- Password generator: fully CSPRNG (
_cryptoRandIntrejection-sampling) - Decrypted vault data:
chrome.storage.sessiononly — never to disk - Clipboard auto-clear: 30 s after any password/username copy (web + extension)
- Breach detection: HIBP k-anonymity — only 5-char SHA-1 prefix transmitted
- Account recovery: challenge-response via HMAC-SHA256;
enc_key_saltNOT returned by/recovery/data— client must derive it by decrypting the recovery blob (proves possession of recovery code without transmitting it); challenge rotated on each/recovery/itemscall to prevent proof replay; recovery key derived with the user's email as a per-user PBKDF2 salt — legacy fixed salt'passkeeper-recovery'accepted transparently for codes created before this change - folder_id ownership: validated server-side on all create/update/import operations — user cannot assign items to another user's folder
- Audit logs: never contain plaintext item names, shared item names, or vault data
Extension Storage Architecture
| Data | Storage | Reason |
|---|---|---|
access_token, vault_key_jwk, vault_items, vault_items_cs |
chrome.storage.session |
Memory-only, cleared on browser close |
refresh_token, enc_key_salt, pending_save, save_blocklist, idle_lock_seconds |
chrome.storage.local |
Persists across restarts |
| Web-app session timeout | localStorage (web_idle_minutes) |
Per-browser preference |
Critical: vault_items_cs is in session, NOT local
Requires Chrome 111+. content.js onChanged listener watches area === 'session'. Do not revert — reverting persists decrypted passwords to disk.
Pending-save badge
background.js sets a red "!" badge on SAVE_CREDENTIALS. Cleared via CLEAR_SAVE_BADGE when user acts on the save prompt in the popup.
Key Implementation Details
password_changed_at — accurate password age tracking
Stored as plain.password_changed_at: ISO-8601 string inside the encrypted enc_data blob. Server never sees it.
Written by:
vault.jshandleFormSubmit— create: always set tonow. Edit: only updated when the password field value actually changed vs. the decrypted existing item in_items. Unchanged password → existingpassword_changed_atpreserved. No existing timestamp + unchanged → absent (dashboard falls back tocreated_at).popup.jssaveCredentialandaddItemToVault— always set tonowfor new extension-saved credentials.toggleFavorite— spreads{ ...item.plain, tags: newTags }, automatically preserving the timestamp.
Read by:
renderSecurityDashboard—ageRef = plain?.password_changed_at || created_at; items with a recentpassword_changed_atare no longer falsely flagged as old even if the item itself is old.- Backwards compatible: items without
password_changed_atfall back tocreated_at.
Auto-lock on tab visibility change
_startWebIdleTracking() now registers a visibilitychange listener in addition to the mouse/keyboard events.
Hidden: clears the inactivity timer (user cannot be active on a hidden tab), records _hiddenAt = Date.now().
Visible again: compares elapsed hidden time against the idle timeout.
- Hidden
≥ timeout→ immediate lock (sameVaultSession.clear()+showUnlockOverlay()+ toast as inactivity lock). - Hidden
< timeout→ clears_hiddenAt, resumes timer from zero.
This catches screen-lock, minimize, and long tab switches. Short tab switches (< timeout) do not trigger a lock.
Emergency access stale snapshot detection
EmergencyAccess._enc_vault_is_legacy() — parses enc_vault JSON server-side (no decryption) and returns True if any item has a name key but lacks enc_name. Exposed as enc_vault_is_legacy in to_dict().
renderEmergencyGrants() — ready grants now show three states:
enc_vault_is_legacy: true→ amber⚠ Outdated snapshotbadge + Re-provision buttonenc_vault_is_legacy: false→ secondary Update Recovery Data buttonaccepted(no snapshot yet) → primary Provide Recovery Data button
After re-provisioning, enc_vault_is_legacy returns false and the warning disappears.
Extension health badge
_runPopupHealthCheck() in popup.js — fires after every fetchAndDecryptVault(). Computes weak/reused synchronously, sends a preliminary HEALTH_UPDATE to the background SW, then runs HIBP in parallel (_popupCheckHibp). Sends a final HEALTH_UPDATE with breach count.
background.js / background.firefox.js:
HEALTH_UPDATEhandler: stores{ breached, weak, reused }inchrome.storage.localashealth_status, callsapplyHealthBadge().applyHealthBadge(): on tabs with no match-count badge, shows red⚠(breach) or amber⚠(weak/reused). Never overwrites the blue match-count badge or the pending-save!badge.CLEAR_SAVE_BADGE: after clearing!, immediately re-applies health badge.- Idle lock: removes
health_statusfrom local storage.
Badge priority: pending-save ! (red) > match count (blue) > health ⚠ (red/amber).
Vault health notifications (background checks)
runBackgroundHealthCheck() fires after every loadVault() call — async, non-blocking.
Flow:
- Computes weak + reused counts synchronously from
_items→ updates sidebar badge instantly - Runs HIBP k-anonymity checks in parallel (
checkHibp) → updates badge + banner when done
Module state:
_healthCache—{ weak, reused, breached, breachedItems, hibpResults }— set after first run_hibpRunning— boolean guard prevents concurrent runs
_updateHealthUI({ weak, reused, breached }) — renders:
- Sidebar badge (
#security-badge) on the 🛡️ Security link: red for breaches, amber for weak/reused only, hidden when clean - Dismissible banner (
#health-banner) above the vault list: summarises issues with "View report" link → Security tab; dismiss hides for the session (banner.dataset.dismissed = "1"); resets on next vault load
Security tab caching: renderSecurityDashboard checks _healthCache?.hibpResults before querying HIBP — avoids double-calling the API within the same session.
Banner reset: banner.dataset.dismissed is set to "0" on each vault reload so fresh results (e.g. after a password change) are visible again.
WebAuthn / Passkey
Library: py-webauthn (webauthn>=2.0). Installed via pip install webauthn.
Config (.env / environment):
WEBAUTHN_RP_ID=pwkeeper.ngodanguyen.tech # effective domain, no scheme/port
WEBAUTHN_RP_NAME=PassKeeper
WEBAUTHN_ORIGINS=https://pwkeeper.ngodanguyen.tech # comma-separated in env
In development: WEBAUTHN_RP_ID=localhost, WEBAUTHN_ORIGINS=http://localhost:5000.
Flows:
Registration (requires active JWT — user must be logged in):
POST /api/webauthn/register/begin → PublicKeyCredentialCreationOptions
POST /api/webauthn/register/complete → verifies attestation, stores credential
Authentication (unauthenticated — replaces password login):
POST /api/webauthn/authenticate/begin → PublicKeyCredentialRequestOptions
POST /api/webauthn/authenticate/complete → verifies assertion → { access_token, refresh_token, enc_key_salt }
Client still prompts for master password after successful assertion to derive vault key.
Management (requires JWT):
GET /api/webauthn/credentials → list registered passkeys
PATCH /api/webauthn/credentials/<id> → rename a passkey
DELETE /api/webauthn/credentials/<id> → remove a passkey
Challenge storage: challenge bytes are stored in the Flask session (signed cookie, SECRET_KEY). No DB row needed. Challenge is consumed (session.pop) on the /complete call.
Clone detection: sign_count is read before verification and updated after. py-webauthn raises on a decreasing counter.
ZK preserved: a WebAuthn assertion authenticates the user to the server only. The vault key PBKDF2(masterPassword, enc_key_salt, 600k) is never sent to or derivable by the server. After a passkey login the client must still enter the master password to unlock the vault.
Client-side (PasskeyAuth in auth.js):
loginWithPasskey(email)— begin →navigator.credentials.get()→ complete → stores tokens → redirects to/vaultregisterPasskey(name)— begin →navigator.credentials.create()→ complete_bufToB64url/_b64urlToBuf— ArrayBuffer ↔ base64url conversion helpers_credentialToJson(cred)— serialisesPublicKeyCredentialfor the server
Settings UI (vault.js loadPasskeys()): renders registered passkeys list with rename/delete; wires "Add passkey" button; hides the section if window.PublicKeyCredential is absent.
auth.js login wiring: "Sign in with Passkey" button on login.html calls PasskeyAuth.loginWithPasskey(email). On success, stores tokens and navigates to /vault — unlock overlay fires if master password field was empty.
Authenticator attachment: register_begin accepts optional attachment in the POST body: "platform" (default — device biometrics) or "cross-platform" (roaming — YubiKey, phone QR, NFC). The settings UI exposes a <select> with both options. Credential type is shown in the passkeys list using transport hints (internal → 📱 Device, usb/nfc/ble → 🔑 Security key).
Exception names (webauthn>=2.0):
InvalidRegistrationResponse— use inregister_completeInvalidAuthenticationResponse— use inauthenticate_completeInvalidCBORData— malformed CBOR attestation- NOT
InvalidAuthenticatorResponse(does not exist)
TOTP replay prevention
Every accepted TOTP code is recorded in totp_used_codes (user_id + code, 120s TTL). A second attempt with the same code within that window returns 400 Verification code already used. Applies to: mfa_enable, mfa_disable, mfa_verify, mfa_backup_codes_regenerate. The table is pruned by the APScheduler cleanup job alongside token_blacklist and recovery_challenges.
Argon2 transparent rehash
verify_auth_token(auth_hash, stored_hash, user=user) calls ph.check_needs_rehash() on success. If the stored hash uses outdated parameters (e.g. after raising ARGON2_TIME_COST), the hash is silently upgraded in the same DB commit as the login success. Pass user=user at all verification callsites — both login and change_password.
folder_id ownership validation
_validate_folder_id(folder_id, user_id) in vault.py queries Folder by both id and user_id. Returns sanitised int or None. Raises ValueError on mismatch. Applied in create_item (400 on invalid), update_item (400 on invalid), import_items (silently clears to None — item still imports to root).
Account recovery flow
1. GET /recovery/data → server creates challenge; returns nonce + recovery blob
enc_key_salt NOT returned (client must decrypt blob)
2. Client decrypts blob with recovery code → gets enc_key_salt
Recovery key = PBKDF2(recoveryCode, userEmail, 200k iter)
Legacy fallback: PBKDF2(recoveryCode, "passkeeper-recovery", 200k iter)
3. Client computes proof = HMAC-SHA256(enc_key_salt, nonce)
4. GET /recovery/items → validates proof; consumes challenge; re-issues fresh challenge
with same expected_proof + new nonce (prevents proof replay)
Returns { id, enc_data, iv, enc_name, iv_name } per item
5. POST /recover → validates proof again; consumes rotated challenge; atomically
re-encrypts vault (enc_data + enc_name) + resets password
Password change key rotation
handleChangePassword in vault.js performs a fully atomic key rotation in a single POST /api/auth/change-password call:
- Derives
newVaultKey = PBKDF2(newPassword, newEncKeySalt, 600k iter) - Re-encrypts every vault item's
enc_data+enc_namewithnewVaultKey - Re-encrypts the ECDH sharing private key at the raw bytes level:
- Fetches
GET /api/sharing/keys→ getsprivate_key_enc/private_key_iv AES-GCM.decrypt(vaultKey, ...)→ raw private key bytesAES-GCM.encrypt(newVaultKey, newIv, ...)→ new ciphertext- Sends
sharing_private_key_enc/sharing_private_key_ivin the payload
- Fetches
- Server atomically updates
master_hash,enc_key_salt, all vault items, andsharing_private_key_enc/sharing_private_key_ivin one transaction
The sharing key uses raw SubtleCrypto calls (not SharingCrypto.decryptPrivateKey) because decryptPrivateKey imports the key with extractable: false, making it impossible to re-export the bytes for re-encryption.
Shared item name encryption
When creating a share, the client encrypts item.name with SharingCrypto.encryptName(sharedKey, name) → enc_name/iv_name. The server receives item_name = item.item_type (non-sensitive type label) for the NOT NULL column. On the recipient's inbox, enc_name/iv_name are stored in data attributes on the View button and decrypted client-side with SharingCrypto.decryptName() when the user clicks View. Legacy shares (pre-migration, no enc_name) fall back to showing item_name (the type string).
Vault item tags
- Stored as
plain.tags: string[]insideenc_data. No schema change ever needed. - Web app:
_parseTags(str)→ comma-split, lowercase, dedup, sort._allTags()collects across all items.renderTagList()builds sidebar. Tags field in modal has live badge preview. - Extension:
.pk-tagbadges below item subtitle. Favorites tab = items wheretags.includes('favorite'). Favorited items show gold ★ in site label. handleSearch()pool correctly branches onitemType/folder/tagfilter types.
Collapsible folder groups
- Web app:
_collapsedGroupsSet persists state across re-renders. Header shows name + count badge + chevron (▼/▶). Click toggles and re-renders. - Extension: only on "All items" tab.
_foldersfetched in parallel with vault._collapsedFoldersSet.folderName(id)resolves to display name with(none)fallback.
Import / Export
- Encrypted JSON export:
GET /api/vault→ versioned envelope → download. Zero-knowledge. - CSV export: decrypt client-side →
name, url, username, password, notes. - JSON import: sends encrypted blobs to
POST /api/vault/importdirectly. - CSV import: RFC 4180-compliant parser (handles
""embedded quotes); parses Chrome / Bitwarden / 1Password formats; encrypts client-side before POSTing. - Both endpoints write audit logs:
vault_item.export,vault_item.import. - Import view resets state (file input, preview, result) every time the view is entered.
apiFetch error handling
apiFetch throws on all non-ok responses including 404. Thrown error carries e.status for callers that need to branch. Callers should use try/catch rather than checking res.ok after the call.
Web-app session timeout
_startWebIdleTracking()called at end ofinit(). Listens tomousemove,mousedown,keydown,touchstart,scroll.- On timeout:
VaultSession.clear()→ unlock overlay → toast. - Stored in
localStorageasweb_idle_minutes. Default 15 min. Options: Never/5/10/15/30/60. - Exposed in Account Settings → Auto-Lock (
<select id="web-idle-select">).
Browser history / back-button
switchView(view, { pushState = true })callshistory.pushState({ view }, '', '#view-name').popstatelistener ininit()restores view. Direct hash links (/vault#security) work.Vault.switchToImportExport()exposed on public return object for sidebar<li onclick>fallback.
Clipboard auto-clear
- Web app:
copyToClipboard()uses_clipboardClearTimersetTimeout 30s →writeText(''). - Extension:
_copyWithAutoClear()same pattern. Applied to password, username, and flyout copies.
Missing 2FA warning
- Security dashboard "No 2FA Saved" section: password items with URL but no
totp_uri. - Uses existing
extractTotpSecret()andmakeSection().
Security score age penalty fix
oldonly includes items that are also weak or reused (weakOrReusedIds.has(i.id)).- Strong unique unchanged passwords no longer penalised.
Keyboard shortcut
manifest.json:commands._execute_action,Ctrl+Shift+L/Cmd+Shift+L.manifest.firefox.json:_execute_browser_action.- Customisable at
chrome://extensions/shortcuts.
Firefox compatibility
| File | Purpose |
|---|---|
manifest.firefox.json |
MV2: browser_action, background.scripts |
background.firefox.js |
In-memory session shim, setTimeout idle lock, browserAction API |
shared/browser-polyfill.js |
chrome = browser alias for content scripts |
Load in Firefox: about:debugging → This Firefox → Load Temporary Add-on → manifest.firefox.json.
web_accessible_resources
Both manifests declare web_accessible_resources: [] (MV3) / [] (MV2). This prevents any external page from loading extension resources via chrome-extension:// or moz-extension:// URLs, blocking extension fingerprinting.
_normaliseUrl() — bare-domain matching
function _normaliseUrl(raw) {
if (!raw) return null;
const s = raw.trim();
if (/^https?:\/\//i.test(s)) return s;
if (s.startsWith("//")) return "https:" + s;
return "https://" + s;
}
In both content.js and popup.js. Prevents silent match failures for bare domains.
_isLikelyUsernameField() — credential field heuristic
- YES:
autocomplete="username|email|tel" - NO: non-credential autocomplete (
name,organization,search, etc.) - YES:
name/id/placeholder/aria-labelmatchesuser|email|mail|login|phone|tel|mobile|account - Otherwise: not decorated
MutationObserver guard
Inspects added/removed nodes — if all carry __pk prefix, returns early. Prevents re-decoration loops when the extension injects/removes its own UI.
showDropdown debounced 150ms on input
_debounce(fn, ms) helper. focus listener remains instant.
Three-dot flyout menu
.pk-flyout div anchored below button. Dynamic items: Open URL / Copy username / Copy password. Closes on outside click.
AuditLog pattern
db.session.add(item)
db.session.flush() # populates item.id
AuditLog.log(user_id=..., action='vault_item.create', resource_id=item.id,
detail=f'Created {item_type} item (id={item.id})') # NO plaintext name
db.session.commit() # atomic
Audit log details never contain plaintext item names, shared item names, or any decrypted vault data. Use item_type and id only.
Common gotchas
item_type: usedb.String(20), notdb.Enum(ItemType)— enum lazy-load breaks.valueINTEGER(unsigned=True): requiresfrom sqlalchemy.dialects.mysql import INTEGER- All datetimes: naive UTC
datetime.now(timezone.utc).replace(tzinfo=None)— never mix with timezone-aware - Extension vault key:
extractable: true(web app usesfalse) - PyJWT
sub:str(user_id)on encode,int(payload['sub'])on decode - TOTP key generation:
python -c "import secrets; print(secrets.token_hex(32))" #vault-listID must not be renamed —vault.jsrenders into it directly_validate_folder_idmust be called for any user-suppliedfolder_idbefore DB writeverify_auth_tokenmust receiveuser=userat bothloginandchange_passwordto enable Argon2 rehash- APScheduler cleanup job handles
TokenBlacklist,RecoveryChallenge, ANDTotpUsedCode; guard withos.environ.get('WERKZEUG_RUN_MAIN') == 'true'in Flask debug mode to prevent double-start password_changed_atlives insideplain(encrypted) — never in the server schema- WebAuthn
attachment:"cross-platform"for security keys;"platform"for device biometrics (default) enc_vault_is_legacycheck inEmergencyAccess.to_dict()is pure JSON inspection — no decryption- Never return
str(e)from exception handlers — log with_log.exception(...)and return a generic user-facing message to avoid leaking DB schema details or query fragments SharingCrypto.decryptPrivateKeyimports the key withextractable: false— use rawSubtleCryptocalls when you need the key bytes (e.g. re-encryption on password change)
Audit Action Catalog
| Module | Action | Trigger |
|---|---|---|
auth.py |
auth.register |
New account |
auth.py |
auth.login / auth.login_failed |
Login success/fail |
auth.py |
auth.account_locked |
Failed login lockout |
auth.py |
auth.mfa_enable/disable/verify |
TOTP actions |
auth.py |
auth.mfa_backup_code_used |
Backup code login |
auth.py |
auth.mfa_backup_codes_regenerated |
Backup code regen |
auth.py |
auth.change_password / auth.change_password_failed |
Password change |
auth.py |
auth.delete_account / auth.delete_account_failed |
Deletion |
auth.py |
auth.recovery_setup/failed/items_denied/success |
Recovery |
vault.py |
vault_item.create/update/delete |
CRUD (detail: type + id only) |
vault.py |
vault_item.export / vault_item.import |
Import/Export |
folders.py |
folder.create/update/delete |
Folder CRUD |
sharing.py |
sharing_keys.create/update |
ECDH key setup |
sharing.py |
shared_item.create/delete/accept |
Sharing (detail: type + id) |
emergency.py |
emergency_access.* |
All EA state transitions |
webauthn.py |
webauthn.register |
Passkey registered |
webauthn.py |
webauthn.auth_success / webauthn.auth_failed |
Passkey login attempt |
webauthn.py |
webauthn.rename / webauthn.delete |
Credential management |
REST API
POST /api/auth/register|login|logout|refresh|recover
GET /api/auth/me|mfa/status|mfa/setup|recovery/status|recovery/data|recovery/items
POST /api/auth/mfa/enable|disable|verify|backup-codes/regenerate|change-password|recovery/setup
DELETE /api/auth/account
GET /api/vault # list all encrypted items
POST /api/vault # { name, item_type, folder_id?, enc_data, iv, enc_name?, iv_name? }
GET /api/vault/<id>
PUT /api/vault/<id>
DELETE /api/vault/<id>
GET /api/vault/export # returns encrypted item array
POST /api/vault/import # accepts array; returns { imported, skipped }
GET|POST /api/folders
PUT|DELETE /api/folders/<id>
GET|POST /api/sharing/keys
GET /api/sharing/public-key
GET|POST /api/sharing # POST: { item_id, recipient_email, enc_data, iv,
DELETE /api/sharing/<id> # item_name (=item_type), item_type,
GET /api/sharing/inbox # enc_name, iv_name }
POST /api/sharing/inbox/<id>/accept
GET|POST /api/emergency
DELETE /api/emergency/<id>
POST /api/emergency/<id>/accept|provide|request|deny
GET /api/emergency/<id>/vault
POST /api/webauthn/register/begin # start passkey registration (JWT required)
POST /api/webauthn/register/complete # finish registration + store credential
POST /api/webauthn/authenticate/begin # start passkey login (unauthenticated)
POST /api/webauthn/authenticate/complete # verify assertion → tokens + enc_key_salt
GET /api/webauthn/credentials # list registered passkeys (JWT required)
PATCH /api/webauthn/credentials/<id> # rename a passkey
DELETE /api/webauthn/credentials/<id> # remove a passkey
Development Setup
python -m venv .venv && .venv\Scripts\activate
pip install -r requirements.txt
# .env: MYSQL_*, SECRET_KEY, JWT_SECRET_KEY, TOTP_ENCRYPTION_KEY
mysql -u root -p -e "CREATE DATABASE passkeeper CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
python reset_db.py
python run.py
Production Deployment
flask db upgrade
sudo systemctl reload passkeeper
Python Dependencies
flask>=3.0 flask-sqlalchemy>=3.1 flask-migrate>=4.0 flask-login>=0.6
flask-wtf>=1.2 flask-limiter>=3.5 flask-cors>=4.0
pymysql>=1.1 argon2-cffi>=23.1 pyjwt>=2.8 python-dotenv>=1.0
gunicorn>=21.0 pyotp>=2.9.0 qrcode[pil]>=7.4.2
cryptography>=42.0 # TOTP secret encryption
redis>=5.0 # Rate-limit storage (required in production)
webauthn>=2.0 # Passkey / WebAuthn (py-webauthn)
Notes
- Never log decrypted vault data server-side — audit details use
item_type+idonly - Background health check runs after every vault load — badge + banner update without user action
- HIBP results cached in
_healthCacheper session — Security tab reuses them, no double-query - Extension health badge stored in
chrome.storage.local(health_status) — survives SW restarts password_changed_atinplainenables accurate old-password detection without schema changes- Tab visibility lock fires immediately when hidden duration ≥ idle timeout — catches screen lock
- Tags live in
enc_dataasplain.tags: string[]— no schema change ever needed vault_items_csis inchrome.storage.session— decrypted data never written to disk- HIBP checks run progressively — synchronous sections render first, then parallel async checks
- Clipboard cleared 30 s after every password/username copy (web app + extension)
- Web-app idle timeout in
localStorage(web_idle_minutes); default 15 min - Browser back/forward works for all five vault views via
history.pushState - Pending save badge (
"!") set onSAVE_CREDENTIALS, cleared viaCLEAR_SAVE_BADGE - Firefox: use
manifest.firefox.json+background.firefox.js+browser-polyfill.js - Redis required in production for rate limiting
- Recovery code never stored server-side; password change clears it (user must regenerate)
save_blocklistinchrome.storage.localsuppresses save banner per hostname
CSP & Nginx Header Architecture
Why Nginx — not Flask — owns the authoritative CSP
The deployment stack is Browser → Nginx → Gunicorn → Flask. When Nginx emits a
Content-Security-Policy header with always, it replaces any CSP header
Flask emits upstream. Changes to app/__init__.py or base.html alone are
ineffective in production — scripts/passkeeper-nginx.conf is the single source
of truth for CSP in production.
Nginx add_header inheritance rule (critical)
Any
locationblock that declares even oneadd_headerdirective silently drops alladd_headerdirectives from every parent block for that location.
The /static/ block uses add_header Cache-Control. Without explicitly repeating
all security headers inside that block, every static asset (vault.js, app.css,
etc.) is served with no CSP, no HSTS, no X-Frame-Options — none.
All security headers must be present in both the server block and the
/static/ location block. Keep them in sync whenever either is modified.
Approved external connect-src origins
| Origin | Purpose |
|---|---|
https://api.pwnedpasswords.com |
HIBP k-anonymity breach check (Security Dashboard) |
connect-src 'self' https://api.pwnedpasswords.com must appear in the CSP in
both the server block and the /static/ location block in
passkeeper-nginx.conf.
Flask-side CSP (app/__init__.py + base.html)
Flask also sets a CSP header and base.html has a <meta http-equiv> CSP tag.
Keep these in sync with the Nginx config for correctness in development (where
Nginx is not present). Note:
frame-ancestorsis valid only in HTTP headers — never in<meta>CSP tags. The browser silently ignores it in meta tags and logs a warning.- The
<meta>tag CSP does not enforceframe-ancestors; the Nginx HTTP header does.
Inline event handler prohibition
script-src 'self' blocks all inline on* HTML attributes. Do not add onclick,
onchange, or any other inline handler to any template. Wire all interactions via
addEventListener in the corresponding JS file instead.
The Vault.switchToImportExport() export on the vault.js public API exists for
historical reasons. The #sidebar-import-export element is wired via
addEventListener in init() — the exported function is not needed for new code.
Third-party browser extension noise
Console errors referencing isCheckout, content-script.js, or the extension ID
clmkdohmabikagpnhjmgacbclihgmdje originate from a third-party shopping/coupon
browser extension — not from PassKeeper. These can be ignored.
The [PassKeeper] decorateFields: host=…, matched=0 of 0 items log from
content.js is a routine debug message (not an error): the content script found
no stored credentials matching the current hostname, which is expected on the vault
page itself.
Roadmap / To-Do
Features planned for future implementation. Ordered by priority within each category.
High priority — user-facing
1. One-time share links Generate a time-limited, single-use encrypted link to share a credential with someone who has no PassKeeper account. The decryption key lives in the URL fragment (never sent to server). Auto-expires and self-destructs after first view.
- New DB table:
share_links(token, enc_data, iv, enc_key_fragment_hash, expires_at, used_at) - Public route:
GET /s/<token>— serves a minimal HTML page - Client derives key from URL fragment, decrypts, displays once
- No auth required for recipient
2. Custom fields
Let users add arbitrary key-value pairs to any item type. Fields stored encrypted inside plain.custom_fields: [{label, value}] — no schema change needed.
- Add "Add field" button in item modal
- Renders as label + value rows; value masked by default for sensitive fields
- Searchable via expanded
handleSearch
3. Trusted devices After successful login + MFA, mark the browser as trusted for N days — MFA skipped on subsequent logins from that device.
- Store a signed
trusted_devicecookie (HMAC of device fingerprint + user_id + expiry) - Server verifies on login; if valid, skip MFA challenge
- User can revoke trusted devices in Account Settings
Medium priority — security / UX
4. Email notifications Send email on security events: new login from unknown IP, MFA disabled, master password changed, emergency access requested.
- Requires SMTP config in
.env(SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASSWORD,SMTP_FROM) - Use Flask-Mail or smtplib directly
- User can opt out per event type in Account Settings
5. Audit log improvements Current audit log is a raw list. Add:
- Filter by action type and date range
- Export to CSV
- Pagination (currently loads all at once)
6. Access from unknown IP alert On login, compare client IP against last-seen IPs for the user. If new, flag in the audit log and (if email configured) send a notification.
Lower priority — platform
7. Vault item attachments Encrypt files client-side (AES-256-GCM), store as base64 blobs attached to vault items.
- New DB table:
vault_attachments(item_id, enc_data, iv, filename_enc, iv_filename, size, created_at) - Client-side size limit (e.g. 5 MB per file, 25 MB per vault)
- Download: decrypt in browser, trigger download via Blob URL
8. Collections / nested folders Group folders into top-level collections (e.g. "Work", "Personal").
- New DB table:
collections(user_id, name) folders.collection_idFK- Sidebar renders collection → folder → items hierarchy
9. iOS AutoFill Extension Expose PassKeeper as an iOS Credential Provider so it fills in Safari and other apps natively.
- Requires iOS Credential Provider Extension target in Xcode
- Shares encrypted vault data with the extension via App Groups
- Ties into the existing JQC iOS project infrastructure
10. Extension — TOTP QR capture Detect 2FA setup QR codes on screen during account registration flows.
- Content script watches for
<img>or<canvas>elements containingotpauth://URIs - Prompts user to save the TOTP secret to the matching vault item
11. Admin panel (multi-user deployments)
- User management (invite, disable, delete)
- Per-user storage and audit log review
- Requires role column on
userstable
Infrastructure
12. Backup verification cron
Extend backup.cron to run a weekly restore test into a throwaway DB, verifying backup integrity automatically.
13. Health check endpoint
GET /health — returns {"status": "ok", "db": "ok", "version": "x.y.z"}. Used by uptime monitors (UptimeRobot, etc.).
CI Pipeline
.gitea/workflows/ci.yml runs on every push to main, master, dev.
Jobs:
lint-python— flake8 (F821 and real errors; style noise suppressed)syntax-check—ast.parseallapp/Python filesmigration-check— single Alembic head, no duplicate revision IDsjs-syntax—node -e "new Function(...)"on all vault/extension JS filesbuild-extension— producespasskeeper-extension-chrome.zipandpasskeeper-extension-firefox.zipas artifacts (30-day retention)
Runner: self-hosted host-mode runner on the production server. Requires python3, pip3, node, zip on the host. No Docker needed.