05/18 Update CLAUDE.md and README.md

This commit is contained in:
2026-05-18 12:22:15 -04:00
parent 2eaf2adbf1
commit 09d3bbdc15
2 changed files with 164 additions and 54 deletions
+142 -43
View File
@@ -48,8 +48,10 @@ passkeeper/
│ │ ├── vault_item.py # enc_data, iv, enc_name, iv_name
│ │ ├── folder.py
│ │ ├── token_blacklist.py # JWT revocation (jti + expires_at)
│ │ ├── shared_item.py # ECDH-encrypted cross-user shares
│ │ ├── 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)
│ │ └── audit_log.py
│ ├── routes/
│ │ ├── auth.py # Register, login, MFA, logout, refresh, change-password, recovery
@@ -58,7 +60,8 @@ passkeeper/
│ │ ├── sharing.py
│ │ └── emergency.py
│ ├── services/
│ │ └── auth_service.py # Argon2id, JWT, blacklist, @require_jwt, TOTP encrypt/decrypt
│ │ └── 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/
@@ -66,13 +69,13 @@ passkeeper/
│ │ │ # encryptName, decryptName, generateSalt
│ │ ├── auth.js # Login/register + TOTP MFA + VaultSession
│ │ ├── recover.js
│ │ ├── sharing.js # ECDH P-256
│ │ ├── 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); Ctrl+Shift+L keyboard shortcut
│ ├── manifest.firefox.json # MV2 (Firefox)
│ ├── 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/
@@ -94,7 +97,11 @@ passkeeper/
│ ├── 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 # adds enc_name + iv_name
── 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
├── scripts/
│ ├── reencrypt_totp_secrets.py
│ ├── backup_db.sh / backup.cron / passkeeper-logrotate
@@ -120,6 +127,7 @@ CREATE TABLE users (
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),
@@ -143,19 +151,61 @@ CREATE TABLE vault_items (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL
);
-- (folders, token_blacklist, shared_items, emergency_access, audit_logs — unchanged)
-- 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)
```
---
## 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 |
| 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 |
---
@@ -165,16 +215,20 @@ CREATE TABLE vault_items (
- `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`; server `name` column = item type only
- **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
- **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
- **Sharing:** ECDH P-256 zero-knowledge re-encryption
- **MFA:** TOTP secret AES-256-GCM encrypted at rest; each code is single-use (replay prevented via `totp_used_codes` table, 120s TTL)
- **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
---
@@ -198,12 +252,41 @@ Requires Chrome 111+. `content.js` `onChanged` listener watches `area === 'sessi
## Key Implementation Details
### 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.
- To favourite an item: add tag `favorite` in the web app edit modal.
- `handleSearch()` pool correctly branches on `itemType` / `folder` / `tag` filter types.
### Collapsible folder groups
@@ -215,8 +298,13 @@ Requires Chrome 111+. `content.js` `onChanged` listener watches `area === 'sessi
- **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:** parses Chrome / Bitwarden / 1Password formats; encrypts client-side before POSTing.
- **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
@@ -262,6 +350,10 @@ Requires Chrome 111+. `content.js` `onChanged` listener watches `area === 'sessi
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
@@ -300,40 +392,47 @@ Inspects added/removed nodes — if all carry `__pk` prefix, returns early. Prev
```python
db.session.add(item)
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,
detail=f'Created {item_type} item (id={item.id})') # NO plaintext name
db.session.commit() # atomic
```
For deletes: capture `id` and `name` before flush.
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.utcnow()` — never mix with timezone-aware
- 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`
---
## 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.mfa_enable/disable/verify` | TOTP actions |
| `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 |
| `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 |
| `emergency.py` | `emergency_access.*` | All EA state transitions |
| 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 |
---
@@ -342,11 +441,11 @@ For deletes: capture `id` and `name` before flush.
```
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|change-password|recovery/setup
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? }
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>
@@ -358,9 +457,9 @@ PUT|DELETE /api/folders/<id>
GET|POST /api/sharing/keys
GET /api/sharing/public-key
GET|POST /api/sharing
DELETE /api/sharing/<id>
GET /api/sharing/inbox
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
@@ -404,7 +503,7 @@ redis>=5.0 # Rate-limit storage (required in production)
## Notes
- Never log decrypted vault data server-side
- Never log decrypted vault data server-side — audit details use `item_type` + `id` only
- 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
@@ -480,4 +579,4 @@ 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.
page itself.