Files
PassKeeper/CLAUDE.sync-conflict-20260507-154033-FEZULOS.md
T

24 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 + advanced settings) 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
  • Advanced Settings: plain.autofill, plain.autologin, plain.reprompt — boolean fields inside enc_data; zero-knowledge like tags
  • 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 is rendered with position: fixed, appended to #app (not inside the button). Coordinates are computed from btn.getBoundingClientRect() so it escapes .vault-list overflow-y: auto clipping. Auto-flips upward when spaceBelow < flyoutH + 8 — items near the bottom of the list show the menu above the button, clearing the bottom nav bar. Dynamic items: Open URL / Copy username / Copy password. Closes on outside click.

Advanced Settings (password items)

Three boolean flags stored inside enc_data as part of plain — zero-knowledge, same pattern as plain.tags. No database schema change required.

Field Default Behaviour
plain.autofill true Whether the extension shows the fill button and fills this item
plain.autologin false After filling, automatically submit the form (submitBtn.click() with 400 ms delay)
plain.reprompt false Require master password before filling, copying, or editing

Web app (vault.js / index.html):

  • Collapsible #adv-settings-toggle / #adv-settings-body section in the password type-fields block.
  • Starts collapsed (hidden) every time the modal opens (openModal resets state).
  • buildPlainData('password') collects the three checkbox values.
  • openModal edit-mode populates checkboxes from plain.*; autofill defaults true via HTML checked attribute — reset by form.reset().
  • CSS: .adv-section, .adv-section-header, .adv-section-body, .adv-checkbox-row, .adv-checkbox-label, .adv-checkbox-desc in app.css.

Extension (popup.js):

  • canFill gates on plain.autofill !== false — hides the fill button for items with autofill disabled.
  • Copy-password, copy-username, autofill button, and flyout copy actions all check plain.reprompt.
  • _repromptMasterPassword(): full-screen overlay inside #app; derives vault key from entered password + enc_key_salt; compares JWK bytes to stored vault_key_jwk.
  • Autofill message sends autologin: !!plain.autologin to the content script.

Extension (content.js):

  • doAutofill(username, password, autologin) — when autologin is true, finds the closest form's submit button and clicks it (or calls form.submit()) after a 400 ms delay to let React/Vue input handlers fire.
  • DO_AUTOFILL message handler forwards !!msg.autologin.

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

CSP & Nginx Header Architecture

Why Nginx — not Flask — owns the authoritative CSP

The deployment stack is Browser → Nginx → Gunicorn → Flask. When Nginx emits a Content-Security-Policy header with always, it replaces any CSP header Flask emits upstream. Changes to app/__init__.py or base.html alone are ineffective in production — scripts/passkeeper-nginx.conf is the single source of truth for CSP in production.

Nginx add_header inheritance rule (critical)

Any location block that declares even one add_header directive silently drops all add_header directives from every parent block for that location.

The /static/ block uses add_header Cache-Control. Without explicitly repeating all security headers inside that block, every static asset (vault.js, app.css, etc.) is served with no CSP, no HSTS, no X-Frame-Options — none.

All security headers must be present in both the server block and the /static/ location block. Keep them in sync whenever either is modified.

Approved external connect-src origins

Origin Purpose
https://api.pwnedpasswords.com HIBP k-anonymity breach check (Security Dashboard)

connect-src 'self' https://api.pwnedpasswords.com must appear in the CSP in both the server block and the /static/ location block in passkeeper-nginx.conf.

Flask-side CSP (app/__init__.py + base.html)

Flask also sets a CSP header and base.html has a <meta http-equiv> CSP tag. Keep these in sync with the Nginx config for correctness in development (where Nginx is not present). Note:

  • frame-ancestors is valid only in HTTP headers — never in <meta> CSP tags. The browser silently ignores it in meta tags and logs a warning.
  • The <meta> tag CSP does not enforce frame-ancestors; the Nginx HTTP header does.

Inline event handler prohibition

script-src 'self' blocks all inline on* HTML attributes. Do not add onclick, onchange, or any other inline handler to any template. Wire all interactions via addEventListener in the corresponding JS file instead.

The Vault.switchToImportExport() export on the vault.js public API exists for historical reasons. The #sidebar-import-export element is wired via addEventListener in init() — the exported function is not needed for new code.

Third-party browser extension noise

Console errors referencing isCheckout, content-script.js, or the extension ID clmkdohmabikagpnhjmgacbclihgmdje originate from a third-party shopping/coupon browser extension — not from PassKeeper. These can be ignored.

The [PassKeeper] decorateFields: host=…, matched=0 of 0 items log from content.js is a routine debug message (not an error): the content script found no stored credentials matching the current hostname, which is expected on the vault page itself.