# 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) │ │ └── 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, │ │ # 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 ├── 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) ```sql -- 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) ``` --- ## 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 | --- ## Security Model - **Zero-knowledge:** master password never sent to server - `authHash = PBKDF2(masterPassword, email, 100k iter)` → auth only - `vaultKey = 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` in `vault_items`; server `name` column = item type only - **Shared item name:** `enc_name`/`iv_name` encrypted with ECDH shared key; server `item_name` = item type only - **Tags:** `plain.tags: string[]` inside `enc_data`; server never sees them - **Argon2id:** double-hashes `authHash` server-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_codes` table, 120s TTL) - **Sharing:** ECDH P-256 zero-knowledge re-encryption; item name also encrypted with shared key - **Password generator:** fully CSPRNG (`_cryptoRandInt` rejection-sampling) - **Decrypted vault data:** `chrome.storage.session` only — 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_salt` NOT 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/items` call to prevent proof replay - **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 ### 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 login callsites. ### 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 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) 5. POST /recover → validates proof again; consumes rotated challenge; atomically re-encrypts vault + resets password ``` ### 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[]` inside `enc_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-tag` badges below item subtitle. Favorites tab = items where `tags.includes('favorite')`. Favorited items show gold ★ in site label. - `handleSearch()` pool correctly branches on `itemType` / `folder` / `tag` filter types. ### Collapsible folder groups - **Web app:** `_collapsedGroups` Set persists state across re-renders. Header shows name + count badge + chevron (▼/▶). Click toggles and re-renders. - **Extension:** only on "All items" tab. `_folders` fetched in parallel with vault. `_collapsedFolders` Set. `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/import` directly. - **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 of `init()`. Listens to `mousemove`, `mousedown`, `keydown`, `touchstart`, `scroll`. - On timeout: `VaultSession.clear()` → unlock overlay → toast. - Stored in `localStorage` as `web_idle_minutes`. Default 15 min. Options: Never/5/10/15/30/60. - Exposed in Account Settings → Auto-Lock (`