# 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) │ │ ├── totp_used_code.py # TOTP replay prevention (one-time use per user) │ │ ├── shared_item.py # ECDH-encrypted cross-user shares; enc_name/iv_name │ │ ├── emergency_access.py # State machine │ │ ├── recovery_challenge.py # Server-side recovery challenge (multi-worker safe) │ │ ├── webauthn_credential.py # Passkey / WebAuthn credentials (one row per key) │ │ └── 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 │ │ └── webauthn.py # Passkey registration, authentication, credential management │ ├── services/ │ │ └── auth_service.py # Argon2id, JWT, blacklist, @require_jwt, TOTP encrypt/decrypt, │ │ # TOTP replay helpers (is_totp_code_used / mark_totp_code_used) │ ├── 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; encryptName/decryptName for shared item names │ │ └── 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); web_accessible_resources: [] │ ├── manifest.firefox.json # MV2 (Firefox); web_accessible_resources: [] │ ├── 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 │ ├── d4e5f6a7b8c9_add_lockout_and_mfa_backup_codes.py │ ├── e5f6a7b8c9d0_add_recovery_challenges_table.py │ ├── f6a7b8c9d0e1_add_totp_used_codes_table.py # TOTP replay prevention │ ├── g7h8i9j0k1l2_encrypt_shared_item_name.py # enc_name/iv_name on shared_items │ └── h8i9j0k1l2m3_add_webauthn_credentials_table.py # Passkey / WebAuthn credentials ├── 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) ```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(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, mfa_backup_codes TEXT, -- JSON array of Argon2id-hashed codes 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 ); -- Shared Items 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, -- non-sensitive fallback label (= item_type) item_type VARCHAR(20) NOT NULL DEFAULT 'password', enc_data TEXT NOT NULL, -- ECDH-encrypted item payload iv VARCHAR(64) NOT NULL, enc_name VARCHAR(512), -- ECDH-encrypted display name (nullable for legacy) iv_name VARCHAR(64), accepted TINYINT(1) DEFAULT 0, created_at DATETIME NOT NULL ); -- TOTP Used Codes (replay prevention) CREATE TABLE totp_used_codes ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, code VARCHAR(6) NOT NULL, expires_at DATETIME NOT NULL, UNIQUE KEY uq_totp_used_user_code (user_id, code), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); -- Recovery Challenges (multi-worker safe) CREATE TABLE recovery_challenges ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL UNIQUE, nonce VARCHAR(64) NOT NULL, expected_proof VARCHAR(64) NOT NULL, expires_at DATETIME NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); -- (folders, token_blacklist, emergency_access, audit_logs — standard schemas) -- WebAuthn / Passkey Credentials CREATE TABLE webauthn_credentials ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, credential_id VARCHAR(512) NOT NULL UNIQUE, -- base64url authenticator credential ID public_key TEXT NOT NULL, -- COSE public key, base64url sign_count BIGINT NOT NULL DEFAULT 0, -- clone detection counter transports VARCHAR(255), -- JSON list e.g. '["internal","hybrid"]' aaguid VARCHAR(64), -- authenticator AAGUID name VARCHAR(128) NOT NULL DEFAULT 'Passkey', -- user-assigned label created_at DATETIME NOT NULL, last_used_at DATETIME, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); ``` --- ## 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 | | `d4e5f6a7b8c9` | Add lockout columns + MFA backup codes | | `e5f6a7b8c9d0` | Add recovery_challenges table | | `f6a7b8c9d0e1` | Add totp_used_codes table (TOTP replay prevent.) | | `g7h8i9j0k1l2` | Add enc_name/iv_name to shared_items | | `h8i9j0k1l2m3` | Add webauthn_credentials table (passkeys) | --- ## 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` in `vault_items`; server `name` column = item type only - **Shared item name:** `enc_name`/`iv_name` encrypted with ECDH shared key; server `item_name` = item type only - **Tags:** `plain.tags: string[]` inside `enc_data`; server never sees them - **Argon2id:** double-hashes `authHash` server-side; transparently rehashes on login if parameters are upgraded - **JWT:** HS256, 15 min access / 7 day refresh, JTI blacklisted on logout - **MFA:** TOTP secret AES-256-GCM encrypted at rest; each code is single-use (replay prevented via `totp_used_codes` table, 120s TTL) - **Passkeys / WebAuthn:** server authentication via FIDO2; ZK model preserved — WebAuthn proves identity to the server but the vault key is still derived from the master password client-side; `sign_count` updated on every assertion for clone detection - **Sharing:** ECDH P-256 zero-knowledge re-encryption; item name also encrypted with shared key - **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 - **Account recovery:** challenge-response via HMAC-SHA256; `enc_key_salt` NOT returned by `/recovery/data` — client must derive it by decrypting the recovery blob (proves possession of recovery code without transmitting it); challenge rotated on each `/recovery/items` call to prevent proof replay; recovery key derived with the user's email as a per-user PBKDF2 salt — legacy fixed salt `'passkeeper-recovery'` accepted transparently for codes created before this change - **folder_id ownership:** validated server-side on all create/update/import operations — user cannot assign items to another user's folder - **Audit logs:** never contain plaintext item names, shared item names, or vault data --- ## 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 ### `password_changed_at` — accurate password age tracking Stored as `plain.password_changed_at: ISO-8601 string` inside the encrypted `enc_data` blob. Server never sees it. **Written by:** - `vault.js` `handleFormSubmit` — **create**: always set to `now`. **Edit**: only updated when the password field value actually changed vs. the decrypted existing item in `_items`. Unchanged password → existing `password_changed_at` preserved. No existing timestamp + unchanged → absent (dashboard falls back to `created_at`). - `popup.js` `saveCredential` and `addItemToVault` — always set to `now` for new extension-saved credentials. - `toggleFavorite` — spreads `{ ...item.plain, tags: newTags }`, automatically preserving the timestamp. **Read by:** - `renderSecurityDashboard` — `ageRef = plain?.password_changed_at || created_at`; items with a recent `password_changed_at` are no longer falsely flagged as old even if the item itself is old. - Backwards compatible: items without `password_changed_at` fall back to `created_at`. ### Auto-lock on tab visibility change `_startWebIdleTracking()` now registers a `visibilitychange` listener in addition to the mouse/keyboard events. **Hidden:** clears the inactivity timer (user cannot be active on a hidden tab), records `_hiddenAt = Date.now()`. **Visible again:** compares elapsed hidden time against the idle timeout. - Hidden `≥ timeout` → immediate lock (same `VaultSession.clear()` + `showUnlockOverlay()` + toast as inactivity lock). - Hidden `< timeout` → clears `_hiddenAt`, resumes timer from zero. This catches screen-lock, minimize, and long tab switches. Short tab switches (< timeout) do not trigger a lock. ### Emergency access stale snapshot detection `EmergencyAccess._enc_vault_is_legacy()` — parses `enc_vault` JSON server-side (no decryption) and returns `True` if any item has a `name` key but lacks `enc_name`. Exposed as `enc_vault_is_legacy` in `to_dict()`. `renderEmergencyGrants()` — `ready` grants now show three states: - `enc_vault_is_legacy: true` → amber `⚠ Outdated snapshot` badge + **Re-provision** button - `enc_vault_is_legacy: false` → secondary **Update Recovery Data** button - `accepted` (no snapshot yet) → primary **Provide Recovery Data** button After re-provisioning, `enc_vault_is_legacy` returns `false` and the warning disappears. ### Extension health badge `_runPopupHealthCheck()` in `popup.js` — fires after every `fetchAndDecryptVault()`. Computes weak/reused synchronously, sends a preliminary `HEALTH_UPDATE` to the background SW, then runs HIBP in parallel (`_popupCheckHibp`). Sends a final `HEALTH_UPDATE` with breach count. `background.js` / `background.firefox.js`: - `HEALTH_UPDATE` handler: stores `{ breached, weak, reused }` in `chrome.storage.local` as `health_status`, calls `applyHealthBadge()`. - `applyHealthBadge()`: on tabs with no match-count badge, shows red `⚠` (breach) or amber `⚠` (weak/reused). Never overwrites the blue match-count badge or the pending-save `!` badge. - `CLEAR_SAVE_BADGE`: after clearing `!`, immediately re-applies health badge. - Idle lock: removes `health_status` from local storage. **Badge priority:** pending-save `!` (red) > match count (blue) > health `⚠` (red/amber). ### Vault health notifications (background checks) `runBackgroundHealthCheck()` fires after every `loadVault()` call — async, non-blocking. **Flow:** 1. Computes weak + reused counts synchronously from `_items` → updates sidebar badge instantly 2. Runs HIBP k-anonymity checks in parallel (`checkHibp`) → updates badge + banner when done **Module state:** - `_healthCache` — `{ weak, reused, breached, breachedItems, hibpResults }` — set after first run - `_hibpRunning` — boolean guard prevents concurrent runs **`_updateHealthUI({ weak, reused, breached })`** — renders: - **Sidebar badge** (`#security-badge`) on the 🛡️ Security link: red for breaches, amber for weak/reused only, hidden when clean - **Dismissible banner** (`#health-banner`) above the vault list: summarises issues with "View report" link → Security tab; dismiss hides for the session (`banner.dataset.dismissed = "1"`); resets on next vault load **Security tab caching:** `renderSecurityDashboard` checks `_healthCache?.hibpResults` before querying HIBP — avoids double-calling the API within the same session. **Banner reset:** `banner.dataset.dismissed` is set to `"0"` on each vault reload so fresh results (e.g. after a password change) are visible again. ### WebAuthn / Passkey **Library:** `py-webauthn` (`webauthn>=2.0`). Installed via `pip install webauthn`. **Config** (`.env` / environment): ``` WEBAUTHN_RP_ID=pwkeeper.ngodanguyen.tech # effective domain, no scheme/port WEBAUTHN_RP_NAME=PassKeeper WEBAUTHN_ORIGINS=https://pwkeeper.ngodanguyen.tech # comma-separated in env ``` In development: `WEBAUTHN_RP_ID=localhost`, `WEBAUTHN_ORIGINS=http://localhost:5000`. **Flows:** *Registration* (requires active JWT — user must be logged in): ``` POST /api/webauthn/register/begin → PublicKeyCredentialCreationOptions POST /api/webauthn/register/complete → verifies attestation, stores credential ``` *Authentication* (unauthenticated — replaces password login): ``` POST /api/webauthn/authenticate/begin → PublicKeyCredentialRequestOptions POST /api/webauthn/authenticate/complete → verifies assertion → { access_token, refresh_token, enc_key_salt } ``` Client still prompts for master password after successful assertion to derive vault key. *Management* (requires JWT): ``` GET /api/webauthn/credentials → list registered passkeys PATCH /api/webauthn/credentials/ → rename a passkey DELETE /api/webauthn/credentials/ → remove a passkey ``` **Challenge storage:** challenge bytes are stored in the Flask session (signed cookie, `SECRET_KEY`). No DB row needed. Challenge is consumed (`session.pop`) on the `/complete` call. **Clone detection:** `sign_count` is read before verification and updated after. `py-webauthn` raises on a decreasing counter. **ZK preserved:** a WebAuthn assertion authenticates the user to the *server* only. The vault key `PBKDF2(masterPassword, enc_key_salt, 600k)` is never sent to or derivable by the server. After a passkey login the client must still enter the master password to unlock the vault. **Client-side (`PasskeyAuth` in `auth.js`):** - `loginWithPasskey(email)` — begin → `navigator.credentials.get()` → complete → stores tokens → redirects to `/vault` - `registerPasskey(name)` — begin → `navigator.credentials.create()` → complete - `_bufToB64url` / `_b64urlToBuf` — ArrayBuffer ↔ base64url conversion helpers - `_credentialToJson(cred)` — serialises `PublicKeyCredential` for the server **Settings UI (`vault.js` `loadPasskeys()`):** renders registered passkeys list with rename/delete; wires "Add passkey" button; hides the section if `window.PublicKeyCredential` is absent. **`auth.js` login wiring:** "Sign in with Passkey" button on `login.html` calls `PasskeyAuth.loginWithPasskey(email)`. On success, stores tokens and navigates to `/vault` — unlock overlay fires if master password field was empty. **Authenticator attachment:** `register_begin` accepts optional `attachment` in the POST body: `"platform"` (default — device biometrics) or `"cross-platform"` (roaming — YubiKey, phone QR, NFC). The settings UI exposes a ``). ### 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 `
  • ` 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`. ### `web_accessible_resources` Both manifests declare `web_accessible_resources: []` (MV3) / `[]` (MV2). This prevents any external page from loading extension resources via `chrome-extension://` or `moz-extension://` URLs, blocking extension fingerprinting. ### `_normaliseUrl()` — bare-domain matching ```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; } ``` 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 ```python db.session.add(item) db.session.flush() # populates item.id AuditLog.log(user_id=..., action='vault_item.create', resource_id=item.id, detail=f'Created {item_type} item (id={item.id})') # NO plaintext name db.session.commit() # atomic ``` Audit log details **never** contain plaintext item names, shared item names, or any decrypted vault data. Use `item_type` and `id` only. ### 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.now(timezone.utc).replace(tzinfo=None)` — 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 - `_validate_folder_id` must be called for any user-supplied `folder_id` before DB write - `verify_auth_token` must receive `user=user` at both `login` and `change_password` to enable Argon2 rehash - APScheduler cleanup job handles `TokenBlacklist`, `RecoveryChallenge`, AND `TotpUsedCode`; guard with `os.environ.get('WERKZEUG_RUN_MAIN') == 'true'` in Flask debug mode to prevent double-start - `password_changed_at` lives inside `plain` (encrypted) — never in the server schema - WebAuthn `attachment`: `"cross-platform"` for security keys; `"platform"` for device biometrics (default) - `enc_vault_is_legacy` check in `EmergencyAccess.to_dict()` is pure JSON inspection — no decryption - Never return `str(e)` from exception handlers — log with `_log.exception(...)` and return a generic user-facing message to avoid leaking DB schema details or query fragments - `SharingCrypto.decryptPrivateKey` imports the key with `extractable: false` — use raw `SubtleCrypto` calls when you need the key bytes (e.g. re-encryption on password change) --- ## 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.account_locked` | Failed login lockout | | `auth.py` | `auth.mfa_enable/disable/verify` | TOTP actions | | `auth.py` | `auth.mfa_backup_code_used` | Backup code login | | `auth.py` | `auth.mfa_backup_codes_regenerated` | Backup code regen | | `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 (detail: type + id only) | | `vault.py` | `shared_item.update` | Re-encrypted N accepted share copies when owner edits item | | `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 (detail: type + id) | | `emergency.py` | `emergency_access.*` | All EA state transitions | | `webauthn.py` | `webauthn.register` | Passkey registered | | `webauthn.py` | `webauthn.auth_success` / `webauthn.auth_failed` | Passkey login attempt | | `webauthn.py` | `webauthn.rename` / `webauthn.delete` | Credential management | --- ## 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|backup-codes/regenerate|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/ PUT /api/vault/ DELETE /api/vault/ 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/ GET|POST /api/sharing/keys GET /api/sharing/public-key GET|POST /api/sharing # POST: { item_id, recipient_email, enc_data, iv, DELETE /api/sharing/ # item_name (=item_type), item_type, GET /api/sharing/inbox # enc_name, iv_name } POST /api/sharing/inbox//accept GET|POST /api/emergency DELETE /api/emergency/ POST /api/emergency//accept|provide|request|deny GET /api/emergency//vault POST /api/webauthn/register/begin # start passkey registration (JWT required) POST /api/webauthn/register/complete # finish registration + store credential POST /api/webauthn/authenticate/begin # start passkey login (unauthenticated) POST /api/webauthn/authenticate/complete # verify assertion → tokens + enc_key_salt GET /api/webauthn/credentials # list registered passkeys (JWT required) PATCH /api/webauthn/credentials/ # rename a passkey DELETE /api/webauthn/credentials/ # remove a passkey ``` --- ## Development Setup ```bash 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 ```bash 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) webauthn>=2.0 # Passkey / WebAuthn (py-webauthn) ``` --- ## Notes - Never log decrypted vault data server-side — audit details use `item_type` + `id` only - Background health check runs after every vault load — badge + banner update without user action - HIBP results cached in `_healthCache` per session — Security tab reuses them, no double-query - Extension health badge stored in `chrome.storage.local` (`health_status`) — survives SW restarts - `password_changed_at` in `plain` enables accurate old-password detection without schema changes - Tab visibility lock fires immediately when hidden duration ≥ idle timeout — catches screen lock - 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 `` 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 `` CSP tags. The browser silently ignores it in meta tags and logs a warning. - The `` 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. --- ## Roadmap / To-Do Features planned for future implementation. Ordered by priority within each category. ### High priority — user-facing **1. One-time share links** Generate a time-limited, single-use encrypted link to share a credential with someone who has no PassKeeper account. The decryption key lives in the URL fragment (never sent to server). Auto-expires and self-destructs after first view. - New DB table: `share_links` (token, enc_data, iv, enc_key_fragment_hash, expires_at, used_at) - Public route: `GET /s/` — serves a minimal HTML page - Client derives key from URL fragment, decrypts, displays once - No auth required for recipient **2. Custom fields** Let users add arbitrary key-value pairs to any item type. Fields stored encrypted inside `plain.custom_fields: [{label, value}]` — no schema change needed. - Add "Add field" button in item modal - Renders as label + value rows; value masked by default for sensitive fields - Searchable via expanded `handleSearch` **3. Trusted devices** After successful login + MFA, mark the browser as trusted for N days — MFA skipped on subsequent logins from that device. - Store a signed `trusted_device` cookie (HMAC of device fingerprint + user_id + expiry) - Server verifies on login; if valid, skip MFA challenge - User can revoke trusted devices in Account Settings ### Medium priority — security / UX **4. Email notifications** Send email on security events: new login from unknown IP, MFA disabled, master password changed, emergency access requested. - Requires SMTP config in `.env` (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`) - Use Flask-Mail or smtplib directly - User can opt out per event type in Account Settings **5. Audit log improvements** Current audit log is a raw list. Add: - Filter by action type and date range - Export to CSV - Pagination (currently loads all at once) **6. Access from unknown IP alert** On login, compare client IP against last-seen IPs for the user. If new, flag in the audit log and (if email configured) send a notification. ### Lower priority — platform **7. Vault item attachments** Encrypt files client-side (AES-256-GCM), store as base64 blobs attached to vault items. - New DB table: `vault_attachments` (item_id, enc_data, iv, filename_enc, iv_filename, size, created_at) - Client-side size limit (e.g. 5 MB per file, 25 MB per vault) - Download: decrypt in browser, trigger download via Blob URL **8. Collections / nested folders** Group folders into top-level collections (e.g. "Work", "Personal"). - New DB table: `collections` (user_id, name) - `folders.collection_id` FK - Sidebar renders collection → folder → items hierarchy **9. iOS AutoFill Extension** Expose PassKeeper as an iOS Credential Provider so it fills in Safari and other apps natively. - Requires iOS Credential Provider Extension target in Xcode - Shares encrypted vault data with the extension via App Groups - Ties into the existing JQC iOS project infrastructure **10. Extension — TOTP QR capture** Detect 2FA setup QR codes on screen during account registration flows. - Content script watches for `` or `` elements containing `otpauth://` URIs - Prompts user to save the TOTP secret to the matching vault item **11. Admin panel** (multi-user deployments) - User management (invite, disable, delete) - Per-user storage and audit log review - Requires role column on `users` table ### Infrastructure **12. Backup verification cron** Extend `backup.cron` to run a weekly restore test into a throwaway DB, verifying backup integrity automatically. **13. Health check endpoint** `GET /health` — returns `{"status": "ok", "db": "ok", "version": "x.y.z"}`. Used by uptime monitors (UptimeRobot, etc.). --- ## CI Pipeline `.gitea/workflows/ci.yml` runs on every push to `main`, `master`, `dev`. **Jobs:** 1. `lint-python` — flake8 (F821 and real errors; style noise suppressed) 2. `syntax-check` — `ast.parse` all `app/` Python files 3. `migration-check` — single Alembic head, no duplicate revision IDs 4. `js-syntax` — `node -e "new Function(...)"` on all vault/extension JS files 5. `build-extension` — produces `passkeeper-extension-chrome.zip` and `passkeeper-extension-firefox.zip` as artifacts (30-day retention) **Runner:** self-hosted host-mode runner on the production server. Requires `python3`, `pip3`, `node`, `zip` on the host. No Docker needed.