# 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 `