Files
PassKeeper/CLAUDE.md
T
2026-04-26 10:10:29 -04:00

31 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, 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 (enc_data, iv, enc_name, iv_name — all sensitive fields encrypted)
│   │   ├── 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 (accepts enc_name/iv_name on create/update)
│   │   ├── 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,
│   │       │                #   encryptName, decryptName, generateSalt
│   │       ├── 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
│   │                        #   renderSecurityDashboard is async — runs HIBP k-anonymity checks after sync sections
│   └── 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 (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;
│   │                        #   encryptName / decryptName helpers for item name encryption
│   ├── popup/
│   │   ├── popup.html       # Login → MFA → Unlock-only → Vault → Generator → Add Item views
│   │   ├── popup.css        # Includes generator styles, save-modal overlay, pk-flyout context menu
│   │   └── popup.js         # Auth, vault fetch/decrypt (decrypts enc_name), tabs, autofill trigger,
│   │                        #   inline password generator (fully CSPRNG via _cryptoRandInt),
│   │                        #   save-prompt modal, copy-username button, three-dot flyout menu,
│   │                        #   SSO, service-worker keepalive, relevant-tab domain filter
│   ├── content/
│   │   └── content.js       # Form detection; _isLikelyUsernameField heuristic (autocomplete/name/id scoring);
│   │                        #   injects PK icon into username AND password fields only;
│   │                        #   debounced input handler (150ms); MutationObserver guards against
│   │                        #   re-triggering on extension's own DOM mutations;
│   │                        #   _normaliseUrl handles bare domains ("github.com")
│   ├── 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
│       └── c3d4e5f6a7b8_encrypt_vault_item_name.py           # Adds enc_name + iv_name to vault_items
├── 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 (secret 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
    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 AES-256-GCM encrypted client-side)
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,                    -- non-sensitive server label (item type only)
    enc_data     TEXT NOT NULL,                            -- base64 AES-256-GCM ciphertext of item payload
    iv           VARCHAR(64) NOT NULL,                     -- base64 12-byte GCM nonce for enc_data
    enc_name     TEXT,                                     -- base64 AES-256-GCM ciphertext of item name
    iv_name      VARCHAR(64),                              -- base64 12-byte GCM nonce for enc_name
    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 (JWT revocation)
CREATE TABLE token_blacklist (
    id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    jti        VARCHAR(36) UNIQUE NOT NULL,
    user_id    INT UNSIGNED NOT NULL,
    expires_at DATETIME NOT NULL,
    INDEX (jti), INDEX (expires_at)
);

-- Shared Items (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,  -- plaintext display name for share inbox
    item_type       VARCHAR(20) NOT NULL,
    enc_data        TEXT NOT NULL,
    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
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,
    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
CREATE TABLE audit_logs (
    id            INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id       INT UNSIGNED NOT NULL,
    action        VARCHAR(64) NOT NULL,
    resource_type VARCHAR(64) NOT NULL,
    resource_id   INT UNSIGNED,
    detail        VARCHAR(512),
    ip_address    VARCHAR(45),
    created_at    DATETIME NOT NULL,
    INDEX (user_id), INDEX (created_at)
);

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 (payload + item name) encrypted client-side (AES-256-GCM) before sending to server
  • Item name encryption: enc_name/iv_name store the AES-256-GCM ciphertext of the item name. The server's plaintext name column holds only the item type (e.g. "password") as a non-sensitive audit label. Legacy items without enc_name fall back to name transparently.
  • 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
  • MFA: TOTP (pyotp), secret stored AES-256-GCM encrypted in users.totp_secret/totp_iv
  • Sharing: ECDH P-256 — shared secret derived client-side, re-encrypts item plaintext
  • Emergency access: same ECDH re-encryption; wait timer enforced server-side
  • CSRF: Flask-WTF on page-serving routes; API blueprints CSRF-exempt (JWT bearer auth)
  • Rate limiting: Flask-Limiter (Redis-backed in production); Nginx dual-zone as second layer
  • HTTP security headers: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, CSP via @app.after_request
  • Password generator (extension): Fully CSPRNG — _cryptoRandInt() uses rejection-sampling with crypto.getRandomValues(). Math.random() is never called anywhere in the generator.
  • Decrypted vault data in extension: Stored in chrome.storage.session only (memory-only, cleared on browser close). Never written to chrome.storage.local.
  • Breach detection: Security dashboard runs HaveIBeenPwned k-anonymity checks — only the first 5 chars of each password's SHA-1 hash are transmitted. Passwords never leave the browser.

Extension Storage Architecture — Critical Rules

Storage area decision table

Data Where stored Why
access_token, vault_key_jwk, vault_items, vault_items_cs chrome.storage.session Memory-only, cleared on browser close. Decrypted data must never persist to disk.
refresh_token, enc_key_salt, pending_save, save_blocklist, idle_lock_seconds chrome.storage.local Persists across browser restarts and SW termination.

vault_items_cs is in chrome.storage.session, NOT chrome.storage.local

As of the current codebase, vault_items_cs (the decrypted vault cache for content scripts) is written to chrome.storage.session. This is a deliberate security decision — decrypted passwords must never be persisted to disk.

Minimum Chrome version required: 111 (March 2023). chrome.storage.session is not readable by content scripts on Chrome ≤ 110. The storage.onChanged listener in content.js watches area === 'session'.

⚠️ The original CLAUDE.md stated vault_items_cs lives in chrome.storage.local. That is no longer correct. If you see it reverting to local, that is a regression — revert it.

chrome.storage.onChanged area guard

chrome.storage.onChanged.addListener(function(changes, area) {
  if (area === 'session' && changes.vault_items_cs) {  // 'session', not 'local'
    ...
  }
});

pending_save must remain in chrome.storage.local

chrome.storage.session is wiped when the MV3 service worker is killed (within seconds of inactivity). pending_save stays in local so the save prompt survives SW restarts.


Key Implementation Decisions & Gotchas

Item name is encrypted client-side (enc_name / iv_name)

The name column in vault_items is not sensitive — it stores only the item type string (e.g. "password") as a server-side audit label. The real display name is stored as AES-256-GCM ciphertext in enc_name with nonce iv_name.

On load: loadVault() / fetchAndDecryptVault() decrypt enc_name after decrypting enc_data and override item.name with the plaintext. Legacy items without enc_name use the server's name field as fallback — no migration of existing rows required.

On save: handleFormSubmit() (web app) and addItemToVault() / saveCredential() (extension) call Crypto.encryptName(vaultKey, name) / ExtCrypto.encryptName(vaultKey, name) before POSTing, sending enc_name + iv_name alongside enc_data + iv.

On password change / recovery: The re-encryption loops in auth.py also update vault_item.enc_name / vault_item.iv_name when the client sends updated values.

API contract: POST /api/vault and PUT /api/vault/<id> now accept optional enc_name and iv_name fields. The name field is still required (used as the server-side label).

Password generator is fully CSPRNG

generatePassword() in extension/popup/popup.js uses _cryptoRandInt(max) for all random selection:

function _cryptoRandInt(max) {
  // Rejection sampling — no modulo bias.
  const limit = Math.floor(0x100000000 / max) * max;
  const buf = new Uint32Array(1);
  do { crypto.getRandomValues(buf); } while (buf[0] >= limit);
  return buf[0] % max;
}

This replaces Math.floor(Math.random() * n) which was used in the required-character selection and Fisher-Yates shuffle steps. Math.random() is not called anywhere in the generator.

_isLikelyUsernameField() — credential field detection heuristic

findUsernameField() in content.js no longer accepts any input[type="text"] preceding the password field. It scores candidates via _isLikelyUsernameField():

  1. Definite YES: autocomplete="username" or "email" or "tel"
  2. Definite NO: Non-credential autocomplete values (name, given-name, organization, search, etc.)
  3. Keyword scan YES: name, id, placeholder, or aria-label contain user|email|mail|login|phone|tel|mobile|account
  4. Otherwise: field is not decorated

input[type="email"] and input[type="tel"] are always accepted without scoring.

_normaliseUrl() — bare-domain URL matching

Both content.js and popup.js include a _normaliseUrl() helper that prepends https:// to URLs that lack a scheme before passing them to new URL(). This prevents silent match failures for vault items stored with bare domains (e.g. "github.com" instead of "https://github.com").

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;
}

MutationObserver guards against extension's own DOM mutations

The MutationObserver in content.js inspects every mutation before calling decorateFields(). If every added/removed node carries a __pk class or id prefix, the callback returns early — preventing re-decoration loops on SPAs triggered by the extension injecting or removing its own dropdown or icon buttons.

showDropdown is debounced on input events

The input event listener on decorated fields wraps showDropdown with a 150 ms debounce via _debounce(). The focus listener remains instant. This eliminates visible lag on large vaults when the user types quickly.

Three-dot menu is now a proper flyout

The data-menu button in the popup renders a .pk-flyout div anchored below the button. Menu items are built dynamically from what the item has — "Open URL" only if plain.url exists, etc. Styled via .pk-flyout / .pk-flyout-item in popup.css. Closes on any outside click.

Copy-username button is a dedicated action

A data-copy-user button with a person SVG icon appears at the left of the action row for any item with a username. This replaces the previous behaviour where the three-dot button silently copied the username on click.

HaveIBeenPwned breach detection

renderSecurityDashboard() in vault.js is now async. After rendering the synchronous sections (Weak / Reused / Old), it fires parallel HIBP API calls for every password item via checkHibp(password):

async function checkHibp(password) {
  const hashHex = /* SHA-1 of password via crypto.subtle */;
  const prefix = hashHex.slice(0, 5);
  const suffix = hashHex.slice(5);
  const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
    headers: { 'Add-Padding': 'true' },
  });
  // parse response, find matching suffix, return breach count
}

Only the first 5 hex characters of the SHA-1 hash are sent. Passwords never leave the browser. Network errors fail safe (return 0, do not block UI). The HIBP section renders progressively after the synchronous sections.

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. Use db.String(20).

INTEGER(unsigned=True) requires MySQL dialect type

from sqlalchemy.dialects.mysql import INTEGER
db.Column(INTEGER(unsigned=True), ...)

All datetimes are naive UTC (datetime.utcnow())

Do NOT mix in datetime.now(timezone.utc) — mixing causes comparison failures in token blacklist and emergency access wait-timer checks.

AuditLog must flush before log, then commit together

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()         # commits both atomically

For deletes, capture id and name before the delete flush.

Extension vault key must be extractable

extension/shared/crypto.js uses extractable: true so the vault key can be serialised as JWK and stored in chrome.storage.session. The web app uses extractable: false.

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

Always convert: 'sub': str(user_id) when generating tokens; int(payload['sub']) when reading back.

TOTP secrets encrypted at rest (server-side)

totp_secret stores AES-256-GCM ciphertext; totp_iv stores the nonce. Encrypted/decrypted exclusively by auth_service.encrypt_totp_secret() / auth_service.decrypt_totp_secret(). Generating the key:

python -c "import secrets; print(secrets.token_hex(32))"

Extension SSO bridge

bridge.js runs on pwkeeper.ngodanguyen.tech only and syncs sessions bidirectionally between the web app and the extension. See the original CLAUDE.md for the full bridge flow.

Autofill uses native HTMLInputElement setter for framework compatibility

const nativeSet = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
if (nativeSet) nativeSet.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));

reset_db.py for schema changes (Windows, no flask db CLI)

python reset_db.py

Production schema changes: flask db upgrade.


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.

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

REST API Endpoints

All /api/vault/* and /api/folders/* routes require Authorization: Bearer <access_token>.

POST   /api/auth/register
POST   /api/auth/login
POST   /api/auth/logout
POST   /api/auth/refresh
GET    /api/auth/me
GET    /api/auth/mfa/status
GET    /api/auth/mfa/setup
POST   /api/auth/mfa/enable
POST   /api/auth/mfa/disable
POST   /api/auth/mfa/verify
POST   /api/auth/change-password   # { current_auth_hash, new_auth_hash, new_enc_key_salt,
                                   #   items: [{id, enc_data, iv, enc_name?, iv_name?}] }
DELETE /api/auth/account
POST   /api/auth/recovery/setup
GET    /api/auth/recovery/status
GET    /api/auth/recovery/data
GET    /api/auth/recovery/items
POST   /api/auth/recover

GET    /api/vault
POST   /api/vault                  # { name, item_type, folder_id, enc_data, iv, enc_name?, iv_name? }
GET    /api/vault/<id>
PUT    /api/vault/<id>             # accepts enc_name, iv_name
DELETE /api/vault/<id>

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

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

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

Development Setup (Windows)

python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
# Edit .env: MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DB, 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 (Ubuntu + Nginx + Gunicorn + systemd)

# Schema migrations (all subsequent deployments)
flask db upgrade

# Reload app
sudo systemctl reload passkeeper

For full initial setup instructions (Nginx config, systemd unit, Certbot, etc.) refer to the original deployment guide in this file's history or scripts/passkeeper-nginx.conf / scripts/passkeeper.service.


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
qrcode[pil]>=7.4.2
cryptography>=42.0    # AES-256-GCM server-side TOTP secret encryption
redis>=5.0            # Shared rate-limit storage across Gunicorn workers

Migration History

Revision Description
71d7158dd3b9 Add audit_logs table; fix sharing_public_key column type
a1b2c3d4e5f6 Encrypt TOTP secret at rest (totp_secret → VARCHAR(255), add totp_iv)
b2c3d4e5f6a7 Add account recovery columns (recovery_enc_salt, recovery_iv)
c3d4e5f6a7b8 Encrypt vault item name (add enc_name, iv_name to vault_items)

Notes

  • Never log decrypted vault data server-side
  • All encryption/decryption of vault data (including item names) happens in the browser
  • The server stores only encrypted blobs — zero-knowledge architecture
  • enc_key_salt is not secret on its own — only useful combined with the master password
  • Redis is required in production for rate limiting. Without it, each Gunicorn worker maintains its own counter.
  • Recovery code is never stored server-side — only the AES-256-GCM ciphertext of enc_key_salt
  • Password change clears the recovery code — user must regenerate from Account Settings
  • The #vault-list element ID must not be renamedvault.js renders all vault items into it
  • save_blocklist in chrome.storage.local stores hostnames for which the save banner is suppressed
  • vault_items_cs is in chrome.storage.session — decrypted data never written to disk (changed from local)
  • HIBP checks run progressively — the security dashboard renders synchronous sections first, then fires parallel k-anonymity requests in the background