diff --git a/.gitignore b/.gitignore index 5f26128..c0b0336 100644 --- a/.gitignore +++ b/.gitignore @@ -283,5 +283,5 @@ marimo/\_static/ marimo/\_lsp/ **marimo**/ README.md -CLAUDE.md +# CLAUDE.md .claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9ea0e07 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,1328 @@ +# PassKeeper — Password Manager Web App & Browser Extension + +## Project Overview + +A full-featured password manager web app and browser extension modeled after LastPass. Users can store, organize, 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, extensions, CSRF exemptions, security headers) +│ ├── config.py # DevelopmentConfig / ProductionConfig (TOTP_ENCRYPTION_KEY, CORS_ORIGINS, RATELIMIT_STORAGE_URI) +│ ├── models/ +│ │ ├── user.py # User model (Argon2id, TOTP encrypted, ECDH sharing keys, recovery columns) +│ │ ├── vault_item.py # VaultItem model (item_type as VARCHAR, enc_data, iv) +│ │ ├── 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 +│ ├── routes/ +│ │ ├── auth.py # Register, login, MFA/TOTP, logout, refresh, change-password, delete-account, recovery +│ │ ├── vault.py # CRUD vault items +│ │ ├── folders.py # CRUD folders +│ │ ├── sharing.py # ECDH key exchange + zero-knowledge item sharing +│ │ └── emergency.py # Emergency access state machine +│ ├── services/ +│ │ └── auth_service.py # Argon2id hashing, JWT generation, blacklist, @require_jwt, TOTP encrypt/decrypt +│ ├── static/ +│ │ ├── css/app.css # Full responsive stylesheet (phone/tablet/laptop/PC breakpoints) +│ │ └── js/ +│ │ ├── crypto.js # Web Crypto API: deriveAuthHash, deriveVaultKey, encryptItem, decryptItem +│ │ ├── 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 +│ └── 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 (Phase 4 — 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 +│ ├── shared/ +│ │ └── crypto.js # Same PBKDF2+AES-GCM logic; extractable vault key for session storage +│ ├── popup/ +│ │ ├── popup.html # Login → MFA → Unlock-only → Vault → Generator views; +│ │ │ # shared bottom nav (Vault/Generator/Alerts/Account); +│ │ │ # blocking save-prompt modal overlay (no auto-dismiss) +│ │ ├── popup.css # Includes generator view styles + save-modal overlay styles +│ │ └── popup.js # Auth, vault fetch/decrypt, tabs, autofill trigger, +│ │ # inline password generator, save-prompt modal, SSO, +│ │ # service-worker keepalive, relevant-tab domain filter +│ ├── content/ +│ │ └── content.js # Form detection; injects PK icon into username AND password fields; +│ │ # focus/input-triggered suggestion dropdown (anchored below field); +│ │ # duplicate detection before save banner; submit watcher +│ ├── 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 +├── 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) +├── requirements.txt +├── wsgi.py # Gunicorn entry point +├── run.py # Dev entry point +├── .env +└── 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 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 (Phase 5: secret now 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 + 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 (Phase 6C) + 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 +); + +-- Folders +CREATE TABLE folders ( + 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 stored as AES-256-GCM encrypted JSON blob) +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, -- plaintext for display only + enc_data TEXT NOT NULL, -- base64 AES-256-GCM ciphertext + iv VARCHAR(64) NOT NULL, -- base64 12-byte GCM nonce + 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 (Phase 3 — JWT revocation) +CREATE TABLE token_blacklist ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + jti VARCHAR(36) UNIQUE NOT NULL, -- JWT ID claim + user_id INT UNSIGNED NOT NULL, + expires_at DATETIME NOT NULL, + INDEX (jti), INDEX (expires_at) +); + +-- Shared Items (Phase 3 — 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, + item_type VARCHAR(20) NOT NULL, + enc_data TEXT NOT NULL, -- re-encrypted with ECDH shared secret + 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 (Phase 3) +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, -- JSON array of ECDH-encrypted vault items + 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 (Phase 3 addition — server-side action trail) +CREATE TABLE audit_logs ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, -- actor; no FK so logs survive user deletion + action VARCHAR(64) NOT NULL, -- e.g. "vault_item.create" + resource_type VARCHAR(64) NOT NULL, -- e.g. "vault_item" + resource_id INT UNSIGNED, -- ID of the affected row (nullable for auth events) + detail VARCHAR(512), -- human-readable summary; NO secrets ever logged + ip_address VARCHAR(45), -- IPv4 or IPv6 (supports X-Forwarded-For) + created_at DATETIME NOT NULL, + INDEX (user_id), INDEX (created_at) +); +``` + +--- + +## Audit Log System + +Every create, edit, and delete action is recorded server-side via `AuditLog.log()`. +**No sensitive or encrypted values are ever written to the audit log.** + +### AuditLog Model (`app/models/audit_log.py`) + +```python +AuditLog.log( + user_id = int, # ID of the acting user + action = str, # dot-namespaced action string (see catalog below) + resource_type = str, # the affected entity type + resource_id = int|None, # PK of the affected row + detail = str|None, # free-text summary (no secrets) + ip_address = str|None, # best-effort client IP +) +``` + +The method adds the entry to `db.session` without committing — the surrounding request +handler's `db.session.commit()` flushes both the business-logic change and the audit row +atomically. Always call `db.session.flush()` before `AuditLog.log()` so `resource_id` +is populated. + +### Audit Action Catalog + +| Route module | Action string | Trigger | +| -------------- | ---------------------------------- | ------------------------------------------------- | +| `auth.py` | `auth.register` | New account created | +| `auth.py` | `auth.login` | Successful password verification | +| `auth.py` | `auth.login_failed` | Failed password attempt (known email) | +| `auth.py` | `auth.mfa_enable` | TOTP enabled after verification | +| `auth.py` | `auth.mfa_disable` | TOTP disabled after verification | +| `auth.py` | `auth.mfa_verify` | MFA step completed, session tokens issued | +| `auth.py` | `auth.change_password` | Master password changed, vault re-encrypted | +| `auth.py` | `auth.change_password_failed` | Password change rejected — wrong current password | +| `auth.py` | `auth.delete_account` | Account permanently deleted | +| `auth.py` | `auth.delete_account_failed` | Deletion rejected — wrong password | +| `auth.py` | `auth.recovery_setup` | Recovery code configured | +| `auth.py` | `auth.recovery_failed` | Recovery attempt — incorrect code | +| `auth.py` | `auth.recovery_items_denied` | Recovery items fetch — incorrect proof | +| `auth.py` | `auth.recovery_success` | Account recovered, vault re-encrypted | +| `vault.py` | `vault_item.create` | Vault item created | +| `vault.py` | `vault_item.update` | Vault item updated | +| `vault.py` | `vault_item.delete` | Vault item deleted | +| `folders.py` | `folder.create` | Folder created | +| `folders.py` | `folder.update` | Folder renamed | +| `folders.py` | `folder.delete` | Folder deleted | +| `sharing.py` | `sharing_keys.create` | ECDH keypair stored for first time | +| `sharing.py` | `sharing_keys.update` | ECDH keypair replaced | +| `sharing.py` | `shared_item.create` | Item shared with another user | +| `sharing.py` | `shared_item.delete` | Share revoked by owner | +| `sharing.py` | `shared_item.accept` | Recipient accepted a share | +| `emergency.py` | `emergency_access.create` | Emergency access invitation sent | +| `emergency.py` | `emergency_access.delete` | Emergency access grant removed | +| `emergency.py` | `emergency_access.accept` | Grantee accepted invitation | +| `emergency.py` | `emergency_access.provide_vault` | Grantor uploaded encrypted vault snapshot | +| `emergency.py` | `emergency_access.request` | Grantee initiated access request | +| `emergency.py` | `emergency_access.deny` | Grantor denied pending request | +| `emergency.py` | `emergency_access.vault_retrieved` | Grantee fetched vault after wait elapsed | + +### Client IP Resolution + +All route modules define a local `_client_ip()` helper that reads the +`X-Forwarded-For` header (first entry only) and falls back to `request.remote_addr`. +This correctly handles deployments behind Nginx. + +--- + +## 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 encrypted client-side (AES-256-GCM) before sending to server +- **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 (old blacklisted, new issued) +- **MFA:** TOTP (pyotp), secret stored **AES-256-GCM encrypted** in `users.totp_secret` (ciphertext, base64) + `users.totp_iv` (nonce, base64). Encrypted server-side with `TOTP_ENCRYPTION_KEY` from config. Decrypted in `auth_service.decrypt_totp_secret()` only when verifying a code — plaintext never persists in memory beyond the request. +- **Sharing:** ECDH P-256 — shared secret derived client-side, used as AES-256-GCM key to re-encrypt item plaintext; server never sees plaintext +- **Emergency access:** same ECDH re-encryption; wait timer enforced server-side; grantor can deny at any time +- **CSRF:** Flask-WTF on page-serving routes; **API blueprints are CSRF-exempt** (JWT bearer tokens make CSRF irrelevant for the API) +- **Rate limiting:** Flask-Limiter (Redis-backed in production, shared across all Gunicorn workers) on `/api/auth/register`, `/api/auth/login`, `/api/auth/mfa/verify` (10/min) and `/api/auth/refresh` (30/min). Disabled in `DevelopmentConfig`. Nginx adds a second layer: `auth_limit` zone (10 req/min, burst 5) on auth endpoints, `api_limit` zone (60 req/s, burst 20) on all other routes. +- **HTTP security headers:** set via `@app.after_request` hook in `__init__.py` on every response — `Strict-Transport-Security` (1 year, includeSubDomains), `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy`, and `Content-Security-Policy` (HTTP header overrides the meta tag). +- `enc_key_salt` stored in `sessionStorage` (not sensitive alone — useless without master password) +- Vault key stored only in JS module-level variable (`VaultSession`); lost on page reload → unlock overlay re-derives it +- **Argon2id parameters** (configurable via `.env`): `time_cost=3`, `memory_cost=65536` (64 MB), `parallelism=4` +- **CORS:** restricted to `CORS_ORIGINS` env var (`*` in dev, production domain in prod) + +--- + +## Key Implementation Decisions & Gotchas + +### `item_type` must be `VARCHAR(20)`, NOT `db.Enum(ItemType)` + +Using `db.Enum(ItemType)` with a `str`-based Python enum caused SQLAlchemy to lazy-load +the column back as a raw string after commit, making `self.item_type.value` raise +`AttributeError`. Changed to `db.String(20)` — stored and read as plain string. + +### `INTEGER(unsigned=True)` requires MySQL dialect type + +`db.Column(db.Integer, unsigned=True)` raises `TypeError`. Must use: + +```python +from sqlalchemy.dialects.mysql import INTEGER +db.Column(INTEGER(unsigned=True), ...) +``` + +### Unlock overlay must use `open` class, not `hidden` + +`.modal-overlay` CSS defaults to `display: none`; only `.modal-overlay.open` shows the overlay. +`showUnlockOverlay()` must call `classList.add('open')`, NOT `classList.remove('hidden')`. + +### No inline scripts or style attributes (CSP) + +`Content-Security-Policy: script-src 'self'; style-src 'self'` blocks all inline JS and +`style="..."` attributes. All logic must live in `.js` files; visibility toggled via CSS classes. + +### VaultSession key lost on page reload + +`window.location.href = '/vault'` causes a full page reload, clearing all JS memory. +Solution: store `enc_key_salt` in `sessionStorage` after login; show unlock overlay on +vault page load so user can re-derive the vault key without a server round-trip. + +### `reset_db.py` for schema changes (Windows, no flask db CLI) + +`flask db upgrade` is unreliable on Windows. Use `reset_db.py` instead: + +```bash +python reset_db.py +``` + +This disables MySQL foreign key checks, drops all tables, re-enables checks, then +calls `db.create_all()`. Must use `engine.connect()` to keep FK_CHECKS on the same connection. + +**Production schema changes:** Use Flask-Migrate (`flask db migrate` / `flask db upgrade`). +The migration in `migrations/versions/71d7158dd3b9_*.py` creates `audit_logs` and fixes +`sharing_public_key` column type from `TEXT` to `VARCHAR(128)`. + +### All datetimes are naive UTC (`datetime.utcnow()`) + +All models and services use naive UTC datetimes consistently. Do NOT mix in +`datetime.now(timezone.utc)` (aware datetimes) — SQLAlchemy + PyMySQL + MySQL all work +with naive datetimes; mixing causes comparison failures in token blacklist and emergency +access wait-timer checks. + +### Emergency access wait timer uses `total_seconds()`, not `.days` + +`timedelta.days` truncates sub-day precision. Use `total_seconds() / 86400` for accurate +remaining-time calculations (both in `emergency.py` and `vault.js`). + +### TOTP secret is encrypted server-side (AES-256-GCM) + +`totp_secret` in the `users` table stores **AES-256-GCM ciphertext** (base64), not the raw base32 secret. +`totp_iv` stores the corresponding 12-byte nonce (base64). The server-side key is a 32-byte value +stored as a 64-char hex string in `TOTP_ENCRYPTION_KEY` (`.env`). + +Encryption/decryption is handled exclusively by `auth_service.encrypt_totp_secret()` and +`auth_service.decrypt_totp_secret()`. The plaintext secret exists in memory only for the +duration of the request that verifies a TOTP code. + +**Generating the key:** + +```bash +python -c "import secrets; print(secrets.token_hex(32))" +``` + +**Migration path for existing deployments:** Run migration `a1b2c3d4e5f6` to widen +`totp_secret` to `VARCHAR(255)` and add `totp_iv`, then run `python scripts/reencrypt_totp_secrets.py` +to re-encrypt any existing plaintext secrets atomically. + +### AuditLog must flush before log, then commit together + +When creating/updating/deleting a resource, always call `db.session.flush()` first +to populate the resource's `id`, then call `AuditLog.log(...)`, then `db.session.commit()`. +This ensures the audit row and the business-logic change are committed atomically. + +```python +db.session.add(item) +db.session.flush() # populates item.id +AuditLog.log( + user_id=g.current_user_id, + action='vault_item.create', + resource_type='vault_item', + resource_id=item.id, # now available + ... +) +db.session.commit() # commits both together +``` + +For deletes, capture the `id` before the delete flush, since SQLAlchemy may clear it: + +```python +item_id = item.id +item_name = item.name +db.session.delete(item) +db.session.flush() +AuditLog.log(..., resource_id=item_id, detail=f'Deleted item: "{item_name}"') +db.session.commit() +``` + +### Extension vault key must be extractable (unlike web app) + +The web app uses `extractable: false` for the vault `CryptoKey` — raw bytes can never be +read back. The extension must use `extractable: true` so the key can be serialised as JWK +and stored in `chrome.storage.session` (survives popup close; cleared when the browser +closes). `extension/shared/crypto.js` diverges from `app/static/js/crypto.js` on this point. + +### Extension API_BASE targets production + +`extension/popup/popup.js` has: + +```js +const API_BASE = "https://pwkeeper.ngodanguyen.tech"; +const VAULT_URL = "https://pwkeeper.ngodanguyen.tech/vault"; +``` + +Update these constants if the server URL changes. The bridge content script in +`manifest.json` also targets this domain via `matches`. + +--- + +## Extension Storage Architecture — Critical Rules + +Understanding which storage area to use, and why, is the single most important thing when +working on the extension. Getting this wrong causes silent failures that are very hard to +diagnose. + +### Storage area decision table + +| Data | Where stored | Why | +|---|---|---| +| `access_token`, `vault_key_jwk`, `vault_items` | `chrome.storage.session` | Cleared on browser close (security). Popup and background SW can read it. | +| `refresh_token`, `enc_key_salt`, `pending_save`, `vault_items_cs` | `chrome.storage.local` | Persists across browser restarts and SW termination. Readable by content scripts on ALL Chrome versions. | + +### `chrome.storage.session` is NOT readable by content scripts on Chrome ≤ 110 + +`chrome.storage.session` access for content scripts was only added in **Chrome 111 (March +2023)**. On Chrome 110 and below, calling `chrome.storage.session.get()` from a content +script silently returns `undefined` — it does not throw. This is the root cause of the +autofill suggestion dropdown always showing "No saved passwords" even when the vault was +unlocked and the badge showed a match count. + +**Rule: never read `chrome.storage.session` from a content script.** Use +`chrome.storage.local` instead. + +### `vault_items_cs` — the content-script-safe vault cache + +`fetchAndDecryptVault()` in `popup.js` writes two copies of the decrypted items: + +1. `chrome.storage.session` key `vault_items` — full items including `enc_data`/`iv`, used + by the popup for rendering and by the background badge counter. +2. `chrome.storage.local` key `vault_items_cs` — lightweight copy (strips `enc_data`/`iv`, + keeps `id`, `name`, `item_type`, `plain`) for content script consumption. + +`vault_items_cs` is cleared on sign-out via `chrome.storage.local.remove(['refresh_token', +'enc_key_salt', 'vault_items_cs'])`. + +### `chrome.storage.onChanged` is the reliable push mechanism for content scripts + +Content scripts react to vault updates via `chrome.storage.onChanged`, not via runtime +messages. This listener fires in the **same event loop tick** as the `set()` call — no +message delivery, no service worker intermediary, no race condition: + +```js +chrome.storage.onChanged.addListener(function(changes, area) { + if (area === 'local' && changes.vault_items_cs) { + var allItems = changes.vault_items_cs.newValue || []; + _matchingItems = _filterForHost(allItems); + // re-decorate fields... + } +}); +``` + +### Why `chrome.runtime.sendMessage` from popup cannot reliably reach content scripts + +The message path is: popup → `chrome.runtime.sendMessage` → background SW → +`chrome.tabs.sendMessage` → content script. This chain has two points of failure: + +1. **SW may be killed between popup open and message send.** Chrome MV3 service workers are + terminated aggressively. If the SW dies after `fetchAndDecryptVault()` starts but before + the `sendMessage` call, the message is silently dropped (`.catch(() => {})` hides this). +2. **`chrome.tabs.sendMessage` on Windows Chrome can silently fail** for tabs on domains not + listed in `host_permissions`, even when the content script is already running. + +`VAULT_UPDATED` is still sent as a best-effort mechanism (background forwards it to all +tabs), but the `storage.onChanged` listener is the guaranteed path. Never rely solely on +runtime messages for content script data delivery. + +### `pending_save` must be stored in `chrome.storage.local`, not `chrome.storage.session` + +`chrome.storage.session` is wiped when the MV3 service worker is killed (which happens +within seconds of inactivity). Storing `pending_save` there means the save prompt disappears +before the user sees it. + +`background.js` writes `pending_save` to `chrome.storage.local` on `SAVE_CREDENTIALS`. +`popup.js` `checkPendingSave()` reads and removes it from `chrome.storage.local`. The prompt +survives SW restarts and reappears every popup open until the user acts on it. + +### Service-worker keepalive prevents mid-session `chrome.storage.session` wipes + +The popup sends a `KEEPALIVE` message to the background every 20 seconds via `setInterval`. +The background handler is a no-op (`sendResponse({ ok: true })`), but receiving the message +prevents Chrome from marking the SW as idle and terminating it — which would wipe +`chrome.storage.session` (`access_token`, `vault_key_jwk`, etc.) mid-session. + +### Save-prompt must be a blocking modal overlay, not an inline banner + +The popup window closes instantly when the user clicks anywhere outside it — Chrome +extension constraint, no override possible. Any inline banner inside the popup disappears +with it. The save-prompt is a full-screen dimmed modal overlay (`position: fixed; inset: 0`) +with a centred card, **no ✕ button and no backdrop click handler**. The only exits are +"Save" and "Not now". `checkPendingSave()` uses `.onclick` assignment (not +`.addEventListener`) to prevent duplicate handler bindings across multiple popup opens. + +### "All relevant" tab filters to current domain only — no fallback to all items + +`getTabItems()` for `_activeTab === 'relevant'` does a single filter: +```js +items = items.filter(isMatch).sort((a, b) => a.name.localeCompare(b.name)); +``` +There is no fallback to all items. When empty, `renderList()` shows +`"No saved passwords for github.com."` using `currentHostname()`. + +### Icon button is injected with `position:fixed` outside the field's DOM subtree + +The autofill icon is **not** wrapped around the input field or inserted as a sibling. It is +appended to `document.body` with `position: fixed` and tracked to the field's coordinates +via `getBoundingClientRect()` plus `scroll`/`resize` listeners. This avoids breaking flex/ +grid layouts, React-controlled inputs, and sites with strict CSS selectors. The field's +padding-right is **not** modified. + +### `decorateField()` uses AbortController to cancel stale listeners on re-decoration + +Each call to `decorateField(field, pwField)` creates an `AbortController`, stores it in +`_fieldAbortMap` (a `WeakMap`), and passes `abortSignal` to all `addEventListener` calls. +When `VAULT_UPDATED` fires and fields are re-decorated, `ac.abort()` is called first — this +atomically cancels all focus/input/blur/scroll/resize listeners and removes the icon button +via its own `abortSignal.addEventListener('abort', ...)` cleanup handler. + +**Never close event listeners over the `items` parameter at decoration time.** All +`showDropdown` calls read `_matchingItems` at call time — the module-level variable that is +always current. Closing over a snapshot of items at decoration time was the root cause of +the "stale closure" bug where the dropdown showed empty results after the vault was unlocked. + +### Suggestion dropdown uses `mousedown`, not `click`, for autofill rows + +`click` fires after `blur`. If the user clicks a dropdown row, the field fires `blur` first, +which triggers the outside-click handler and removes the dropdown before `click` fires — +the autofill never happens. `mousedown` fires before `blur`. Combined with +`e.preventDefault()`, this keeps the field focused long enough to fill both username and +password fields. + +### `showDropdown` signature — no `items` parameter + +```js +async function showDropdown(anchorField, pwField, filterText, panel) +``` + +`items` is **not** a parameter. The function reads `_matchingItems` directly (module-level, +always fresh). `panel` is `'credentials'` or `'more'`. The "More options" panel is a second +screen within the same dropdown element, navigated via Back/More options rows. + +### `OPEN_VAULT` and `OPEN_GENERATOR` messages must be handled in background.js + +Content scripts cannot call `chrome.tabs.create` or `chrome.action.openPopup` directly. +These actions require the background service worker. The content script sends the message, +background handles it. `OPEN_GENERATOR` stores `popup_nav: 'generator'` in session storage +and calls `chrome.action.openPopup()`; the popup reads and clears this flag on `init()`. + +### `isVisible()` replaces `offsetParent` check for field detection + +`el.offsetParent === null` returns true (appears invisible) for elements inside +`position: fixed` containers, which is common on modern login forms. `isVisible()` uses +`getBoundingClientRect()` + `getComputedStyle()` checks instead, correctly detecting all +visible fields regardless of their positioning context. + +### `findUsernameField()` has a two-pass fallback + +1. Walk backwards through all inputs in DOM order before `pwField`, return first + `email`/`text`/`tel` input that is visible and enabled. +2. If not found, search within the nearest `