22 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)
│ │ ├── shared_item.py # ECDH-encrypted cross-user shares
│ │ ├── emergency_access.py # State machine
│ │ └── 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
│ ├── services/
│ │ └── auth_service.py # Argon2id, JWT, blacklist, @require_jwt, TOTP encrypt/decrypt
│ ├── 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
│ │ └── 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); Ctrl+Shift+L keyboard shortcut
│ ├── manifest.firefox.json # MV2 (Firefox)
│ ├── 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 # adds enc_name + iv_name
├── 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,
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
);
-- (folders, token_blacklist, shared_items, emergency_access, audit_logs — unchanged)
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 |
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_name; servernamecolumn = item type only - Tags:
plain.tags: string[]insideenc_data; server never sees them - Argon2id: double-hashes
authHashserver-side - JWT: HS256, 15 min access / 7 day refresh, JTI blacklisted on logout
- MFA: TOTP secret AES-256-GCM encrypted at rest
- Sharing: ECDH P-256 zero-knowledge re-encryption
- 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
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
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. - To favourite an item: add tag
favoritein the web app edit modal.
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: parses Chrome / Bitwarden / 1Password formats; encrypts client-side before POSTing.
- Both endpoints write audit logs:
vault_item.export,vault_item.import.
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.
_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, ...)
db.session.commit() # atomic
For deletes: capture id and name before flush.
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.utcnow()— 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
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.mfa_enable/disable/verify |
TOTP actions |
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 |
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 |
emergency.py |
emergency_access.* |
All EA state transitions |
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|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
DELETE /api/sharing/<id>
GET /api/sharing/inbox
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
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)
Notes
- Never log decrypted vault data server-side
- 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.