04/26 update claude.md
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user