Files
PassKeeper/CLAUDE.md
T
2026-05-18 22:14:21 -04:00

748 lines
40 KiB
Markdown

# 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
- **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/<id> → rename a passkey
DELETE /api/webauthn/credentials/<id> → 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 `<select>` with both options. Credential type is shown in the passkeys list using transport hints (`internal` → 📱 Device, `usb/nfc/ble` → 🔑 Security key).
**Exception names** (`webauthn>=2.0`):
- `InvalidRegistrationResponse` — use in `register_complete`
- `InvalidAuthenticationResponse` — use in `authenticate_complete`
- `InvalidCBORData` — malformed CBOR attestation
- NOT `InvalidAuthenticatorResponse` (does not exist)
### TOTP replay prevention
Every accepted TOTP code is recorded in `totp_used_codes` (user_id + code, 120s TTL). A second attempt with the same code within that window returns `400 Verification code already used`. Applies to: `mfa_enable`, `mfa_disable`, `mfa_verify`, `mfa_backup_codes_regenerate`. The table is pruned by the APScheduler cleanup job alongside `token_blacklist` and `recovery_challenges`.
### Argon2 transparent rehash
`verify_auth_token(auth_hash, stored_hash, user=user)` calls `ph.check_needs_rehash()` on success. If the stored hash uses outdated parameters (e.g. after raising `ARGON2_TIME_COST`), the hash is silently upgraded in the same DB commit as the login success. Pass `user=user` at all login callsites.
### folder_id ownership validation
`_validate_folder_id(folder_id, user_id)` in `vault.py` queries `Folder` by both `id` and `user_id`. Returns sanitised int or `None`. Raises `ValueError` on mismatch. Applied in `create_item` (400 on invalid), `update_item` (400 on invalid), `import_items` (silently clears to `None` — item still imports to root).
### Account recovery flow
```
1. GET /recovery/data → server creates challenge; returns nonce + recovery blob
enc_key_salt NOT returned (client must decrypt blob)
2. Client decrypts blob with recovery code → gets enc_key_salt
3. Client computes proof = HMAC-SHA256(enc_key_salt, nonce)
4. GET /recovery/items → validates proof; consumes challenge; re-issues fresh challenge
with same expected_proof + new nonce (prevents proof replay)
5. POST /recover → validates proof again; consumes rotated challenge; atomically
re-encrypts vault + resets password
```
### Shared item name encryption
When creating a share, the client encrypts `item.name` with `SharingCrypto.encryptName(sharedKey, name)``enc_name`/`iv_name`. The server receives `item_name = item.item_type` (non-sensitive type label) for the `NOT NULL` column. On the recipient's inbox, `enc_name`/`iv_name` are stored in data attributes on the View button and decrypted client-side with `SharingCrypto.decryptName()` when the user clicks View. Legacy shares (pre-migration, no `enc_name`) fall back to showing `item_name` (the type string).
### Vault item tags
- Stored as `plain.tags: string[]` inside `enc_data`. No schema change ever needed.
- **Web app:** `_parseTags(str)` → comma-split, lowercase, dedup, sort. `_allTags()` collects across all items. `renderTagList()` builds sidebar. Tags field in modal has live badge preview.
- **Extension:** `.pk-tag` badges below item subtitle. Favorites tab = items where `tags.includes('favorite')`. Favorited items show gold ★ in site label.
- `handleSearch()` pool correctly branches on `itemType` / `folder` / `tag` filter types.
### Collapsible folder groups
- **Web app:** `_collapsedGroups` Set persists state across re-renders. Header shows name + count badge + chevron (▼/▶). Click toggles and re-renders.
- **Extension:** only on "All items" tab. `_folders` fetched in parallel with vault. `_collapsedFolders` Set. `folderName(id)` resolves to display name with `(none)` fallback.
### Import / Export
- **Encrypted JSON export:** `GET /api/vault` → versioned envelope → download. Zero-knowledge.
- **CSV export:** decrypt client-side → `name, url, username, password, notes`.
- **JSON import:** sends encrypted blobs to `POST /api/vault/import` directly.
- **CSV import:** RFC 4180-compliant parser (handles `""` embedded quotes); parses Chrome / Bitwarden / 1Password formats; encrypts client-side before POSTing.
- Both endpoints write audit logs: `vault_item.export`, `vault_item.import`.
- Import view resets state (file input, preview, result) every time the view is entered.
### `apiFetch` error handling
`apiFetch` throws on **all** non-ok responses including 404. Thrown error carries `e.status` for callers that need to branch. Callers should use `try/catch` rather than checking `res.ok` after the call.
### Web-app session timeout
- `_startWebIdleTracking()` called at end of `init()`. Listens to `mousemove`, `mousedown`, `keydown`, `touchstart`, `scroll`.
- On timeout: `VaultSession.clear()` → unlock overlay → toast.
- Stored in `localStorage` as `web_idle_minutes`. Default 15 min. Options: Never/5/10/15/30/60.
- Exposed in Account Settings → Auto-Lock (`<select id="web-idle-select">`).
### Browser history / back-button
- `switchView(view, { pushState = true })` calls `history.pushState({ view }, '', '#view-name')`.
- `popstate` listener in `init()` restores view. Direct hash links (`/vault#security`) work.
- `Vault.switchToImportExport()` exposed on public return object for sidebar `<li onclick>` fallback.
### Clipboard auto-clear
- Web app: `copyToClipboard()` uses `_clipboardClearTimer` setTimeout 30s → `writeText('')`.
- Extension: `_copyWithAutoClear()` same pattern. Applied to password, username, and flyout copies.
### Missing 2FA warning
- Security dashboard "No 2FA Saved" section: password items with URL but no `totp_uri`.
- Uses existing `extractTotpSecret()` and `makeSection()`.
### Security score age penalty fix
- `old` only includes items that are **also weak or reused** (`weakOrReusedIds.has(i.id)`).
- Strong unique unchanged passwords no longer penalised.
### Keyboard shortcut
- `manifest.json`: `commands._execute_action`, `Ctrl+Shift+L` / `Cmd+Shift+L`.
- `manifest.firefox.json`: `_execute_browser_action`.
- Customisable at `chrome://extensions/shortcuts`.
### Firefox compatibility
| File | Purpose |
| ---------------------------- | ------------------------------------------------------------------- |
| `manifest.firefox.json` | MV2: `browser_action`, `background.scripts` |
| `background.firefox.js` | In-memory session shim, `setTimeout` idle lock, `browserAction` API |
| `shared/browser-polyfill.js` | `chrome = browser` alias for content scripts |
Load in Firefox: `about:debugging` → This Firefox → Load Temporary Add-on → `manifest.firefox.json`.
### `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 login to enable Argon2 rehash
- APScheduler cleanup job handles `TokenBlacklist`, `RecoveryChallenge`, AND `TotpUsedCode`
- `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
---
## 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` | `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/<id>
PUT /api/vault/<id>
DELETE /api/vault/<id>
GET /api/vault/export # returns encrypted item array
POST /api/vault/import # accepts array; returns { imported, skipped }
GET|POST /api/folders
PUT|DELETE /api/folders/<id>
GET|POST /api/sharing/keys
GET /api/sharing/public-key
GET|POST /api/sharing # POST: { item_id, recipient_email, enc_data, iv,
DELETE /api/sharing/<id> # item_name (=item_type), item_type,
GET /api/sharing/inbox # enc_name, iv_name }
POST /api/sharing/inbox/<id>/accept
GET|POST /api/emergency
DELETE /api/emergency/<id>
POST /api/emergency/<id>/accept|provide|request|deny
GET /api/emergency/<id>/vault
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/<id> # rename a passkey
DELETE /api/webauthn/credentials/<id> # 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 `<meta http-equiv>` CSP tag.
Keep these in sync with the Nginx config for correctness in development (where
Nginx is not present). Note:
- `frame-ancestors` is valid **only** in HTTP headers — never in `<meta>` CSP tags.
The browser silently ignores it in meta tags and logs a warning.
- The `<meta>` tag CSP does not enforce `frame-ancestors`; the Nginx HTTP header does.
### Inline event handler prohibition
`script-src 'self'` blocks all inline `on*` HTML attributes. Do not add `onclick`,
`onchange`, or any other inline handler to any template. Wire all interactions via
`addEventListener` in the corresponding JS file instead.
The `Vault.switchToImportExport()` export on the `vault.js` public API exists for
historical reasons. The `#sidebar-import-export` element is wired via
`addEventListener` in `init()` — the exported function is not needed for new code.
### Third-party browser extension noise
Console errors referencing `isCheckout`, `content-script.js`, or the extension ID
`clmkdohmabikagpnhjmgacbclihgmdje` originate from a third-party shopping/coupon
browser extension — not from PassKeeper. These can be ignored.
The `[PassKeeper] decorateFields: host=…, matched=0 of 0 items` log from
`content.js` is a routine debug message (not an error): the content script found
no stored credentials matching the current hostname, which is expected on the vault
page itself.