17 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