diff --git a/CLAUDE.md b/CLAUDE.md index 68012ee..b99c054 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/ PUT /api/vault/ DELETE /api/vault/ @@ -358,9 +457,9 @@ PUT|DELETE /api/folders/ GET|POST /api/sharing/keys GET /api/sharing/public-key -GET|POST /api/sharing -DELETE /api/sharing/ -GET /api/sharing/inbox +GET|POST /api/sharing # POST: { item_id, recipient_email, enc_data, iv, +DELETE /api/sharing/ # item_name (=item_type), item_type, +GET /api/sharing/inbox # enc_name, iv_name } POST /api/sharing/inbox//accept GET|POST /api/emergency @@ -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. \ No newline at end of file diff --git a/README.md b/README.md index a334097..3d6cc6b 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,14 @@ A self-hosted, zero-knowledge password manager — web app and Chrome/Firefox ex - **Collapsible folder groups** — click any folder header in the vault list to collapse/expand; item count badge and chevron indicator; state persists across re-renders - **TOTP / 2FA** — per-site TOTP codes stored in `plain.totp_uri`; live 6-digit display with countdown - **Folder organisation** — create, rename, delete; filter vault by folder -- **Item sharing** — ECDH P-256 zero-knowledge re-encryption; share with any registered user +- **Item sharing** — ECDH P-256 zero-knowledge re-encryption; item name encrypted with shared key — server never sees it - **Emergency access** — configurable wait-timer access grant for a trusted contact - **Security dashboard** — weak / reused / old / **no 2FA saved** / **HaveIBeenPwned breach check** (k-anonymity — passwords never transmitted) -- **Import / Export** — encrypted JSON backup; CSV export (plaintext, handle carefully); import from Chrome, Bitwarden, and 1Password CSV formats -- **Account MFA** — TOTP-based login (Google Authenticator / Authy) +- **Import / Export** — encrypted JSON backup; CSV export (plaintext, handle carefully); import from Chrome, Bitwarden, and 1Password CSV formats (RFC 4180 compliant parser) +- **Account MFA** — TOTP-based login (Google Authenticator / Authy); single-use code enforcement prevents replay attacks - **Master password change** — atomic zero-knowledge re-encryption of entire vault including item names -- **Account recovery** — 128-bit recovery code; server never stores it -- **Audit log** — server-side trail of all create/edit/delete/import/export actions +- **Account recovery** — 128-bit recovery code; server never stores it; challenge-response proof prevents forgery +- **Audit log** — server-side trail of all create/edit/delete/import/export actions; no plaintext names ever logged - **Encrypted item names** — `enc_name`/`iv_name`; server holds only the item type as a label - **Browser history** — back/forward button works for all views (`history.pushState`) - **Web-app auto-lock** — configurable inactivity timeout (5/10/15/30/60 min or Never); stored per browser in `localStorage` @@ -55,6 +55,7 @@ Master Password │ ├─ PBKDF2(email, 100k iter) ──► authHash ──► POST /api/auth/login │ Argon2id(authHash) stored in DB + │ (transparently rehashed if params upgraded) │ └─ PBKDF2(enc_key_salt, 600k iter) ──► vaultKey (browser memory only) │ @@ -64,9 +65,19 @@ Master Password enc_name + iv_name (item name) │ POST /api/vault ──► Server stores ciphertext only + +Sharing: ECDH(Alice_priv, Bob_pub) ──► sharedKey ──► AES-256-GCM(enc_data + enc_name) + Server stores only ciphertext — cannot read item content or item name ``` -A database breach exposes only encrypted ciphertext. The server cannot read vault names, passwords, or tags. +### Security hardening highlights +- **TOTP replay prevention** — each 6-digit code is single-use (120s window); recorded in `totp_used_codes` table +- **Recovery proof** — `enc_key_salt` not returned by server during recovery; client must decrypt the recovery blob to prove code possession +- **folder_id ownership** — all create/update/import operations validate folder belongs to current user +- **Audit log privacy** — item names and shared item names never appear in server-side audit logs +- **Extension fingerprinting** — `web_accessible_resources: []` blocks external pages from probing extension files + +A database breach exposes only encrypted ciphertext. The server cannot read vault names, passwords, tags, or shared item names. --- @@ -197,7 +208,7 @@ passkeeper/ ├── app/ # Flask application │ ├── models/ # SQLAlchemy models │ ├── routes/ # API blueprints (auth, vault, folders, sharing, emergency) -│ ├── services/ # Auth (Argon2id, JWT, TOTP encryption) +│ ├── services/ # Auth (Argon2id, JWT, TOTP encryption, TOTP replay helpers) │ ├── static/js/ # Client-side crypto + vault UI │ └── templates/ # Jinja2 templates ├── extension/ # Browser extension @@ -205,7 +216,7 @@ passkeeper/ │ ├── content/ # Content script (autofill, field detection) │ ├── shared/ # Shared crypto + Firefox polyfill │ ├── bridge/ # SSO bridge -│ ├── background.js # Chrome MV3 service worker +│ ├── background.js # Chrome MV3 service worker │ ├── background.firefox.js # Firefox MV2 background page │ ├── manifest.json # Chrome/Edge MV3 │ └── manifest.firefox.json # Firefox MV2 @@ -224,7 +235,7 @@ All vault/folder endpoints require `Authorization: Bearer `. |---|---|---| | POST | `/api/auth/register` | Create account | | POST | `/api/auth/login` | Authenticate | -| POST | `/api/auth/mfa/verify` | Complete MFA | +| POST | `/api/auth/mfa/verify` | Complete MFA (single-use code) | | POST | `/api/auth/refresh` | Rotate tokens | | POST | `/api/auth/logout` | Blacklist tokens | | GET | `/api/vault` | List encrypted items | @@ -234,7 +245,7 @@ All vault/folder endpoints require `Authorization: Bearer `. | GET | `/api/vault/export` | Download encrypted JSON backup | | POST | `/api/vault/import` | Bulk import; returns `{ imported, skipped }` | | GET/POST | `/api/folders` | List / create folders | -| POST | `/api/sharing` | Share item (ECDH re-encryption) | +| POST | `/api/sharing` | Share item — sends `enc_name`/`iv_name` for ZK name | | POST | `/api/emergency` | Create emergency access grant | | POST | `/api/auth/change-password` | Atomic vault re-encryption | | POST | `/api/auth/recover` | Account recovery (one-time) | @@ -245,7 +256,7 @@ All vault/folder endpoints require `Authorization: Bearer `. Tags are stored as `plain.tags: string[]` inside the encrypted vault blob — the server never sees them and no schema change is required. -**Web app:** Add tags in the item edit modal (comma-separated). Tags appear as purple badge pills on item rows. A Tags section in the sidebar lets you filter by any tag. +**Web app:** Add tags in the item edit modal (comma-separated). Tags appear as purple badge pills on item rows. A Tags section in the sidebar lets you filter by any tag. Search respects the active tag filter. **Extension:** Tags appear as `.pk-tag` badges on item rows. Items tagged `favorite` appear in the **Favorites** tab and show a ★ in the site label.