# 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) ```sql -- Users CREATE TABLE users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, master_hash VARCHAR(255) NOT NULL, -- Argon2id hash of client-derived PBKDF2 auth_hash enc_key_salt VARCHAR(64) NOT NULL, -- Random 16-byte salt (base64), returned on login created_at DATETIME DEFAULT NOW(), last_login DATETIME, -- TOTP / MFA (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 ```js 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/` 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: ```js 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"`). ```js 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)`: ```js 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 ```python 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 ```python 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: ```bash 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 ```js 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) ```bash 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 `. ``` 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/ PUT /api/vault/ # accepts enc_name, iv_name DELETE /api/vault/ GET /api/folders POST /api/folders PUT /api/folders/ DELETE /api/folders/ GET /api/sharing/keys POST /api/sharing/keys GET /api/sharing/public-key GET /api/sharing POST /api/sharing DELETE /api/sharing/ GET /api/sharing/inbox POST /api/sharing/inbox//accept GET /api/emergency POST /api/emergency DELETE /api/emergency/ POST /api/emergency//accept POST /api/emergency//provide POST /api/emergency//request POST /api/emergency//deny GET /api/emergency//vault ``` --- ## Development Setup (Windows) ```bash 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) ```bash # 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 renamed** — `vault.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