Files
PassKeeper/CLAUDE.md
T

72 KiB
Raw Blame History

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)

-- 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)

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:

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:

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:

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.

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:

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:

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:

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:

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

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 <form> or [role="form"] ancestor.

This handles SPAs where the username and password fields are not sequential siblings in the flat DOM.

TOTP for vault items — stored in the encrypted plain blob, not in the DB schema

Per-site TOTP secrets are stored as plain.totp_uri inside the AES-256-GCM encrypted vault item blob. No database schema change is required — the server never sees the secret. This is distinct from the user's account MFA (users.totp_secret), which is a server-side AES-256-GCM encrypted field used for extension/web-app login verification.

totp_uri accepts two formats:

  • Full otpauth://totp/... URI (output from QR code scanner apps like Aegis)
  • Plain base32 secret (e.g. JBSWY3DPEHPK3PXP)

extractTotpSecret() handles both. The plain object for a password item is:

{ url, username, password, totp_uri, notes }

TOTP code generation — pure Web Crypto, no library

The RFC 6238 TOTP implementation is ~40 lines using crypto.subtle.sign('HMAC', ...) with SHA-1. Key steps:

  1. Decode base32 secret → Uint8Array via base32ToBytes()
  2. Compute counter = Math.floor(Date.now() / 1000 / 30) written as big-endian 64-bit
  3. Import key bytes, sign counter with HMAC-SHA-1
  4. Dynamic truncation: offset = sig[19] & 0x0f, extract 4 bytes, mask high bit, mod 1,000,000
  5. Left-pad to 6 digits

Both web app (vault.js) and extension (popup.js) carry identical copies of this logic. Do not introduce a dependency — the implementations are intentionally self-contained.

TOTP tickers must be cleared on every list re-render

Each vault item with a totp_uri starts a setInterval(tick, 1000). If renderList() re-renders without clearing them first, intervals accumulate indefinitely — one per render per TOTP item. Always call _clearTotpTickers() at the top of renderList() (extension) and store the interval IDs in _totpIntervals. In the web app, the interval ID is stored in li.dataset.totpInterval and cleared when items are re-rendered. Use codeEl.isConnected inside the tick callback to bail out gracefully if the element was removed mid-interval.

Vault item CSS was entirely missing from app.css

The vault item classes (vault-item, item-name, item-sub, item-info, item-icon, item-actions) had no CSS rules in app.css — the items rendered through browser defaults and inherited flexbox from parent containers. They appear visually correct only because the HTML structure is clean. When adding TOTP, the relevant vault item rules (vault-item, item-name, item-sub, etc.) were added at the same time. If vault item layout ever breaks, check that these rules are present in app.css starting after the btn-icon[data-action="launch"] rule.

save_blocklist — per-site never-save list

Stored in chrome.storage.local as save_blocklist: string[] (array of hostnames). Written by the "Never for this site" button in the save banner (addToBlocklist(hostname)). Checked at the very start of watchSubmissions submit handler via isBlocked(hostname) before any credential classification. No UI yet to view or remove entries — manageable via chrome://extensions/ → PassKeeper → "Inspect views: service worker" → Application → Local Storage as a stop-gap.

_addViewInitialised guard prevents duplicate event listener binding

initAddView() is called every time the user opens the Add Item view. Event listeners (back, toggle, generate, save, Enter) are bound only once via the _addViewInitialised boolean. Fields (except URL and site name, which are pre-filled from the active tab) are cleared on each open. The URL/name pre-fill runs every open via chrome.tabs.query.

Load extension in Chrome for development

  1. Open chrome://extensions/
  2. Enable Developer mode (top-right toggle)
  3. Click Load unpacked → select extension/ folder
  4. Reload after any JS/CSS changes; no reload needed for icon or manifest changes.
  5. Check content script logs in the page's DevTools console (not the popup's console).
  6. Check background SW logs via chrome://extensions/ → PassKeeper → "Service Worker" link.

Do NOT use exclude_matches with chrome:// or chrome-extension:// schemes

These schemes are invalid in exclude_matches and will prevent the extension from loading. They are already excluded implicitly since matches only lists http://*/* and https://*/*.

Autofill uses native HTMLInputElement setter for framework compatibility

React/Vue/Angular intercept el.value = x but not the native property setter. The content script uses Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set to set the value, then dispatches input + change events so frameworks detect the change.

Load extension in Chrome for development

  1. Open chrome://extensions/
  2. Enable Developer mode (top-right toggle)
  3. Click Load unpacked → select extension/ folder
  4. Reload after any JS/CSS changes; no reload needed for icon or manifest changes.

Do NOT use exclude_matches with chrome:// or chrome-extension:// schemes

These schemes are invalid in exclude_matches and will prevent the extension from loading (Invalid scheme error). They are already excluded implicitly since matches only lists http://*/* and https://*/*. Remove the exclude_matches field entirely.

Autofill uses native HTMLInputElement setter for framework compatibility

React/Vue/Angular intercept el.value = x but not the native property setter. The content script uses Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set to set the value, then dispatches input + change events so frameworks detect the change.

JWT sub claim must be a string (PyJWT 2.x)

PyJWT >= 2.0 enforces that the sub (subject) claim must be a string. Passing an integer user_id directly causes InvalidClaimError: Subject must be a string on decode, even though encoding succeeds silently. Always convert: 'sub': str(user_id) when generating tokens, and int(payload['sub']) when reading it back for DB lookups.

Do not call loadFolders() unconditionally in vault.js init()

loadFolders() was previously called at the top of init() before the vault key check. This triggered an API call even when the unlock overlay was showing. If the token was invalid for any reason, tryRefreshToken() ran immediately and redirected to login before the user could enter their unlock password. loadFolders() is already included inside loadVault() via Promise.all — do not add a separate call at init.

Extension SSO bridge — session shared between web app and extension

extension/bridge/bridge.js is a content script that runs only on pwkeeper.ngodanguyen.tech. It bridges sessions in both directions:

  • Web app → Extension: auth.js dispatches passkeeper:session custom event after login; bridge forwards access_token, refresh_token, enc_key_salt to the background via chrome.runtime.sendMessage({ type: 'WEB_SESSION_SYNC', ... }). Background stores access_token + enc_key_salt to chrome.storage.session and refresh_token + enc_key_salt to chrome.storage.local (local persists across browser restarts). When the extension popup opens it detects tokens without a vault key and shows an unlock-only view (master password only, no email).

  • Extension → Web app (new tab): On every page load bridge.js checks whether the web page has an access_token in sessionStorage. If not, it reads refresh_token + enc_key_salt from chrome.storage.local, calls /api/auth/refresh to obtain a fresh access_token, injects all three into the page's sessionStorage/localStorage, and dispatches passkeeper:ext-login. This is the primary path when the user opens the vault URL from the extension popup.

  • Extension → Web app (already-open tab): After popup login, popup sends EXT_SESSION_SYNC to background; background finds open vault tabs and sends INJECT_SESSION to the bridge; bridge writes tokens and dispatches passkeeper:ext-login.

  • Logout is shared: vault.js redirectToLogin() and handleLogout() both dispatch passkeeper:logout; bridge forwards WEB_SESSION_CLEAR to background, which clears all extension storage.

The popup has three auth views: view-login (full login), view-mfa (TOTP), and view-unlock (master password only, used when tokens already exist from the web app).

vault.js init() waits for extension bridge before redirecting

vault.js init() is async. If sessionStorage has no access_token on page load, it waits up to 800 ms for a passkeeper:ext-login event (fired by bridge.js after it injects fresh tokens). Only if no event arrives within the timeout does it redirect to /login. This prevents the race where vault.js redirected before bridge.js finished its async chrome.storage read and token refresh.

tryRefreshToken is a singleton in vault.js

vault.js loadVault() calls Promise.all([apiFetch('/api/vault'), apiFetch('/api/folders')]). If the access_token is expired both requests get 401 simultaneously and both would call tryRefreshToken() — the second call would send the already-rotated (blacklisted) refresh token and trigger redirectToLogin(). tryRefreshToken uses a module-level _refreshPromise so all concurrent callers share one in-flight request and receive the same result.

Sharing private key never leaves the client unencrypted

The ECDH P-256 private key (JWK) is encrypted with the vault key (AES-256-GCM) before being sent to the server. Stored as sharing_private_key_enc + sharing_private_key_iv. The server stores only the encrypted JWK — it cannot derive the ECDH shared secret.

Emergency access status machine

invited  → (grantee /accept)      → accepted
accepted → (grantor /provide)     → ready
ready    → (grantor /provide)     → ready    (grantor can re-upload snapshot any time)
ready    → (grantee /request)     → pending  (wait timer starts)
pending  → (grantor /deny)        → ready    (grantee may re-request)
pending  (wait_days elapsed)      → wait_elapsed=true, grantee may call /vault

Duplicate active invitations are blocked: a new invitation is rejected if an existing record exists with status in ['invited', 'accepted', 'ready', 'pending'] for the same grantor/grantee pair.

TokenBlacklist opportunistic cleanup

blacklist_token() in auth_service.py calls TokenBlacklist.cleanup_expired() after each blacklist write. This deletes rows where expires_at <= now() in the same DB session. This keeps the table manageable without a separate scheduled job.

Failed login attempts are audited

auth.py login() logs auth.login_failed to audit_logs when the password check fails for a known email. Unknown emails are not logged (no user_id to attach to). The 0.1 s time.sleep() before the user lookup mitigates timing-based user enumeration regardless.

wait_days input is guarded against non-numeric values

emergency.py create_emergency() wraps int(data.get('wait_days', 7)) in try/except (TypeError, ValueError) and returns HTTP 400 on invalid input. Without this, a non-numeric string would raise an unhandled ValueError and return a 500.

Nginx upstream block — do not proxy_pass directly to 127.0.0.1:5000

The Nginx config uses an upstream passkeeper_app { server 127.0.0.1:5000; keepalive 32; } block rather than proxy_pass http://127.0.0.1:5000 directly. This enables:

  • proxy_next_upstream — on error, timeout, http_502, or http_503, Nginx retries once within 10 s, bridging the brief window when Gunicorn is restarting.
  • keepalive 32 — connection pool reuse between Nginx and Gunicorn workers.
  • Proxy timeoutsproxy_connect_timeout 5s, proxy_read_timeout 30s cap hung connections.

Never revert to direct proxy_pass http://127.0.0.1:5000 — it loses all retry capability.

Gunicorn must run with --preload

Without --preload, each of the 4 workers independently imports the full Flask app on startup. If one worker crashes during app initialization (e.g. a transient DB connection failure at boot), it dies silently while Gunicorn reports healthy — resulting in fewer active workers and Connection refused errors under concurrent load.

With --preload, the master process loads the app once and forks workers from the snapshot. Worker crashes are isolated; the master immediately spawns a replacement without re-importing the app. This also reduces per-worker memory footprint via copy-on-write.

Trade-off: --preload means app code is loaded before workers fork, so code that opens connections in module scope (not recommended) will share file descriptors across workers. PassKeeper uses Flask-SQLAlchemy's connection pool which is fork-safe by default.

ProtectSystem=strict is NOT used in the systemd service

ProtectSystem=strict makes the entire filesystem read-only except for paths listed in ReadWritePaths. Any path that Gunicorn, SQLAlchemy, or the OS touches at runtime that is missing from that list causes a silent startup failure — the service starts, systemd reports it active, but Gunicorn cannot bind or write logs, producing Connection refused. PrivateTmp=true and NoNewPrivileges=true are retained as they carry no such risk.

Change password is a single atomic operation

POST /api/auth/change-password accepts the new credentials AND a full array of all re-encrypted vault items in one request. The server updates master_hash, enc_key_salt, and every vault_items row in a single transaction with rollback on any error. This prevents the vault ever being in a split-encrypted state (some items with old key, some with new). The recovery code is also cleared atomically — it was encrypted with the old vault key and is now invalid.

Recovery code is one-time use

POST /api/auth/recover clears recovery_enc_salt and recovery_iv after a successful recovery. The recovery code cannot be reused. The user must generate a new one from Account Settings after logging in. This prevents replay attacks if a recovery code is ever exposed.

Recovery proof — how the server verifies the recovery code without knowing it

The server cannot verify the recovery code directly (it never stores it). Verification works via a proof: the client decrypts recovery_enc_salt using the recovery key — if the code is wrong, AES-GCM authentication tag verification fails client-side. The client then sends recovery_proof = decrypted_enc_key_salt (the plaintext). The server checks recovery_proof == user.enc_key_salt. If correct, the client demonstrably had the right recovery code. This is the same pattern as a HMAC proof without needing a shared secret.

Sidebar collapsed state persists in localStorage

vault.js reads localStorage.getItem('sidebar_collapsed') on every page load and applies .collapsed before paint — no flash of expanded sidebar. The toggle button is always visible and clickable because .sidebar.collapsed .sidebar-header uses justify-content: center with the logo icon/text set to width: 0; pointer-events: none.

Mobile sidebar uses CSS transform, not display:none

The mobile sidebar overlay uses transform: translateX(-100%)transform: translateX(0) rather than display: none toggle. This allows the CSS transition to animate the slide-in. The .mobile-open class is added by JS; the backdrop (#sidebar-backdrop) receives .open simultaneously. document.body.style.overflow = 'hidden' prevents the page from scrolling while the sidebar is open.

Vault list content width is constrained by a wrapper div, not the <ul> itself

The #vault-list <ul> element is the target JS renders vault items into — its ID must not change. The max-width constraint lives on a parent .vault-list-inner div. The scroll container is .vault-list (the grandparent). Do not apply max-width directly to #vault-list or move items to a different container — JS will stop finding them.

<div class="vault-list">
  <!-- scroll container, full width -->
  <div class="vault-list-inner">
    <!-- max-width: 900px, centred -->
    <ul id="vault-list"></ul>
    <!-- JS renders here — ID must not change -->
  </div>
</div>

Development Setup (Windows)

# 1. Create virtual environment
python -m venv .venv
.venv\Scripts\activate

# 2. Install dependencies
pip install -r requirements.txt

# 3. Configure environment
copy .env.example .env
# Edit .env: set MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DB, SECRET_KEY, JWT_SECRET_KEY

# 4. Create MySQL database
mysql -u root -p -e "CREATE DATABASE passkeeper CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"

# 5. Create user and grant privileges
CREATE USER 'user'@'host' IDENTIFIED BY 'password';
GRANT ALL PRIVILEGES ON passkeeper.* TO 'user'@'host';
FLUSH PRIVILEGES;

# 6. Create tables
python reset_db.py

# 7. Run dev server
python run.py

Production Deployment (Ubuntu + Nginx + Gunicorn + systemd)

1. Server Setup

sudo apt update && sudo apt install python3.12 python3.12-venv python3-pip \
    mysql-server nginx certbot python3-certbot-nginx

2. App Deployment

cd /var/www/passkeeper
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env  # fill in production values
python reset_db.py    # first deploy only

For subsequent schema changes, use Flask-Migrate:

flask db upgrade

3. Gunicorn test

gunicorn --workers 4 --bind 127.0.0.1:5000 wsgi:app

4. systemd Service (/etc/systemd/system/passkeeper.service)

[Unit]
Description=PassKeeper Gunicorn daemon
After=network.target mysql.service
Wants=mysql.service
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
User=spuser
Group=www-data
WorkingDirectory=/home/spuser/PassKeeper
EnvironmentFile=/home/spuser/PassKeeper/.env
ExecStart=/home/spuser/.venv/bin/gunicorn \
          --workers 4 \
          --bind 127.0.0.1:5000 \
          --preload \
          --timeout 30 \
          --graceful-timeout 20 \
          --keep-alive 5 \
          --access-logfile /home/spuser/logs/access.log \
          --error-logfile  /home/spuser/logs/error.log \
          --log-level warning \
          wsgi:app
ExecReload=/bin/kill -s USR2 $MAINPID
WatchdogSec=60s
Restart=on-failure
RestartSec=5s
PrivateTmp=true
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable passkeeper
sudo systemctl start passkeeper
journalctl -xeu passkeeper.service

5. Nginx Config (/etc/nginx/sites-available/passkeeper)

limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=api_limit:10m  rate=60r/s;

upstream passkeeper_app {
    server 127.0.0.1:5000;
    keepalive 32;
}

server {
    server_name pwkeeper.ngodanguyen.tech passkeeper.ngodanguyen.tech;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Frame-Options           "DENY"                              always;
    add_header X-Content-Type-Options    "nosniff"                           always;
    add_header Referrer-Policy           "strict-origin-when-cross-origin"   always;
    add_header Permissions-Policy        "geolocation=(), camera=(), microphone=()" always;
    add_header Content-Security-Policy
        "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';"
        always;

    server_tokens off;
    client_max_body_size 1m;

    proxy_connect_timeout 5s;
    proxy_read_timeout    30s;
    proxy_send_timeout    30s;
    proxy_next_upstream error timeout http_502 http_503;
    proxy_next_upstream_tries   2;
    proxy_next_upstream_timeout 10s;

    location ~ ^/api/auth/(login|register|mfa/verify) {
        limit_req zone=auth_limit burst=5 nodelay;
        limit_req_status 429;
        proxy_pass http://passkeeper_app;
        proxy_http_version 1.1;
        proxy_set_header Connection        "";
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /static/ {
        alias /home/spuser/PassKeeper/app/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    location / {
        limit_req zone=api_limit burst=20 nodelay;
        limit_req_status 429;
        proxy_pass http://passkeeper_app;
        proxy_http_version 1.1;
        proxy_set_header Connection        "";
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/pwkeeper.ngodanguyen.tech/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/pwkeeper.ngodanguyen.tech/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}

server {
    if ($host = pwkeeper.ngodanguyen.tech) {
        return 301 https://$host$request_uri;
    } # managed by Certbot
    listen 80;
    server_name pwkeeper.ngodanguyen.tech passkeeper.ngodanguyen.tech;
    return 404; # managed by Certbot
}
sudo ln -s /etc/nginx/sites-available/passkeeper /etc/nginx/sites-enabled/
sudo certbot --nginx -d yourdomain.com
sudo nginx -t && sudo systemctl reload nginx

REST API Endpoints

All /api/vault/* and /api/folders/* routes require Authorization: Bearer <access_token>. All API blueprints are exempt from CSRF (JWT auth makes it unnecessary).

POST   /api/auth/register          # { email, auth_hash, enc_key_salt }
POST   /api/auth/login             # { email, auth_hash } → { access_token, refresh_token, enc_key_salt }
                                   # or → { mfa_required: true, mfa_token, enc_key_salt } if TOTP enabled
POST   /api/auth/logout            # blacklists both tokens
POST   /api/auth/refresh           # { refresh_token } → { access_token, refresh_token } (rotates)
GET    /api/auth/me                # → { id, email, created_at, last_login, totp_enabled, recovery_configured }
GET    /api/auth/mfa/status        # → { totp_enabled }
GET    /api/auth/mfa/setup         # → { secret, qr_code, uri }  (generates TOTP secret, not stored yet)
POST   /api/auth/mfa/enable        # { secret, totp_code } — verifies first code & stores AES-256-GCM encrypted secret
POST   /api/auth/mfa/disable       # { totp_code } — verifies & clears secret
POST   /api/auth/mfa/verify        # { mfa_token, totp_code } → { access_token, refresh_token }
POST   /api/auth/change-password   # { current_auth_hash, new_auth_hash, new_enc_key_salt, items: [{id,enc_data,iv}] }
DELETE /api/auth/account           # { auth_hash } — permanently deletes account + all data (FK cascade)
POST   /api/auth/recovery/setup    # { recovery_enc_salt, recovery_iv } — store recovery blob
GET    /api/auth/recovery/status   # → { recovery_configured }
GET    /api/auth/recovery/data     # ?email= → { enc_key_salt, recovery_enc_salt, recovery_iv } (unauthenticated)
GET    /api/auth/recovery/items    # ?email= + X-Recovery-Proof header → { items: [{id,enc_data,iv}] } (unauthenticated)
POST   /api/auth/recover           # { email, new_auth_hash, new_enc_key_salt, recovery_proof, items } (unauthenticated)
                                   # → { access_token, refresh_token, enc_key_salt }

GET    /api/vault                  # list all items (encrypted blobs)
POST   /api/vault                  # { name, item_type, folder_id, enc_data, iv }
GET    /api/vault/<id>
PUT    /api/vault/<id>
DELETE /api/vault/<id>

GET    /api/folders
POST   /api/folders                # { name }
PUT    /api/folders/<id>           # { name }
DELETE /api/folders/<id>

GET    /api/sharing/keys           # → { keys_setup, public_key, private_key_enc, private_key_iv }
POST   /api/sharing/keys           # { public_key, private_key_enc, private_key_iv }
GET    /api/sharing/public-key     # ?email=… → { user_id, email, public_key }
GET    /api/sharing                # list outgoing shares
POST   /api/sharing                # { item_id, recipient_email, enc_data, iv, item_name, item_type }
DELETE /api/sharing/<id>           # revoke share
GET    /api/sharing/inbox          # list incoming shares (includes owner_email, owner_public_key)
POST   /api/sharing/inbox/<id>/accept

GET    /api/emergency              # → { grants: [...], access: [...] }
POST   /api/emergency              # { grantee_email, wait_days }
DELETE /api/emergency/<id>
POST   /api/emergency/<id>/accept  # grantee accepts invitation
POST   /api/emergency/<id>/provide # { enc_vault } — grantor uploads ECDH-encrypted snapshot
POST   /api/emergency/<id>/request # grantee starts wait timer
POST   /api/emergency/<id>/deny    # grantor denies pending request
GET    /api/emergency/<id>/vault   # grantee fetches vault after wait elapsed → { enc_vault, grantor_public_key }

UI / Feature Spec

Left Sidebar

  • All Items
  • Item type filters (Passwords, Secure Notes, Payment Cards, Bank Accounts, Addresses, Identities)
  • Folder list with create (+) and delete (🗑) per folder
  • Sharing Center
  • Security Dashboard
  • Emergency Access
  • Account Settings (MFA + Sharing Keys + Change Password + Recovery Code + Delete Account)
  • Collapsible sidebar — toggle button collapses to 72px icon rail; state persisted in localStorage; tooltips on hover in collapsed state
  • Passkeys

Vault Main View

  • Search bar (real-time client-side filter)
  • Items grouped by folder
  • Floating action button (+ Add Item)
  • Item row: copy username, copy password, edit, delete (actions always visible)
  • TOTP live code — password items with totp_uri show a live 6-digit code + countdown timer; 🔐 copy button in teal
  • Sort by: Name AZ / ZA / Newest / Oldest / By folder
  • Content max-width — vault list and header capped at 900px (1100px on large PC) to prevent excessive stretching on wide displays
  • Grid / List view toggle

Responsive Layout

Breakpoint Behaviour
Phone ≤ 640px Sidebar removed from flow; slides in as overlay via hamburger (☰) in top bar; backdrop closes it; modals become bottom sheets; mobile FAB in top bar
Tablet 6411024px Sidebar in flow at 200px; user can collapse to 72px icon rail
Laptop 10251440px Full 240px sidebar; standard 24px padding
Large PC > 1440px 260px sidebar; 40px padding; content areas widen to 1100px max-width

Item Types & Fields

Type Fields
Password Name, URL, Username, Password, TOTP URI (optional), Notes, Folder
Secure Note Name, Note body, Folder
Address Name, Title, First/Last name, Company, Address, Phone, Email
Payment Card Name, Card number, Expiry, CVV, Cardholder name
Bank Account Bank name, Account type, Routing number, Account number
SSN Name, Number
Passkey Name, Origin, Credential ID, Public key

Browser Extension (Phase 4)

  • Manifest V3 (Chrome/Edge; Firefox needs minor tweaks)
  • Popup: LastPass-style UI — search, tabs (All relevant / All items / Recents), item avatars
  • Toolbar popup: search vault, copy password, autofill button per item
  • "All relevant" tab: shows only items matching the current tab's domain; empty state shows the hostname
  • Content script: detect login forms, inject PK icon button into both username and password fields
  • Suggestion dropdown: anchored below the focused field; shows site + username + edit icon; filters as user types; keyboard navigation (↑↓ Enter); "More options" panel; ✓ Filled flash on autofill
  • Auto-save banner — "Never for this site" button adds domain to persistent blocklist; duplicate detection; blocking modal in popup
  • Inline password generator: length slider, charset checkboxes, strength indicator, copy + refresh
  • Bottom nav: Vault / Generator / Alerts / Account
  • Account view: configurable idle lock timeout (1/5/10/30 min / Never); open web vault; sign out
  • Inline Add Item form — pre-fills URL + site name from current tab; generate button; folder select; saves directly via API without opening web vault
  • TOTP live display — items with totp_uri show a live 6-digit code + countdown timer in the popup row; one-click copy; ticker clears on re-render
  • SSO with web app (login once, shared session via bridge.js)

Build Phases

Phase 1 — Core Auth & Vault COMPLETE

  • User registration / login with Argon2id
  • Zero-knowledge vault key derivation (PBKDF2, Web Crypto API)
  • AES-256-GCM encrypt/decrypt in browser
  • Store/retrieve encrypted password items (CRUD)
  • Folder create, delete, filter
  • Unlock overlay (re-derive vault key after page reload)
  • JWT access + refresh token auth
  • Sign out

Phase 2 — Full Item Types & UI Polish COMPLETE

  • All 7 item types (password, note, card, bank, address, SSN, passkey) with type-specific forms
  • Security dashboard (weak / reused / old passwords with score)
  • Sort by: Name AZ / ZA / Newest / Oldest / By folder
  • UI polish (emoji icons, sidebar navigation, responsive layout)
  • Grid / List view toggle (deferred)

Phase 3 — Sharing & MFA COMPLETE

  • TOTP-based MFA (Google Authenticator / Authy) — setup, enable, disable, verify
  • Item sharing via email — ECDH P-256 zero-knowledge re-encryption
  • Sharing inbox — accept, view decrypted shared items (read-only detail modal)
  • Emergency access — state machine (invited → accepted → ready → pending → vault access)
  • Emergency vault view — collapsible in-page panel with decrypted item fields
  • JWT token blacklisting on logout + refresh token rotation
  • Account Settings modal (MFA management + sharing key generation)
  • Audit logging — server-side trail for all create/edit/delete actions

Phase 4 — Account Management & UI Polish COMPLETE

  • Master password change (6A) — zero-knowledge: client re-encrypts all vault items with new vault key, submits atomically; server verifies current password; recovery code cleared on change

  • Account deletion (6B) — requires password confirmation; FK cascade deletes all data; audited

  • Account recovery (6C) — 128-bit recovery code; recovery_key = PBKDF2(code, 'passkeeper-recovery', 200k); enc_key_salt encrypted with recovery key stored server-side; one-time use (consumed on recovery); /recover page with two-step flow

  • GET /api/auth/me — lightweight profile endpoint (email, MFA status, recovery status)

  • Collapsible sidebar — 240px ↔ 72px icon rail; localStorage persistence; CSS transitions; tooltips via ::after pseudo-element; toggle button always accessible

  • Full responsive layout — phone overlay sidebar + mobile top bar, tablet icon rail, laptop standard, large PC wider content areas; modals as bottom sheets on mobile

  • Vault content max-width.vault-list-inner + .vault-header-inner cap content at 900px (1100px on large PC) to prevent over-stretching on wide displays

  • Account Settings modal expanded — Change Password, Recovery Code, Danger Zone (Delete Account) sections added

  • Manifest V3 extension (Chrome/Edge; Firefox needs minor tweaks)

  • Popup: LastPass-style UI — search bar, tabs (All relevant / All items / Recents), colored avatars

  • Popup views: Login → MFA → Unlock-only → Vault → Generator → Account → Add Item

  • Inline password generator — length slider + charset checkboxes + strength indicator + copy/refresh; no external tab

  • Bottom nav: Vault / Generator / Alerts / Account — shared across authenticated views

  • Account view — configurable idle lock (1/5/10/30 min / Never stored in chrome.storage.local); open web vault; sign out; setting applied immediately to background service worker via SET_IDLE_TIMEOUT message

  • Idle lockchrome.idle API; applyIdleInterval() reads user preference on every SW startup; clears session + vault_items_cs on idle/locked; respects "Never" setting

  • Broad host permissionshost_permissions: ["http://*/*", "https://*/*"] so chrome.tabs.sendMessage reaches all tabs reliably

  • "All relevant" tab shows ONLY items matching the current tab's domain; empty state shows hostname

  • Badge on toolbar icon showing number of matching vault items for current tab

  • Content script: injects position:fixed PK icon (outside field DOM) into both username and password fields; isVisible() handles position:fixed containers; AbortController per field for clean re-decoration

  • Suggestion dropdown: anchored below field; site + username + edit pencil; filters as user types; ArrowUp/Down/Enter keyboard navigation; "More options" panel (Back / Generate / Open vault); ✓ Filled flash on autofill; closes on Escape or outside mousedown

  • vault_items_cs in chrome.storage.local — content-script-safe cache; storage.onChanged listener for instant re-decoration; no chrome.storage.session reads in content scripts

  • Autofill is React/Vue/Angular compatible (native HTMLInputElement setter + events)

  • Duplicate detection before save banner: new / updated / same; same suppressed silently

  • "Never for this site" — third button on save banner; adds hostname to save_blocklist in chrome.storage.local; checked before showing banner on submit

  • Save-prompt modal: blocking overlay, no auto-dismiss, no ✕; folder dropdown from /api/folders; pending_save in chrome.storage.local

  • Inline Add Item formview-add; pre-fills URL + site name from active tab; generate button; folder select; POST /api/vault; returns to vault on success

  • TOTP live display in popup — items with plain.totp_uri show live 6-digit code + countdown; one-click copy; 1s ticker per item; all tickers cleared on re-render via _clearTotpTickers()

  • Token refresh + session restore (vault key in chrome.storage.session)

  • Service-worker keepalive: popup pings background every 20 s

  • SSO bridge (bridge.js): login once on web app; logout synced both ways

Phase 5 — Production Hardening COMPLETE

  • TOTP secrets encrypted at rest — AES-256-GCM server-side encryption (TOTP_ENCRYPTION_KEY); migration a1b2c3d4e5f6 + scripts/reencrypt_totp_secrets.py
  • Rate limiting fixed — Redis-backed (RATELIMIT_STORAGE_URI), shared across all Gunicorn workers; Nginx dual-zone rate limiting as second layer
  • HTTP security headers — HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, CSP via @app.after_request hook
  • CORS lockedCORS_ORIGINS env var (production: domain only, dev: *)
  • CSP via HTTP header — overrides meta tag, applied to all responses including API
  • Failed login auditauth.login_failed logged to audit_logs on bad password
  • wait_days input validationTypeError/ValueError guard in emergency.py
  • Nginx hardeningupstream block, proxy_next_upstream retry, proxy timeouts, server_tokens off, client_max_body_size, Cache-Control: immutable on static
  • Gunicorn --preload — single app import, isolated worker crashes, lower memory footprint
  • systemd watchdogWatchdogSec=60s; Restart=on-failure + RestartSec=5s; StartLimitBurst=5; PrivateTmp, NoNewPrivileges
  • Automated MySQL backupsscripts/backup_db.sh (gzip, 30-day retention); scripts/backup.cron (daily 2 AM)
  • Log rotationscripts/passkeeper-logrotate (daily, 30-day retention, zero-downtime USR1 signal)

Python Dependencies (requirements.txt)

flask>=3.0
flask-sqlalchemy>=3.1
flask-migrate>=4.0
flask-login>=0.6
flask-wtf>=1.2
flask-limiter>=3.5
flask-cors>=4.0
pymysql>=1.1
argon2-cffi>=23.1
pyjwt>=2.8
python-dotenv>=1.0
gunicorn>=21.0
pyotp>=2.9.0          # TOTP MFA (Phase 3)
qrcode[pil]>=7.4.2    # QR code generation for MFA setup (Phase 3)
cryptography>=42.0    # AES-256-GCM server-side TOTP secret encryption (Phase 5)
redis>=5.0            # Shared rate-limit storage across Gunicorn workers (Phase 5)

Notes

  • Never log decrypted vault data server-side
  • All encryption/decryption of vault data happens in the browser (JavaScript Web Crypto API)
  • Flask API handles only encrypted blobs — zero-knowledge server
  • Browser extension uses same REST API with JWT bearer tokens
  • enc_key_salt is not secret on its own — it's only useful combined with the master password
  • TOTP secrets are encrypted at rest using AES-256-GCM with a server-side key (TOTP_ENCRYPTION_KEY). The plaintext secret exists in memory only during TOTP verification. This is the user's account MFA secret — distinct from per-site TOTP URIs stored in vault item plain.totp_uri.
  • Per-site TOTP URIs (plain.totp_uri) are stored client-side only inside the AES-256-GCM encrypted vault blob. The server never sees them. They are decrypted with the vault key and used client-side to generate 6-digit codes via HMAC-SHA-1 (RFC 6238).
  • The audit_logs table has no foreign key on user_id intentionally — audit records should survive user deletion for forensic purposes
  • Redis is required in production for rate limiting (RATELIMIT_STORAGE_URI=redis://127.0.0.1:6379/0). Without it, each Gunicorn worker maintains its own counter and the effective rate limit is multiplied by the number of workers.
  • Recovery code is never stored server-side — only recovery_enc_salt (AES-256-GCM ciphertext of enc_key_salt) and recovery_iv are stored. The server cannot derive the recovery code or enc_key_salt from these alone.
  • Password change clears the recovery code — after a master password change, recovery_enc_salt and recovery_iv are set to NULL. The user must regenerate a recovery code from Account Settings.
  • The #vault-list element ID must not be renamedvault.js renders all vault items directly into document.getElementById('vault-list'). The max-width constraint is applied to a wrapper div (.vault-list-inner), not to the list itself.
  • save_blocklist in chrome.storage.local stores an array of hostnames for which the save banner is permanently suppressed. No removal UI yet — manage via DevTools Local Storage inspector on the extension service worker.