Files
PassKeeper/CLAUDE.md
T

17 KiB

PassKeeper — Password Manager Web App & Browser Extension

Project Overview

A full-featured password manager web app and browser extension modelled after LastPass. Users can store, organise, and autofill credentials securely with a zero-knowledge architecture.


Tech Stack

Development (Windows)

  • Backend: Python 3.12, Flask 3.x
  • Database: MySQL 8.x
  • Frontend: Vanilla JS (Web Crypto API) + Jinja2 templates
  • Dev server: python run.py

Production (Ubuntu)

  • Web server: Nginx (reverse proxy, TLS termination)
  • WSGI server: Gunicorn
  • Process manager: systemd
  • Database: MySQL 8.x
  • TLS: Let's Encrypt / Certbot

Architecture

Browser Extension  <──────────────────────────────────────┐
Web App            <──> Nginx ──> Gunicorn ──> Flask App  │
                                                   │        │
                                               MySQL DB     │
                                                            │
                              REST API (JSON, HTTPS) ───────┘

Project File Structure

passkeeper/
├── app/
│   ├── __init__.py          # App factory, blueprints, CSRF exemptions, security headers
│   ├── config.py            # DevelopmentConfig / ProductionConfig
│   ├── models/
│   │   ├── user.py                  # Argon2id, TOTP encrypted, ECDH keys, recovery
│   │   ├── vault_item.py            # enc_data, iv, enc_name, iv_name
│   │   ├── folder.py
│   │   ├── token_blacklist.py       # JWT revocation (jti + expires_at)
│   │   ├── shared_item.py           # ECDH-encrypted cross-user shares
│   │   ├── emergency_access.py      # State machine
│   │   └── audit_log.py
│   ├── routes/
│   │   ├── auth.py          # Register, login, MFA, logout, refresh, change-password, recovery
│   │   ├── vault.py         # CRUD + GET /export + POST /import
│   │   ├── folders.py
│   │   ├── sharing.py
│   │   └── emergency.py
│   ├── services/
│   │   └── auth_service.py  # Argon2id, JWT, blacklist, @require_jwt, TOTP encrypt/decrypt
│   ├── static/
│   │   ├── css/app.css      # Full responsive stylesheet; collapsible group styles; tag badge styles
│   │   └── js/
│   │       ├── crypto.js    # deriveAuthHash, deriveVaultKey, encryptItem, decryptItem,
│   │       │                #   encryptName, decryptName, generateSalt
│   │       ├── auth.js      # Login/register + TOTP MFA + VaultSession
│   │       ├── recover.js
│   │       ├── sharing.js   # ECDH P-256
│   │       └── vault.js     # Full vault UI — see "Key Features" below
│   └── templates/vault/
│       └── index.html       # Tags field, Auto-Lock setting, Import/Export view + sidebar
├── extension/
│   ├── manifest.json          # MV3 (Chrome/Edge); Ctrl+Shift+L keyboard shortcut
│   ├── manifest.firefox.json  # MV2 (Firefox)
│   ├── background.js          # Chrome SW: badges, pending-save "!" badge, CLEAR_SAVE_BADGE
│   ├── background.firefox.js  # Firefox: in-memory session shim, setTimeout idle lock
│   ├── shared/
│   │   ├── crypto.js            # PBKDF2+AES-GCM; encryptName/decryptName; extractable key
│   │   └── browser-polyfill.js  # chrome=browser alias for Firefox content scripts
│   ├── popup/
│   │   ├── popup.html  # Tabs: All relevant / All items / Favorites / Recents
│   │   ├── popup.css   # .pk-group-* collapsible styles; .pk-tag badge; .pk-flyout menu
│   │   └── popup.js    # Vault+folder fetch; collapsible groups; Favorites tab;
│   │                   #   tag display; clipboard auto-clear; CSPRNG generator;
│   │                   #   save badge clear; three-dot flyout; copy-username button
│   ├── content/
│   │   └── content.js  # _isLikelyUsernameField; _normaliseUrl; debounced input (150ms);
│   │                   #   MutationObserver guard; vault_items_cs from chrome.storage.session
│   ├── bridge/
│   │   └── bridge.js   # SSO bridge (vault domain only)
│   └── icons/
├── migrations/versions/
│   ├── 71d7158dd3b9_add_audit_log_tables_fix_sharing_public_.py
│   ├── a1b2c3d4e5f6_encrypt_totp_secret_at_rest.py
│   ├── b2c3d4e5f6a7_add_account_recovery_columns.py
│   └── c3d4e5f6a7b8_encrypt_vault_item_name.py   # adds enc_name + iv_name
├── scripts/
│   ├── reencrypt_totp_secrets.py
│   ├── backup_db.sh / backup.cron / passkeeper-logrotate
│   ├── passkeeper-nginx.conf / passkeeper.service
├── reset_db.py
├── requirements.txt
├── wsgi.py / run.py
└── CLAUDE.md

Database Schema (MySQL)

-- Users
CREATE TABLE users (
    id                       INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    email                    VARCHAR(255) UNIQUE NOT NULL,
    master_hash              VARCHAR(255) NOT NULL,   -- Argon2id(authHash)
    enc_key_salt             VARCHAR(64) NOT NULL,
    created_at / last_login  DATETIME,
    totp_secret              VARCHAR(255),  -- AES-256-GCM ciphertext
    totp_iv                  VARCHAR(64),
    totp_enabled             TINYINT(1) DEFAULT 0,
    sharing_public_key       VARCHAR(128),
    sharing_private_key_enc  TEXT,
    sharing_private_key_iv   VARCHAR(64),
    recovery_enc_salt        VARCHAR(128),
    recovery_iv              VARCHAR(64)
);

-- Vault Items
CREATE TABLE vault_items (
    id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id    INT UNSIGNED NOT NULL,
    folder_id  INT UNSIGNED,
    item_type  VARCHAR(20) NOT NULL DEFAULT 'password',
    name       VARCHAR(255) NOT NULL,  -- server label only (item type string)
    enc_data   TEXT NOT NULL,          -- AES-256-GCM ciphertext of payload + tags
    iv         VARCHAR(64) NOT NULL,
    enc_name   TEXT,                   -- AES-256-GCM ciphertext of item name (nullable)
    iv_name    VARCHAR(64),
    created_at DATETIME DEFAULT NOW(),
    updated_at DATETIME DEFAULT NOW() ON UPDATE NOW(),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL
);
-- (folders, token_blacklist, shared_items, emergency_access, audit_logs — unchanged)

Migration History

Revision Description
71d7158dd3b9 Add audit_logs; fix sharing_public_key type
a1b2c3d4e5f6 Encrypt TOTP secret at rest
b2c3d4e5f6a7 Add account recovery columns
c3d4e5f6a7b8 Add enc_name + iv_name to vault_items

Security Model

  • Zero-knowledge: master password never sent to server
    • authHash = PBKDF2(masterPassword, email, 100k iter) → auth only
    • vaultKey = PBKDF2(masterPassword, enc_key_salt, 600k iter) → browser memory only
    • All vault data (payload + name + tags) encrypted client-side (AES-256-GCM)
  • Item name: enc_name/iv_name; server name column = item type only
  • Tags: plain.tags: string[] inside enc_data; server never sees them
  • Argon2id: double-hashes authHash server-side
  • JWT: HS256, 15 min access / 7 day refresh, JTI blacklisted on logout
  • MFA: TOTP secret AES-256-GCM encrypted at rest
  • Sharing: ECDH P-256 zero-knowledge re-encryption
  • Password generator: fully CSPRNG (_cryptoRandInt rejection-sampling)
  • Decrypted vault data: chrome.storage.session only — never to disk
  • Clipboard auto-clear: 30 s after any password/username copy (web + extension)
  • Breach detection: HIBP k-anonymity — only 5-char SHA-1 prefix transmitted

Extension Storage Architecture

Data Storage Reason
access_token, vault_key_jwk, vault_items, vault_items_cs chrome.storage.session Memory-only, cleared on browser close
refresh_token, enc_key_salt, pending_save, save_blocklist, idle_lock_seconds chrome.storage.local Persists across restarts
Web-app session timeout localStorage (web_idle_minutes) Per-browser preference

Critical: vault_items_cs is in session, NOT local

Requires Chrome 111+. content.js onChanged listener watches area === 'session'. Do not revert — reverting persists decrypted passwords to disk.

Pending-save badge

background.js sets a red "!" badge on SAVE_CREDENTIALS. Cleared via CLEAR_SAVE_BADGE when user acts on the save prompt in the popup.


Key Implementation Details

Vault item tags

  • Stored as plain.tags: string[] inside enc_data. No schema change ever needed.
  • Web app: _parseTags(str) → comma-split, lowercase, dedup, sort. _allTags() collects across all items. renderTagList() builds sidebar. Tags field in modal has live badge preview.
  • Extension: .pk-tag badges below item subtitle. Favorites tab = items where tags.includes('favorite'). Favorited items show gold ★ in site label.
  • To favourite an item: add tag favorite in the web app edit modal.

Collapsible folder groups

  • Web app: _collapsedGroups Set persists state across re-renders. Header shows name + count badge + chevron (▼/▶). Click toggles and re-renders.
  • Extension: only on "All items" tab. _folders fetched in parallel with vault. _collapsedFolders Set. folderName(id) resolves to display name with (none) fallback.

Import / Export

  • Encrypted JSON export: GET /api/vault → versioned envelope → download. Zero-knowledge.
  • CSV export: decrypt client-side → name, url, username, password, notes.
  • JSON import: sends encrypted blobs to POST /api/vault/import directly.
  • CSV import: parses Chrome / Bitwarden / 1Password formats; encrypts client-side before POSTing.
  • Both endpoints write audit logs: vault_item.export, vault_item.import.

Web-app session timeout

  • _startWebIdleTracking() called at end of init(). Listens to mousemove, mousedown, keydown, touchstart, scroll.
  • On timeout: VaultSession.clear() → unlock overlay → toast.
  • Stored in localStorage as web_idle_minutes. Default 15 min. Options: Never/5/10/15/30/60.
  • Exposed in Account Settings → Auto-Lock (<select id="web-idle-select">).

Browser history / back-button

  • switchView(view, { pushState = true }) calls history.pushState({ view }, '', '#view-name').
  • popstate listener in init() restores view. Direct hash links (/vault#security) work.
  • Vault.switchToImportExport() exposed on public return object for sidebar <li onclick> fallback.

Clipboard auto-clear

  • Web app: copyToClipboard() uses _clipboardClearTimer setTimeout 30s → writeText('').
  • Extension: _copyWithAutoClear() same pattern. Applied to password, username, and flyout copies.

Missing 2FA warning

  • Security dashboard "No 2FA Saved" section: password items with URL but no totp_uri.
  • Uses existing extractTotpSecret() and makeSection().

Security score age penalty fix

  • old only includes items that are also weak or reused (weakOrReusedIds.has(i.id)).
  • Strong unique unchanged passwords no longer penalised.

Keyboard shortcut

  • manifest.json: commands._execute_action, Ctrl+Shift+L / Cmd+Shift+L.
  • manifest.firefox.json: _execute_browser_action.
  • Customisable at chrome://extensions/shortcuts.

Firefox compatibility

File Purpose
manifest.firefox.json MV2: browser_action, background.scripts
background.firefox.js In-memory session shim, setTimeout idle lock, browserAction API
shared/browser-polyfill.js chrome = browser alias for content scripts

Load in Firefox: about:debugging → This Firefox → Load Temporary Add-on → manifest.firefox.json.

_normaliseUrl() — bare-domain matching

function _normaliseUrl(raw) {
  if (!raw) return null;
  const s = raw.trim();
  if (/^https?:\/\//i.test(s)) return s;
  if (s.startsWith('//')) return 'https:' + s;
  return 'https://' + s;
}

In both content.js and popup.js. Prevents silent match failures for bare domains.

_isLikelyUsernameField() — credential field heuristic

  1. YES: autocomplete="username|email|tel"
  2. NO: non-credential autocomplete (name, organization, search, etc.)
  3. YES: name/id/placeholder/aria-label matches user|email|mail|login|phone|tel|mobile|account
  4. Otherwise: not decorated

MutationObserver guard

Inspects added/removed nodes — if all carry __pk prefix, returns early. Prevents re-decoration loops when the extension injects/removes its own UI.

showDropdown debounced 150ms on input

_debounce(fn, ms) helper. focus listener remains instant.

Three-dot flyout menu

.pk-flyout div anchored below button. Dynamic items: Open URL / Copy username / Copy password. Closes on outside click.

AuditLog pattern

db.session.add(item)
db.session.flush()   # populates item.id
AuditLog.log(user_id=..., action='vault_item.create', resource_id=item.id, ...)
db.session.commit()  # atomic

For deletes: capture id and name before flush.

Common gotchas

  • item_type: use db.String(20), not db.Enum(ItemType) — enum lazy-load breaks .value
  • INTEGER(unsigned=True): requires from sqlalchemy.dialects.mysql import INTEGER
  • All datetimes: naive UTC datetime.utcnow() — never mix with timezone-aware
  • Extension vault key: extractable: true (web app uses false)
  • PyJWT sub: str(user_id) on encode, int(payload['sub']) on decode
  • TOTP key generation: python -c "import secrets; print(secrets.token_hex(32))"
  • #vault-list ID must not be renamed — vault.js renders into it directly

Audit Action Catalog

Module Action Trigger
auth.py auth.register New account
auth.py auth.login / auth.login_failed Login success/fail
auth.py auth.mfa_enable/disable/verify TOTP actions
auth.py auth.change_password / auth.change_password_failed Password change
auth.py auth.delete_account / auth.delete_account_failed Deletion
auth.py auth.recovery_setup/failed/items_denied/success Recovery
vault.py vault_item.create/update/delete CRUD
vault.py vault_item.export / vault_item.import Import/Export
folders.py folder.create/update/delete Folder CRUD
sharing.py sharing_keys.create/update ECDH key setup
sharing.py shared_item.create/delete/accept Sharing
emergency.py emergency_access.* All EA state transitions

REST API

POST   /api/auth/register|login|logout|refresh|recover
GET    /api/auth/me|mfa/status|mfa/setup|recovery/status|recovery/data|recovery/items
POST   /api/auth/mfa/enable|disable|verify|change-password|recovery/setup
DELETE /api/auth/account

GET    /api/vault                 # list all encrypted items
POST   /api/vault                 # { name, item_type, folder_id, enc_data, iv, enc_name?, iv_name? }
GET    /api/vault/<id>
PUT    /api/vault/<id>
DELETE /api/vault/<id>
GET    /api/vault/export          # returns encrypted item array
POST   /api/vault/import          # accepts array; returns { imported, skipped }

GET|POST        /api/folders
PUT|DELETE      /api/folders/<id>

GET|POST        /api/sharing/keys
GET             /api/sharing/public-key
GET|POST        /api/sharing
DELETE          /api/sharing/<id>
GET             /api/sharing/inbox
POST            /api/sharing/inbox/<id>/accept

GET|POST        /api/emergency
DELETE          /api/emergency/<id>
POST            /api/emergency/<id>/accept|provide|request|deny
GET             /api/emergency/<id>/vault

Development Setup

python -m venv .venv && .venv\Scripts\activate
pip install -r requirements.txt
# .env: MYSQL_*, SECRET_KEY, JWT_SECRET_KEY, TOTP_ENCRYPTION_KEY
mysql -u root -p -e "CREATE DATABASE passkeeper CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
python reset_db.py
python run.py

Production Deployment

flask db upgrade
sudo systemctl reload passkeeper

Python Dependencies

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  qrcode[pil]>=7.4.2
cryptography>=42.0   # TOTP secret encryption
redis>=5.0           # Rate-limit storage (required in production)

Notes

  • Never log decrypted vault data server-side
  • Tags live in enc_data as plain.tags: string[] — no schema change ever needed
  • vault_items_cs is in chrome.storage.session — decrypted data never written to disk
  • HIBP checks run progressively — synchronous sections render first, then parallel async checks
  • Clipboard cleared 30 s after every password/username copy (web app + extension)
  • Web-app idle timeout in localStorage (web_idle_minutes); default 15 min
  • Browser back/forward works for all five vault views via history.pushState
  • Pending save badge ("!") set on SAVE_CREDENTIALS, cleared via CLEAR_SAVE_BADGE
  • Firefox: use manifest.firefox.json + background.firefox.js + browser-polyfill.js
  • Redis required in production for rate limiting
  • Recovery code never stored server-side; password change clears it (user must regenerate)
  • save_blocklist in chrome.storage.local suppresses save banner per hostname