04/26 update claude.md

This commit is contained in:
2026-04-26 20:36:48 -04:00
parent e8f57df2df
commit dab797c1f0
+120 -32
View File
@@ -9,12 +9,14 @@ A full-featured password manager web app and browser extension modelled after La
## Tech Stack ## Tech Stack
### Development (Windows) ### Development (Windows)
- **Backend:** Python 3.12, Flask 3.x - **Backend:** Python 3.12, Flask 3.x
- **Database:** MySQL 8.x - **Database:** MySQL 8.x
- **Frontend:** Vanilla JS (Web Crypto API) + Jinja2 templates - **Frontend:** Vanilla JS (Web Crypto API) + Jinja2 templates
- **Dev server:** `python run.py` - **Dev server:** `python run.py`
### Production (Ubuntu) ### Production (Ubuntu)
- **Web server:** Nginx (reverse proxy, TLS termination) - **Web server:** Nginx (reverse proxy, TLS termination)
- **WSGI server:** Gunicorn - **WSGI server:** Gunicorn
- **Process manager:** systemd - **Process manager:** systemd
@@ -148,12 +150,12 @@ CREATE TABLE vault_items (
## Migration History ## Migration History
| Revision | Description | | Revision | Description |
|---|---| | -------------- | ------------------------------------------- |
| `71d7158dd3b9` | Add audit_logs; fix sharing_public_key type | | `71d7158dd3b9` | Add audit_logs; fix sharing_public_key type |
| `a1b2c3d4e5f6` | Encrypt TOTP secret at rest | | `a1b2c3d4e5f6` | Encrypt TOTP secret at rest |
| `b2c3d4e5f6a7` | Add account recovery columns | | `b2c3d4e5f6a7` | Add account recovery columns |
| `c3d4e5f6a7b8` | Add enc_name + iv_name to vault_items | | `c3d4e5f6a7b8` | Add enc_name + iv_name to vault_items |
--- ---
@@ -178,16 +180,18 @@ CREATE TABLE vault_items (
## Extension Storage Architecture ## Extension Storage Architecture
| Data | Storage | Reason | | Data | Storage | Reason |
|---|---|---| | -------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------- |
| `access_token`, `vault_key_jwk`, `vault_items`, `vault_items_cs` | `chrome.storage.session` | Memory-only, cleared on browser close | | `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 | | `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 | | Web-app session timeout | `localStorage` (`web_idle_minutes`) | Per-browser preference |
### Critical: `vault_items_cs` is in `session`, NOT `local` ### 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. Requires Chrome 111+. `content.js` `onChanged` listener watches `area === 'session'`. Do not revert — reverting persists decrypted passwords to disk.
### Pending-save badge ### 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. `background.js` sets a red `"!"` badge on `SAVE_CREDENTIALS`. Cleared via `CLEAR_SAVE_BADGE` when user acts on the save prompt in the popup.
--- ---
@@ -195,16 +199,19 @@ Requires Chrome 111+. `content.js` `onChanged` listener watches `area === 'sessi
## Key Implementation Details ## Key Implementation Details
### Vault item tags ### Vault item tags
- Stored as `plain.tags: string[]` inside `enc_data`. No schema change ever needed. - 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. - **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. - **Extension:** `.pk-tag` badges below item subtitle. Favorites tab = items where `tags.includes('favorite')`. Favorited items show gold ★ in site label.
- To favourite an item: add tag `favorite` in the web app edit modal. - To favourite an item: add tag `favorite` in the web app edit modal.
### Collapsible folder groups ### Collapsible folder groups
- **Web app:** `_collapsedGroups` Set persists state across re-renders. Header shows name + count badge + chevron (▼/▶). Click toggles and re-renders. - **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. - **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 ### Import / Export
- **Encrypted JSON export:** `GET /api/vault` → versioned envelope → download. Zero-knowledge. - **Encrypted JSON export:** `GET /api/vault` → versioned envelope → download. Zero-knowledge.
- **CSV export:** decrypt client-side → `name, url, username, password, notes`. - **CSV export:** decrypt client-side → `name, url, username, password, notes`.
- **JSON import:** sends encrypted blobs to `POST /api/vault/import` directly. - **JSON import:** sends encrypted blobs to `POST /api/vault/import` directly.
@@ -212,79 +219,95 @@ Requires Chrome 111+. `content.js` `onChanged` listener watches `area === 'sessi
- Both endpoints write audit logs: `vault_item.export`, `vault_item.import`. - Both endpoints write audit logs: `vault_item.export`, `vault_item.import`.
### Web-app session timeout ### Web-app session timeout
- `_startWebIdleTracking()` called at end of `init()`. Listens to `mousemove`, `mousedown`, `keydown`, `touchstart`, `scroll`. - `_startWebIdleTracking()` called at end of `init()`. Listens to `mousemove`, `mousedown`, `keydown`, `touchstart`, `scroll`.
- On timeout: `VaultSession.clear()` → unlock overlay → toast. - On timeout: `VaultSession.clear()` → unlock overlay → toast.
- Stored in `localStorage` as `web_idle_minutes`. Default 15 min. Options: Never/5/10/15/30/60. - 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">`). - Exposed in Account Settings → Auto-Lock (`<select id="web-idle-select">`).
### Browser history / back-button ### Browser history / back-button
- `switchView(view, { pushState = true })` calls `history.pushState({ view }, '', '#view-name')`. - `switchView(view, { pushState = true })` calls `history.pushState({ view }, '', '#view-name')`.
- `popstate` listener in `init()` restores view. Direct hash links (`/vault#security`) work. - `popstate` listener in `init()` restores view. Direct hash links (`/vault#security`) work.
- `Vault.switchToImportExport()` exposed on public return object for sidebar `<li onclick>` fallback. - `Vault.switchToImportExport()` exposed on public return object for sidebar `<li onclick>` fallback.
### Clipboard auto-clear ### Clipboard auto-clear
- Web app: `copyToClipboard()` uses `_clipboardClearTimer` setTimeout 30s → `writeText('')`. - Web app: `copyToClipboard()` uses `_clipboardClearTimer` setTimeout 30s → `writeText('')`.
- Extension: `_copyWithAutoClear()` same pattern. Applied to password, username, and flyout copies. - Extension: `_copyWithAutoClear()` same pattern. Applied to password, username, and flyout copies.
### Missing 2FA warning ### Missing 2FA warning
- Security dashboard "No 2FA Saved" section: password items with URL but no `totp_uri`. - Security dashboard "No 2FA Saved" section: password items with URL but no `totp_uri`.
- Uses existing `extractTotpSecret()` and `makeSection()`. - Uses existing `extractTotpSecret()` and `makeSection()`.
### Security score age penalty fix ### Security score age penalty fix
- `old` only includes items that are **also weak or reused** (`weakOrReusedIds.has(i.id)`). - `old` only includes items that are **also weak or reused** (`weakOrReusedIds.has(i.id)`).
- Strong unique unchanged passwords no longer penalised. - Strong unique unchanged passwords no longer penalised.
### Keyboard shortcut ### Keyboard shortcut
- `manifest.json`: `commands._execute_action`, `Ctrl+Shift+L` / `Cmd+Shift+L`. - `manifest.json`: `commands._execute_action`, `Ctrl+Shift+L` / `Cmd+Shift+L`.
- `manifest.firefox.json`: `_execute_browser_action`. - `manifest.firefox.json`: `_execute_browser_action`.
- Customisable at `chrome://extensions/shortcuts`. - Customisable at `chrome://extensions/shortcuts`.
### Firefox compatibility ### Firefox compatibility
| File | Purpose |
|---|---| | File | Purpose |
| `manifest.firefox.json` | MV2: `browser_action`, `background.scripts` | | ---------------------------- | ------------------------------------------------------------------- |
| `background.firefox.js` | In-memory session shim, `setTimeout` idle lock, `browserAction` API | | `manifest.firefox.json` | MV2: `browser_action`, `background.scripts` |
| `shared/browser-polyfill.js` | `chrome = browser` alias for content 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`. Load in Firefox: `about:debugging` → This Firefox → Load Temporary Add-on → `manifest.firefox.json`.
### `_normaliseUrl()` — bare-domain matching ### `_normaliseUrl()` — bare-domain matching
```js ```js
function _normaliseUrl(raw) { function _normaliseUrl(raw) {
if (!raw) return null; if (!raw) return null;
const s = raw.trim(); const s = raw.trim();
if (/^https?:\/\//i.test(s)) return s; if (/^https?:\/\//i.test(s)) return s;
if (s.startsWith('//')) return 'https:' + s; if (s.startsWith("//")) return "https:" + s;
return 'https://' + s; return "https://" + s;
} }
``` ```
In both `content.js` and `popup.js`. Prevents silent match failures for bare domains. In both `content.js` and `popup.js`. Prevents silent match failures for bare domains.
### `_isLikelyUsernameField()` — credential field heuristic ### `_isLikelyUsernameField()` — credential field heuristic
1. **YES:** `autocomplete="username|email|tel"` 1. **YES:** `autocomplete="username|email|tel"`
2. **NO:** non-credential autocomplete (`name`, `organization`, `search`, etc.) 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` 3. **YES:** `name/id/placeholder/aria-label` matches `user|email|mail|login|phone|tel|mobile|account`
4. **Otherwise:** not decorated 4. **Otherwise:** not decorated
### MutationObserver guard ### 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. 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` ### `showDropdown` debounced 150ms on `input`
`_debounce(fn, ms)` helper. `focus` listener remains instant. `_debounce(fn, ms)` helper. `focus` listener remains instant.
### Three-dot flyout menu ### Three-dot flyout menu
`.pk-flyout` div anchored below button. Dynamic items: Open URL / Copy username / Copy password. Closes on outside click. `.pk-flyout` div anchored below button. Dynamic items: Open URL / Copy username / Copy password. Closes on outside click.
### AuditLog pattern ### AuditLog pattern
```python ```python
db.session.add(item) db.session.add(item)
db.session.flush() # populates item.id db.session.flush() # populates item.id
AuditLog.log(user_id=..., action='vault_item.create', resource_id=item.id, ...) AuditLog.log(user_id=..., action='vault_item.create', resource_id=item.id, ...)
db.session.commit() # atomic db.session.commit() # atomic
``` ```
For deletes: capture `id` and `name` before flush. For deletes: capture `id` and `name` before flush.
### Common gotchas ### Common gotchas
- `item_type`: use `db.String(20)`, not `db.Enum(ItemType)` — enum lazy-load breaks `.value` - `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` - `INTEGER(unsigned=True)`: requires `from sqlalchemy.dialects.mysql import INTEGER`
- All datetimes: naive UTC `datetime.utcnow()` — never mix with timezone-aware - All datetimes: naive UTC `datetime.utcnow()` — never mix with timezone-aware
@@ -297,20 +320,20 @@ For deletes: capture `id` and `name` before flush.
## Audit Action Catalog ## Audit Action Catalog
| Module | Action | Trigger | | Module | Action | Trigger |
|---|---|---| | -------------- | ------------------------------------------------------ | ------------------------ |
| `auth.py` | `auth.register` | New account | | `auth.py` | `auth.register` | New account |
| `auth.py` | `auth.login` / `auth.login_failed` | Login success/fail | | `auth.py` | `auth.login` / `auth.login_failed` | Login success/fail |
| `auth.py` | `auth.mfa_enable/disable/verify` | TOTP actions | | `auth.py` | `auth.mfa_enable/disable/verify` | TOTP actions |
| `auth.py` | `auth.change_password` / `auth.change_password_failed` | Password change | | `auth.py` | `auth.change_password` / `auth.change_password_failed` | Password change |
| `auth.py` | `auth.delete_account` / `auth.delete_account_failed` | Deletion | | `auth.py` | `auth.delete_account` / `auth.delete_account_failed` | Deletion |
| `auth.py` | `auth.recovery_setup/failed/items_denied/success` | Recovery | | `auth.py` | `auth.recovery_setup/failed/items_denied/success` | Recovery |
| `vault.py` | `vault_item.create/update/delete` | CRUD | | `vault.py` | `vault_item.create/update/delete` | CRUD |
| `vault.py` | `vault_item.export` / `vault_item.import` | Import/Export | | `vault.py` | `vault_item.export` / `vault_item.import` | Import/Export |
| `folders.py` | `folder.create/update/delete` | Folder CRUD | | `folders.py` | `folder.create/update/delete` | Folder CRUD |
| `sharing.py` | `sharing_keys.create/update` | ECDH key setup | | `sharing.py` | `sharing_keys.create/update` | ECDH key setup |
| `sharing.py` | `shared_item.create/delete/accept` | Sharing | | `sharing.py` | `shared_item.create/delete/accept` | Sharing |
| `emergency.py` | `emergency_access.*` | All EA state transitions | | `emergency.py` | `emergency_access.*` | All EA state transitions |
--- ---
@@ -392,4 +415,69 @@ redis>=5.0 # Rate-limit storage (required in production)
- Firefox: use `manifest.firefox.json` + `background.firefox.js` + `browser-polyfill.js` - Firefox: use `manifest.firefox.json` + `background.firefox.js` + `browser-polyfill.js`
- Redis required in production for rate limiting - Redis required in production for rate limiting
- Recovery code never stored server-side; password change clears it (user must regenerate) - Recovery code never stored server-side; password change clears it (user must regenerate)
- `save_blocklist` in `chrome.storage.local` suppresses save banner per hostname - `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.