diff --git a/CLAUDE.md b/CLAUDE.md index 311e3e7..0478fab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,14 +9,12 @@ A full-featured password manager web app and browser extension modelled after La ## 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 @@ -41,89 +39,67 @@ Web App <──> Nginx ──> Gunicorn ──> Flask App │ ``` passkeeper/ ├── app/ -│ ├── __init__.py # App factory (blueprints, extensions, CSRF exemptions, security headers) -│ ├── config.py # DevelopmentConfig / ProductionConfig (TOTP_ENCRYPTION_KEY, CORS_ORIGINS, RATELIMIT_STORAGE_URI) +│ ├── __init__.py # App factory, blueprints, CSRF exemptions, security headers +│ ├── config.py # DevelopmentConfig / ProductionConfig │ ├── models/ -│ │ ├── user.py # User model (Argon2id, TOTP encrypted, ECDH sharing keys, recovery columns) -│ │ ├── vault_item.py # VaultItem model (enc_data, iv, enc_name, iv_name — all sensitive fields encrypted) -│ │ ├── folder.py # Folder model -│ │ ├── token_blacklist.py # JWT revocation on logout (jti + expires_at) -│ │ ├── shared_item.py # Cross-user ECDH-encrypted item shares -│ │ ├── emergency_access.py # Emergency access grants (state machine) -│ │ └── audit_log.py # Server-side audit trail for all CUD actions +│ │ ├── 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/TOTP, logout, refresh, change-password, delete-account, recovery -│ │ ├── vault.py # CRUD vault items (accepts enc_name/iv_name on create/update) -│ │ ├── folders.py # CRUD folders -│ │ ├── sharing.py # ECDH key exchange + zero-knowledge item sharing -│ │ └── emergency.py # Emergency access state machine +│ │ ├── 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 hashing, JWT generation, blacklist, @require_jwt, TOTP encrypt/decrypt +│ │ └── auth_service.py # Argon2id, JWT, blacklist, @require_jwt, TOTP encrypt/decrypt │ ├── static/ -│ │ ├── css/app.css # Full responsive stylesheet (phone/tablet/laptop/PC breakpoints) +│ │ ├── css/app.css # Full responsive stylesheet; collapsible group styles; tag badge styles │ │ └── js/ -│ │ ├── crypto.js # Web Crypto API: deriveAuthHash, deriveVaultKey, encryptItem, decryptItem, +│ │ ├── crypto.js # deriveAuthHash, deriveVaultKey, encryptItem, decryptItem, │ │ │ # encryptName, decryptName, generateSalt -│ │ ├── auth.js # Login/register forms + TOTP MFA step + VaultSession -│ │ ├── recover.js # Account recovery flow (two-step: verify code → set new password) -│ │ ├── sharing.js # ECDH P-256 key generation, encrypt/decrypt for sharing -│ │ └── vault.js # Vault UI: all views + sidebar toggle + change-password + recovery + delete-account -│ │ # renderSecurityDashboard is async — runs HIBP k-anonymity checks after sync sections -│ └── templates/ -│ ├── base.html # CSP meta, csrf-token meta, viewport meta, no inline scripts/styles -│ ├── auth/ -│ │ ├── login.html # Login + MFA step + "Forgot password?" link -│ │ ├── register.html -│ │ └── recover.html # Two-step account recovery page -│ └── vault/ -│ └── index.html # Sidebar (collapsible), mobile topbar, vault list, modals -├── extension/ # Browser extension (Manifest V3) -│ ├── manifest.json # MV3: permissions, content_scripts, background, action -│ ├── background.js # Service worker: badge count, save-credentials relay, SSO sync, -│ │ # KEEPALIVE ping handler, OPEN_VAULT tab opener +│ │ ├── 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 # Same PBKDF2+AES-GCM logic; extractable vault key for session storage; -│ │ # encryptName / decryptName helpers for item name encryption +│ │ ├── crypto.js # PBKDF2+AES-GCM; encryptName/decryptName; extractable key +│ │ └── browser-polyfill.js # chrome=browser alias for Firefox content scripts │ ├── popup/ -│ │ ├── popup.html # Login → MFA → Unlock-only → Vault → Generator → Add Item views -│ │ ├── popup.css # Includes generator styles, save-modal overlay, pk-flyout context menu -│ │ └── popup.js # Auth, vault fetch/decrypt (decrypts enc_name), tabs, autofill trigger, -│ │ # inline password generator (fully CSPRNG via _cryptoRandInt), -│ │ # save-prompt modal, copy-username button, three-dot flyout menu, -│ │ # SSO, service-worker keepalive, relevant-tab domain filter +│ │ ├── 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 # Form detection; _isLikelyUsernameField heuristic (autocomplete/name/id scoring); -│ │ # injects PK icon into username AND password fields only; -│ │ # debounced input handler (150ms); MutationObserver guards against -│ │ # re-triggering on extension's own DOM mutations; -│ │ # _normaliseUrl handles bare domains ("github.com") +│ │ └── content.js # _isLikelyUsernameField; _normaliseUrl; debounced input (150ms); +│ │ # MutationObserver guard; vault_items_cs from chrome.storage.session │ ├── bridge/ -│ │ └── bridge.js # SSO bridge (runs on vault domain only): syncs session web↔extension -│ ├── icons/ # Generated by make_icons.py (Pillow) -│ │ ├── icon16.png -│ │ ├── icon48.png -│ │ └── icon128.png -│ └── make_icons.py # Re-run to regenerate icons: python extension/make_icons.py -├── migrations/ -│ ├── env.py # Alembic env (Flask-Migrate) -│ ├── script.py.mako -│ └── versions/ -│ ├── 71d7158dd3b9_add_audit_log_tables_fix_sharing_public_.py -│ ├── a1b2c3d4e5f6_encrypt_totp_secret_at_rest.py # Widens totp_secret + adds totp_iv -│ ├── b2c3d4e5f6a7_add_account_recovery_columns.py # Adds recovery_enc_salt + recovery_iv -│ └── c3d4e5f6a7b8_encrypt_vault_item_name.py # Adds enc_name + iv_name to vault_items +│ │ └── 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 # One-time migration: encrypt existing plaintext TOTP secrets -│ ├── backup_db.sh # Automated MySQL backup (gzip, 30-day retention) -│ ├── backup.cron # Crontab entry for daily 2 AM backup -│ ├── passkeeper-logrotate # logrotate config for Gunicorn logs -│ ├── passkeeper-nginx.conf # Hardened Nginx config (HSTS, rate limiting, upstream retry) -│ └── passkeeper.service # Hardened systemd unit (watchdog, preload, sandboxing) -├── reset_db.py # Drop + recreate all tables (dev only, handles FK checks) +│ ├── reencrypt_totp_secrets.py +│ ├── backup_db.sh / backup.cron / passkeeper-logrotate +│ ├── passkeeper-nginx.conf / passkeeper.service +├── reset_db.py ├── requirements.txt -├── wsgi.py # Gunicorn entry point -├── run.py # Dev entry point -├── .env +├── wsgi.py / run.py └── CLAUDE.md ``` @@ -136,204 +112,143 @@ passkeeper/ CREATE TABLE users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, - master_hash VARCHAR(255) NOT NULL, -- Argon2id hash of client-derived PBKDF2 auth_hash - enc_key_salt VARCHAR(64) NOT NULL, -- Random 16-byte salt (base64), returned on login - created_at DATETIME DEFAULT NOW(), - last_login DATETIME, - -- TOTP / MFA (secret AES-256-GCM encrypted at rest) - totp_secret VARCHAR(255), -- AES-256-GCM ciphertext of base32 secret (base64) - totp_iv VARCHAR(64), -- base64 12-byte GCM nonce for totp_secret + 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, - -- ECDH P-256 sharing keypair - sharing_public_key VARCHAR(128), -- Raw uncompressed point (65 bytes), base64, plaintext - sharing_private_key_enc TEXT, -- JWK, AES-256-GCM encrypted with vault key - sharing_private_key_iv VARCHAR(64), -- base64 12-byte nonce for private key encryption - -- Account recovery - recovery_enc_salt VARCHAR(128), -- enc_key_salt re-encrypted with recovery key (base64) - recovery_iv VARCHAR(64) -- base64 12-byte nonce for recovery_enc_salt + 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) ); --- Folders -CREATE TABLE folders ( +-- Vault Items +CREATE TABLE vault_items ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, - name VARCHAR(128) NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - --- Vault Items (all sensitive fields AES-256-GCM encrypted client-side) -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', -- plain string, not ENUM - name VARCHAR(255) NOT NULL, -- non-sensitive server label (item type only) - enc_data TEXT NOT NULL, -- base64 AES-256-GCM ciphertext of item payload - iv VARCHAR(64) NOT NULL, -- base64 12-byte GCM nonce for enc_data - enc_name TEXT, -- base64 AES-256-GCM ciphertext of item name - iv_name VARCHAR(64), -- base64 12-byte GCM nonce for enc_name - created_at DATETIME DEFAULT NOW(), - updated_at DATETIME DEFAULT NOW() ON UPDATE NOW(), + 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 ); - --- Token Blacklist (JWT revocation) -CREATE TABLE token_blacklist ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - jti VARCHAR(36) UNIQUE NOT NULL, - user_id INT UNSIGNED NOT NULL, - expires_at DATETIME NOT NULL, - INDEX (jti), INDEX (expires_at) -); - --- Shared Items (ECDH zero-knowledge sharing) -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, -- plaintext display name for share inbox - item_type VARCHAR(20) NOT NULL, - enc_data TEXT NOT NULL, - iv VARCHAR(64) NOT NULL, - accepted TINYINT(1) DEFAULT 0, - created_at DATETIME DEFAULT NOW(), - FOREIGN KEY (item_id) REFERENCES vault_items(id) ON DELETE CASCADE -); - --- Emergency Access -CREATE TABLE emergency_access ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - grantor_id INT UNSIGNED NOT NULL, - grantee_email VARCHAR(255) NOT NULL, - grantee_id INT UNSIGNED, - wait_days INT NOT NULL DEFAULT 7, - status VARCHAR(20) NOT NULL DEFAULT 'invited', - request_initiated_at DATETIME, - enc_vault TEXT, - created_at DATETIME DEFAULT NOW(), - FOREIGN KEY (grantor_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (grantee_id) REFERENCES users(id) ON DELETE SET NULL -); - --- Audit Logs -CREATE TABLE audit_logs ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - user_id INT UNSIGNED NOT NULL, - action VARCHAR(64) NOT NULL, - resource_type VARCHAR(64) NOT NULL, - resource_id INT UNSIGNED, - detail VARCHAR(512), - ip_address VARCHAR(45), - created_at DATETIME NOT NULL, - INDEX (user_id), INDEX (created_at) -); +-- (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, 100_000 iter)` — sent to server for auth only - - `vaultKey = PBKDF2(masterPassword, enc_key_salt, 600_000 iter)` — stays in browser memory only - - All vault data (payload + item name) encrypted client-side (AES-256-GCM) before sending to server -- **Item name encryption:** `enc_name`/`iv_name` store the AES-256-GCM ciphertext of the item name. The server's plaintext `name` column holds only the item type (e.g. `"password"`) as a non-sensitive audit label. Legacy items without `enc_name` fall back to `name` transparently. -- **Server-side:** Argon2id hash of the client-derived `authHash` (double-hashed for depth) -- **JWT:** HS256, access token 15 min, refresh token 7 days, unique JTI per token -- **JWT blacklist:** on logout both tokens are blacklisted by JTI; refresh rotates tokens -- **MFA:** TOTP (pyotp), secret stored AES-256-GCM encrypted in `users.totp_secret`/`totp_iv` -- **Sharing:** ECDH P-256 — shared secret derived client-side, re-encrypts item plaintext -- **Emergency access:** same ECDH re-encryption; wait timer enforced server-side -- **CSRF:** Flask-WTF on page-serving routes; API blueprints CSRF-exempt (JWT bearer auth) -- **Rate limiting:** Flask-Limiter (Redis-backed in production); Nginx dual-zone as second layer -- **HTTP security headers:** HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, CSP via `@app.after_request` -- **Password generator (extension):** Fully CSPRNG — `_cryptoRandInt()` uses rejection-sampling with `crypto.getRandomValues()`. `Math.random()` is never called anywhere in the generator. -- **Decrypted vault data in extension:** Stored in `chrome.storage.session` only (memory-only, cleared on browser close). Never written to `chrome.storage.local`. -- **Breach detection:** Security dashboard runs HaveIBeenPwned k-anonymity checks — only the first 5 chars of each password's SHA-1 hash are transmitted. Passwords never leave the browser. + - `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`; server `name` column = item type only +- **Tags:** `plain.tags: string[]` inside `enc_data`; server never sees them +- **Argon2id:** double-hashes `authHash` server-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 (`_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 --- -## Extension Storage Architecture — Critical Rules +## Extension Storage Architecture -### Storage area decision table - -| Data | Where stored | Why | +| Data | Storage | Reason | |---|---|---| -| `access_token`, `vault_key_jwk`, `vault_items`, `vault_items_cs` | `chrome.storage.session` | Memory-only, cleared on browser close. Decrypted data must never persist to disk. | -| `refresh_token`, `enc_key_salt`, `pending_save`, `save_blocklist`, `idle_lock_seconds` | `chrome.storage.local` | Persists across browser restarts and SW termination. | +| `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 | -### `vault_items_cs` is in `chrome.storage.session`, NOT `chrome.storage.local` +### 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. -As of the current codebase, `vault_items_cs` (the decrypted vault cache for content scripts) is written to **`chrome.storage.session`**. This is a deliberate security decision — decrypted passwords must never be persisted to disk. - -**Minimum Chrome version required: 111 (March 2023).** `chrome.storage.session` is not readable by content scripts on Chrome ≤ 110. The `storage.onChanged` listener in `content.js` watches `area === 'session'`. - -> ⚠️ **The original CLAUDE.md stated `vault_items_cs` lives in `chrome.storage.local`. That is no longer correct.** If you see it reverting to `local`, that is a regression — revert it. - -### `chrome.storage.onChanged` area guard - -```js -chrome.storage.onChanged.addListener(function(changes, area) { - if (area === 'session' && changes.vault_items_cs) { // 'session', not 'local' - ... - } -}); -``` - -### `pending_save` must remain in `chrome.storage.local` - -`chrome.storage.session` is wiped when the MV3 service worker is killed (within seconds of inactivity). `pending_save` stays in `local` so the save prompt survives SW restarts. +### 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 Decisions & Gotchas +## Key Implementation Details -### Item name is encrypted client-side (`enc_name` / `iv_name`) +### 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. +- To favourite an item: add tag `favorite` in the web app edit modal. -The `name` column in `vault_items` is **not** sensitive — it stores only the item type string (e.g. `"password"`) as a server-side audit label. The real display name is stored as AES-256-GCM ciphertext in `enc_name` with nonce `iv_name`. +### 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. -**On load:** `loadVault()` / `fetchAndDecryptVault()` decrypt `enc_name` after decrypting `enc_data` and override `item.name` with the plaintext. Legacy items without `enc_name` use the server's `name` field as fallback — no migration of existing rows required. +### 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:** parses Chrome / Bitwarden / 1Password formats; encrypts client-side before POSTing. +- Both endpoints write audit logs: `vault_item.export`, `vault_item.import`. -**On save:** `handleFormSubmit()` (web app) and `addItemToVault()` / `saveCredential()` (extension) call `Crypto.encryptName(vaultKey, name)` / `ExtCrypto.encryptName(vaultKey, name)` before POSTing, sending `enc_name` + `iv_name` alongside `enc_data` + `iv`. +### 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 (`