diff --git a/.gitignore b/.gitignore index c0b0336..3402c17 100644 --- a/.gitignore +++ b/.gitignore @@ -282,6 +282,6 @@ cython_debug/ marimo/\_static/ marimo/\_lsp/ **marimo**/ -README.md +# README.md # CLAUDE.md .claude/ diff --git a/CLAUDE.md b/CLAUDE.md index 9ea0e07..311e3e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Project Overview -A full-featured password manager web app and browser extension modeled after LastPass. Users can store, organize, and autofill credentials securely with a zero-knowledge architecture. +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. --- @@ -45,7 +45,7 @@ passkeeper/ │ ├── config.py # DevelopmentConfig / ProductionConfig (TOTP_ENCRYPTION_KEY, CORS_ORIGINS, RATELIMIT_STORAGE_URI) │ ├── models/ │ │ ├── user.py # User model (Argon2id, TOTP encrypted, ECDH sharing keys, recovery columns) -│ │ ├── vault_item.py # VaultItem model (item_type as VARCHAR, enc_data, iv) +│ │ ├── vault_item.py # VaultItem model (enc_data, iv, enc_name, iv_name — all sensitive fields encrypted) │ │ ├── folder.py # Folder model │ │ ├── token_blacklist.py # JWT revocation on logout (jti + expires_at) │ │ ├── shared_item.py # Cross-user ECDH-encrypted item shares @@ -53,7 +53,7 @@ passkeeper/ │ │ └── audit_log.py # Server-side audit trail for all CUD actions │ ├── routes/ │ │ ├── auth.py # Register, login, MFA/TOTP, logout, refresh, change-password, delete-account, recovery -│ │ ├── vault.py # CRUD vault items +│ │ ├── vault.py # CRUD vault items (accepts enc_name/iv_name on create/update) │ │ ├── folders.py # CRUD folders │ │ ├── sharing.py # ECDH key exchange + zero-knowledge item sharing │ │ └── emergency.py # Emergency access state machine @@ -62,11 +62,13 @@ passkeeper/ │ ├── static/ │ │ ├── css/app.css # Full responsive stylesheet (phone/tablet/laptop/PC breakpoints) │ │ └── js/ -│ │ ├── crypto.js # Web Crypto API: deriveAuthHash, deriveVaultKey, encryptItem, decryptItem +│ │ ├── crypto.js # Web Crypto API: deriveAuthHash, deriveVaultKey, encryptItem, decryptItem, +│ │ │ # encryptName, decryptName, generateSalt │ │ ├── auth.js # Login/register forms + TOTP MFA step + VaultSession │ │ ├── recover.js # Account recovery flow (two-step: verify code → set new password) │ │ ├── sharing.js # ECDH P-256 key generation, encrypt/decrypt for sharing │ │ └── vault.js # Vault UI: all views + sidebar toggle + change-password + recovery + delete-account +│ │ # renderSecurityDashboard is async — runs HIBP k-anonymity checks after sync sections │ └── templates/ │ ├── base.html # CSP meta, csrf-token meta, viewport meta, no inline scripts/styles │ ├── auth/ @@ -75,24 +77,26 @@ passkeeper/ │ │ └── recover.html # Two-step account recovery page │ └── vault/ │ └── index.html # Sidebar (collapsible), mobile topbar, vault list, modals -├── extension/ # Browser extension (Phase 4 — Manifest V3) +├── extension/ # Browser extension (Manifest V3) │ ├── manifest.json # MV3: permissions, content_scripts, background, action │ ├── background.js # Service worker: badge count, save-credentials relay, SSO sync, │ │ # KEEPALIVE ping handler, OPEN_VAULT tab opener │ ├── shared/ -│ │ └── crypto.js # Same PBKDF2+AES-GCM logic; extractable vault key for session storage +│ │ └── crypto.js # Same PBKDF2+AES-GCM logic; extractable vault key for session storage; +│ │ # encryptName / decryptName helpers for item name encryption │ ├── popup/ -│ │ ├── popup.html # Login → MFA → Unlock-only → Vault → Generator views; -│ │ │ # shared bottom nav (Vault/Generator/Alerts/Account); -│ │ │ # blocking save-prompt modal overlay (no auto-dismiss) -│ │ ├── popup.css # Includes generator view styles + save-modal overlay styles -│ │ └── popup.js # Auth, vault fetch/decrypt, tabs, autofill trigger, -│ │ # inline password generator, save-prompt modal, SSO, -│ │ # service-worker keepalive, relevant-tab domain filter +│ │ ├── popup.html # Login → MFA → Unlock-only → Vault → Generator → Add Item views +│ │ ├── popup.css # Includes generator styles, save-modal overlay, pk-flyout context menu +│ │ └── popup.js # Auth, vault fetch/decrypt (decrypts enc_name), tabs, autofill trigger, +│ │ # inline password generator (fully CSPRNG via _cryptoRandInt), +│ │ # save-prompt modal, copy-username button, three-dot flyout menu, +│ │ # SSO, service-worker keepalive, relevant-tab domain filter │ ├── content/ -│ │ └── content.js # Form detection; injects PK icon into username AND password fields; -│ │ # focus/input-triggered suggestion dropdown (anchored below field); -│ │ # duplicate detection before save banner; submit watcher +│ │ └── content.js # Form detection; _isLikelyUsernameField heuristic (autocomplete/name/id scoring); +│ │ # injects PK icon into username AND password fields only; +│ │ # debounced input handler (150ms); MutationObserver guards against +│ │ # re-triggering on extension's own DOM mutations; +│ │ # _normaliseUrl handles bare domains ("github.com") │ ├── bridge/ │ │ └── bridge.js # SSO bridge (runs on vault domain only): syncs session web↔extension │ ├── icons/ # Generated by make_icons.py (Pillow) @@ -106,7 +110,8 @@ passkeeper/ │ └── versions/ │ ├── 71d7158dd3b9_add_audit_log_tables_fix_sharing_public_.py │ ├── a1b2c3d4e5f6_encrypt_totp_secret_at_rest.py # Widens totp_secret + adds totp_iv -│ └── b2c3d4e5f6a7_add_account_recovery_columns.py # Adds recovery_enc_salt + recovery_iv +│ ├── b2c3d4e5f6a7_add_account_recovery_columns.py # Adds recovery_enc_salt + recovery_iv +│ └── c3d4e5f6a7b8_encrypt_vault_item_name.py # Adds enc_name + iv_name to vault_items ├── scripts/ │ ├── reencrypt_totp_secrets.py # One-time migration: encrypt existing plaintext TOTP secrets │ ├── backup_db.sh # Automated MySQL backup (gzip, 30-day retention) @@ -135,7 +140,7 @@ CREATE TABLE users ( enc_key_salt VARCHAR(64) NOT NULL, -- Random 16-byte salt (base64), returned on login created_at DATETIME DEFAULT NOW(), last_login DATETIME, - -- TOTP / MFA (Phase 5: secret now AES-256-GCM encrypted at rest) + -- TOTP / MFA (secret AES-256-GCM encrypted at rest) totp_secret VARCHAR(255), -- AES-256-GCM ciphertext of base32 secret (base64) totp_iv VARCHAR(64), -- base64 12-byte GCM nonce for totp_secret totp_enabled TINYINT(1) DEFAULT 0, @@ -143,7 +148,7 @@ CREATE TABLE users ( sharing_public_key VARCHAR(128), -- Raw uncompressed point (65 bytes), base64, plaintext sharing_private_key_enc TEXT, -- JWK, AES-256-GCM encrypted with vault key sharing_private_key_iv VARCHAR(64), -- base64 12-byte nonce for private key encryption - -- Account recovery (Phase 6C) + -- Account recovery recovery_enc_salt VARCHAR(128), -- enc_key_salt re-encrypted with recovery key (base64) recovery_iv VARCHAR(64) -- base64 12-byte nonce for recovery_enc_salt ); @@ -156,47 +161,49 @@ CREATE TABLE folders ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); --- Vault Items (all sensitive fields stored as AES-256-GCM encrypted JSON blob) +-- Vault Items (all sensitive fields AES-256-GCM encrypted client-side) 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', -- plain string, not ENUM - name VARCHAR(255) NOT NULL, -- plaintext for display only - enc_data TEXT NOT NULL, -- base64 AES-256-GCM ciphertext - iv VARCHAR(64) NOT NULL, -- base64 12-byte GCM nonce + name VARCHAR(255) NOT NULL, -- non-sensitive server label (item type only) + enc_data TEXT NOT NULL, -- base64 AES-256-GCM ciphertext of item payload + iv VARCHAR(64) NOT NULL, -- base64 12-byte GCM nonce for enc_data + enc_name TEXT, -- base64 AES-256-GCM ciphertext of item name + iv_name VARCHAR(64), -- base64 12-byte GCM nonce for enc_name 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 ); --- Token Blacklist (Phase 3 — JWT revocation) +-- Token Blacklist (JWT revocation) CREATE TABLE token_blacklist ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - jti VARCHAR(36) UNIQUE NOT NULL, -- JWT ID claim + jti VARCHAR(36) UNIQUE NOT NULL, user_id INT UNSIGNED NOT NULL, expires_at DATETIME NOT NULL, INDEX (jti), INDEX (expires_at) ); --- Shared Items (Phase 3 — ECDH zero-knowledge sharing) +-- Shared Items (ECDH zero-knowledge sharing) 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, + item_name VARCHAR(255) NOT NULL, -- plaintext display name for share inbox item_type VARCHAR(20) NOT NULL, - enc_data TEXT NOT NULL, -- re-encrypted with ECDH shared secret + enc_data TEXT NOT NULL, iv VARCHAR(64) NOT NULL, accepted TINYINT(1) DEFAULT 0, created_at DATETIME DEFAULT NOW(), FOREIGN KEY (item_id) REFERENCES vault_items(id) ON DELETE CASCADE ); --- Emergency Access (Phase 3) +-- Emergency Access CREATE TABLE emergency_access ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, grantor_id INT UNSIGNED NOT NULL, @@ -205,21 +212,21 @@ CREATE TABLE emergency_access ( wait_days INT NOT NULL DEFAULT 7, status VARCHAR(20) NOT NULL DEFAULT 'invited', request_initiated_at DATETIME, - enc_vault TEXT, -- JSON array of ECDH-encrypted vault items + enc_vault TEXT, created_at DATETIME DEFAULT NOW(), FOREIGN KEY (grantor_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (grantee_id) REFERENCES users(id) ON DELETE SET NULL ); --- Audit Logs (Phase 3 addition — server-side action trail) +-- Audit Logs CREATE TABLE audit_logs ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - user_id INT UNSIGNED NOT NULL, -- actor; no FK so logs survive user deletion - action VARCHAR(64) NOT NULL, -- e.g. "vault_item.create" - resource_type VARCHAR(64) NOT NULL, -- e.g. "vault_item" - resource_id INT UNSIGNED, -- ID of the affected row (nullable for auth events) - detail VARCHAR(512), -- human-readable summary; NO secrets ever logged - ip_address VARCHAR(45), -- IPv4 or IPv6 (supports X-Forwarded-For) + user_id INT UNSIGNED NOT NULL, + action VARCHAR(64) NOT NULL, + resource_type VARCHAR(64) NOT NULL, + resource_id INT UNSIGNED, + detail VARCHAR(512), + ip_address VARCHAR(45), created_at DATETIME NOT NULL, INDEX (user_id), INDEX (created_at) ); @@ -227,28 +234,218 @@ CREATE TABLE audit_logs ( --- -## Audit Log System +## Security Model -Every create, edit, and delete action is recorded server-side via `AuditLog.log()`. -**No sensitive or encrypted values are ever written to the audit log.** +- **Zero-knowledge:** master password never sent to server + - `authHash = PBKDF2(masterPassword, email, 100_000 iter)` — sent to server for auth only + - `vaultKey = PBKDF2(masterPassword, enc_key_salt, 600_000 iter)` — stays in browser memory only + - All vault data (payload + item name) encrypted client-side (AES-256-GCM) before sending to server +- **Item name encryption:** `enc_name`/`iv_name` store the AES-256-GCM ciphertext of the item name. The server's plaintext `name` column holds only the item type (e.g. `"password"`) as a non-sensitive audit label. Legacy items without `enc_name` fall back to `name` transparently. +- **Server-side:** Argon2id hash of the client-derived `authHash` (double-hashed for depth) +- **JWT:** HS256, access token 15 min, refresh token 7 days, unique JTI per token +- **JWT blacklist:** on logout both tokens are blacklisted by JTI; refresh rotates tokens +- **MFA:** TOTP (pyotp), secret stored AES-256-GCM encrypted in `users.totp_secret`/`totp_iv` +- **Sharing:** ECDH P-256 — shared secret derived client-side, re-encrypts item plaintext +- **Emergency access:** same ECDH re-encryption; wait timer enforced server-side +- **CSRF:** Flask-WTF on page-serving routes; API blueprints CSRF-exempt (JWT bearer auth) +- **Rate limiting:** Flask-Limiter (Redis-backed in production); Nginx dual-zone as second layer +- **HTTP security headers:** HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, CSP via `@app.after_request` +- **Password generator (extension):** Fully CSPRNG — `_cryptoRandInt()` uses rejection-sampling with `crypto.getRandomValues()`. `Math.random()` is never called anywhere in the generator. +- **Decrypted vault data in extension:** Stored in `chrome.storage.session` only (memory-only, cleared on browser close). Never written to `chrome.storage.local`. +- **Breach detection:** Security dashboard runs HaveIBeenPwned k-anonymity checks — only the first 5 chars of each password's SHA-1 hash are transmitted. Passwords never leave the browser. -### AuditLog Model (`app/models/audit_log.py`) +--- -```python -AuditLog.log( - user_id = int, # ID of the acting user - action = str, # dot-namespaced action string (see catalog below) - resource_type = str, # the affected entity type - resource_id = int|None, # PK of the affected row - detail = str|None, # free-text summary (no secrets) - ip_address = str|None, # best-effort client IP -) +## Extension Storage Architecture — Critical Rules + +### Storage area decision table + +| Data | Where stored | Why | +|---|---|---| +| `access_token`, `vault_key_jwk`, `vault_items`, `vault_items_cs` | `chrome.storage.session` | Memory-only, cleared on browser close. Decrypted data must never persist to disk. | +| `refresh_token`, `enc_key_salt`, `pending_save`, `save_blocklist`, `idle_lock_seconds` | `chrome.storage.local` | Persists across browser restarts and SW termination. | + +### `vault_items_cs` is in `chrome.storage.session`, NOT `chrome.storage.local` + +As of the current codebase, `vault_items_cs` (the decrypted vault cache for content scripts) is written to **`chrome.storage.session`**. This is a deliberate security decision — decrypted passwords must never be persisted to disk. + +**Minimum Chrome version required: 111 (March 2023).** `chrome.storage.session` is not readable by content scripts on Chrome ≤ 110. The `storage.onChanged` listener in `content.js` watches `area === 'session'`. + +> ⚠️ **The original CLAUDE.md stated `vault_items_cs` lives in `chrome.storage.local`. That is no longer correct.** If you see it reverting to `local`, that is a regression — revert it. + +### `chrome.storage.onChanged` area guard + +```js +chrome.storage.onChanged.addListener(function(changes, area) { + if (area === 'session' && changes.vault_items_cs) { // 'session', not 'local' + ... + } +}); ``` -The method adds the entry to `db.session` without committing — the surrounding request -handler's `db.session.commit()` flushes both the business-logic change and the audit row -atomically. Always call `db.session.flush()` before `AuditLog.log()` so `resource_id` -is populated. +### `pending_save` must remain in `chrome.storage.local` + +`chrome.storage.session` is wiped when the MV3 service worker is killed (within seconds of inactivity). `pending_save` stays in `local` so the save prompt survives SW restarts. + +--- + +## Key Implementation Decisions & Gotchas + +### Item name is encrypted client-side (`enc_name` / `iv_name`) + +The `name` column in `vault_items` is **not** sensitive — it stores only the item type string (e.g. `"password"`) as a server-side audit label. The real display name is stored as AES-256-GCM ciphertext in `enc_name` with nonce `iv_name`. + +**On load:** `loadVault()` / `fetchAndDecryptVault()` decrypt `enc_name` after decrypting `enc_data` and override `item.name` with the plaintext. Legacy items without `enc_name` use the server's `name` field as fallback — no migration of existing rows required. + +**On save:** `handleFormSubmit()` (web app) and `addItemToVault()` / `saveCredential()` (extension) call `Crypto.encryptName(vaultKey, name)` / `ExtCrypto.encryptName(vaultKey, name)` before POSTing, sending `enc_name` + `iv_name` alongside `enc_data` + `iv`. + +**On password change / recovery:** The re-encryption loops in `auth.py` also update `vault_item.enc_name` / `vault_item.iv_name` when the client sends updated values. + +**API contract:** `POST /api/vault` and `PUT /api/vault/` now accept optional `enc_name` and `iv_name` fields. The `name` field is still required (used as the server-side label). + +### Password generator is fully CSPRNG + +`generatePassword()` in `extension/popup/popup.js` uses `_cryptoRandInt(max)` for all random selection: + +```js +function _cryptoRandInt(max) { + // Rejection sampling — no modulo bias. + const limit = Math.floor(0x100000000 / max) * max; + const buf = new Uint32Array(1); + do { crypto.getRandomValues(buf); } while (buf[0] >= limit); + return buf[0] % max; +} +``` + +This replaces `Math.floor(Math.random() * n)` which was used in the required-character selection and Fisher-Yates shuffle steps. `Math.random()` is not called anywhere in the generator. + +### `_isLikelyUsernameField()` — credential field detection heuristic + +`findUsernameField()` in `content.js` no longer accepts any `input[type="text"]` preceding the password field. It scores candidates via `_isLikelyUsernameField()`: + +1. **Definite YES:** `autocomplete="username"` or `"email"` or `"tel"` +2. **Definite NO:** Non-credential `autocomplete` values (`name`, `given-name`, `organization`, `search`, etc.) +3. **Keyword scan YES:** `name`, `id`, `placeholder`, or `aria-label` contain `user|email|mail|login|phone|tel|mobile|account` +4. **Otherwise:** field is not decorated + +`input[type="email"]` and `input[type="tel"]` are always accepted without scoring. + +### `_normaliseUrl()` — bare-domain URL matching + +Both `content.js` and `popup.js` include a `_normaliseUrl()` helper that prepends `https://` to URLs that lack a scheme before passing them to `new URL()`. This prevents silent match failures for vault items stored with bare domains (e.g. `"github.com"` instead of `"https://github.com"`). + +```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; +} +``` + +### MutationObserver guards against extension's own DOM mutations + +The `MutationObserver` in `content.js` inspects every mutation before calling `decorateFields()`. If every added/removed node carries a `__pk` class or id prefix, the callback returns early — preventing re-decoration loops on SPAs triggered by the extension injecting or removing its own dropdown or icon buttons. + +### `showDropdown` is debounced on `input` events + +The `input` event listener on decorated fields wraps `showDropdown` with a 150 ms debounce via `_debounce()`. The `focus` listener remains instant. This eliminates visible lag on large vaults when the user types quickly. + +### Three-dot menu is now a proper flyout + +The `data-menu` button in the popup renders a `.pk-flyout` div anchored below the button. Menu items are built dynamically from what the item has — "Open URL" only if `plain.url` exists, etc. Styled via `.pk-flyout` / `.pk-flyout-item` in `popup.css`. Closes on any outside click. + +### Copy-username button is a dedicated action + +A `data-copy-user` button with a person SVG icon appears at the left of the action row for any item with a username. This replaces the previous behaviour where the three-dot button silently copied the username on click. + +### HaveIBeenPwned breach detection + +`renderSecurityDashboard()` in `vault.js` is now `async`. After rendering the synchronous sections (Weak / Reused / Old), it fires parallel HIBP API calls for every password item via `checkHibp(password)`: + +```js +async function checkHibp(password) { + const hashHex = /* SHA-1 of password via crypto.subtle */; + const prefix = hashHex.slice(0, 5); + const suffix = hashHex.slice(5); + const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, { + headers: { 'Add-Padding': 'true' }, + }); + // parse response, find matching suffix, return breach count +} +``` + +Only the first 5 hex characters of the SHA-1 hash are sent. Passwords never leave the browser. Network errors fail safe (return 0, do not block UI). The HIBP section renders progressively after the synchronous sections. + +### `item_type` must be `VARCHAR(20)`, NOT `db.Enum(ItemType)` + +Using `db.Enum(ItemType)` with a `str`-based Python enum caused SQLAlchemy to lazy-load the column back as a raw string after commit, making `self.item_type.value` raise `AttributeError`. Use `db.String(20)`. + +### `INTEGER(unsigned=True)` requires MySQL dialect type + +```python +from sqlalchemy.dialects.mysql import INTEGER +db.Column(INTEGER(unsigned=True), ...) +``` + +### All datetimes are naive UTC (`datetime.utcnow()`) + +Do NOT mix in `datetime.now(timezone.utc)` — mixing causes comparison failures in token blacklist and emergency access wait-timer checks. + +### AuditLog must flush before log, then commit together + +```python +db.session.add(item) +db.session.flush() # populates item.id +AuditLog.log(user_id=..., action='vault_item.create', resource_id=item.id, ...) +db.session.commit() # commits both atomically +``` + +For deletes, capture `id` and `name` before the delete flush. + +### Extension vault key must be extractable + +`extension/shared/crypto.js` uses `extractable: true` so the vault key can be serialised as JWK and stored in `chrome.storage.session`. The web app uses `extractable: false`. + +### JWT `sub` claim must be a string (PyJWT 2.x) + +Always convert: `'sub': str(user_id)` when generating tokens; `int(payload['sub'])` when reading back. + +### TOTP secrets encrypted at rest (server-side) + +`totp_secret` stores AES-256-GCM ciphertext; `totp_iv` stores the nonce. Encrypted/decrypted exclusively by `auth_service.encrypt_totp_secret()` / `auth_service.decrypt_totp_secret()`. Generating the key: + +```bash +python -c "import secrets; print(secrets.token_hex(32))" +``` + +### Extension SSO bridge + +`bridge.js` runs on `pwkeeper.ngodanguyen.tech` only and syncs sessions bidirectionally between the web app and the extension. See the original CLAUDE.md for the full bridge flow. + +### Autofill uses native HTMLInputElement setter for framework compatibility + +```js +const nativeSet = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; +if (nativeSet) nativeSet.call(el, value); +el.dispatchEvent(new Event('input', { bubbles: true })); +el.dispatchEvent(new Event('change', { bubbles: true })); +``` + +### `reset_db.py` for schema changes (Windows, no flask db CLI) + +```bash +python reset_db.py +``` + +Production schema changes: `flask db upgrade`. + +--- + +## Audit Log System + +Every create, edit, and delete action is recorded server-side via `AuditLog.log()`. No sensitive or encrypted values are ever written to the audit log. ### Audit Action Catalog @@ -287,603 +484,60 @@ is populated. | `emergency.py` | `emergency_access.deny` | Grantor denied pending request | | `emergency.py` | `emergency_access.vault_retrieved` | Grantee fetched vault after wait elapsed | -### Client IP Resolution - -All route modules define a local `_client_ip()` helper that reads the -`X-Forwarded-For` header (first entry only) and falls back to `request.remote_addr`. -This correctly handles deployments behind Nginx. - --- -## Security Model +## REST API Endpoints -- **Zero-knowledge:** master password never sent to server - - `authHash = PBKDF2(masterPassword, email, 100_000 iter)` — sent to server for auth only - - `vaultKey = PBKDF2(masterPassword, enc_key_salt, 600_000 iter)` — stays in browser memory only - - All vault data encrypted client-side (AES-256-GCM) before sending to server -- **Server-side:** Argon2id hash of the client-derived `authHash` (double-hashed for depth) -- **JWT:** HS256, access token 15 min, refresh token 7 days, unique JTI per token -- **JWT blacklist:** on logout both tokens are blacklisted by JTI; refresh rotates tokens (old blacklisted, new issued) -- **MFA:** TOTP (pyotp), secret stored **AES-256-GCM encrypted** in `users.totp_secret` (ciphertext, base64) + `users.totp_iv` (nonce, base64). Encrypted server-side with `TOTP_ENCRYPTION_KEY` from config. Decrypted in `auth_service.decrypt_totp_secret()` only when verifying a code — plaintext never persists in memory beyond the request. -- **Sharing:** ECDH P-256 — shared secret derived client-side, used as AES-256-GCM key to re-encrypt item plaintext; server never sees plaintext -- **Emergency access:** same ECDH re-encryption; wait timer enforced server-side; grantor can deny at any time -- **CSRF:** Flask-WTF on page-serving routes; **API blueprints are CSRF-exempt** (JWT bearer tokens make CSRF irrelevant for the API) -- **Rate limiting:** Flask-Limiter (Redis-backed in production, shared across all Gunicorn workers) on `/api/auth/register`, `/api/auth/login`, `/api/auth/mfa/verify` (10/min) and `/api/auth/refresh` (30/min). Disabled in `DevelopmentConfig`. Nginx adds a second layer: `auth_limit` zone (10 req/min, burst 5) on auth endpoints, `api_limit` zone (60 req/s, burst 20) on all other routes. -- **HTTP security headers:** set via `@app.after_request` hook in `__init__.py` on every response — `Strict-Transport-Security` (1 year, includeSubDomains), `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy`, and `Content-Security-Policy` (HTTP header overrides the meta tag). -- `enc_key_salt` stored in `sessionStorage` (not sensitive alone — useless without master password) -- Vault key stored only in JS module-level variable (`VaultSession`); lost on page reload → unlock overlay re-derives it -- **Argon2id parameters** (configurable via `.env`): `time_cost=3`, `memory_cost=65536` (64 MB), `parallelism=4` -- **CORS:** restricted to `CORS_ORIGINS` env var (`*` in dev, production domain in prod) - ---- - -## Key Implementation Decisions & Gotchas - -### `item_type` must be `VARCHAR(20)`, NOT `db.Enum(ItemType)` - -Using `db.Enum(ItemType)` with a `str`-based Python enum caused SQLAlchemy to lazy-load -the column back as a raw string after commit, making `self.item_type.value` raise -`AttributeError`. Changed to `db.String(20)` — stored and read as plain string. - -### `INTEGER(unsigned=True)` requires MySQL dialect type - -`db.Column(db.Integer, unsigned=True)` raises `TypeError`. Must use: - -```python -from sqlalchemy.dialects.mysql import INTEGER -db.Column(INTEGER(unsigned=True), ...) -``` - -### Unlock overlay must use `open` class, not `hidden` - -`.modal-overlay` CSS defaults to `display: none`; only `.modal-overlay.open` shows the overlay. -`showUnlockOverlay()` must call `classList.add('open')`, NOT `classList.remove('hidden')`. - -### No inline scripts or style attributes (CSP) - -`Content-Security-Policy: script-src 'self'; style-src 'self'` blocks all inline JS and -`style="..."` attributes. All logic must live in `.js` files; visibility toggled via CSS classes. - -### VaultSession key lost on page reload - -`window.location.href = '/vault'` causes a full page reload, clearing all JS memory. -Solution: store `enc_key_salt` in `sessionStorage` after login; show unlock overlay on -vault page load so user can re-derive the vault key without a server round-trip. - -### `reset_db.py` for schema changes (Windows, no flask db CLI) - -`flask db upgrade` is unreliable on Windows. Use `reset_db.py` instead: - -```bash -python reset_db.py -``` - -This disables MySQL foreign key checks, drops all tables, re-enables checks, then -calls `db.create_all()`. Must use `engine.connect()` to keep FK_CHECKS on the same connection. - -**Production schema changes:** Use Flask-Migrate (`flask db migrate` / `flask db upgrade`). -The migration in `migrations/versions/71d7158dd3b9_*.py` creates `audit_logs` and fixes -`sharing_public_key` column type from `TEXT` to `VARCHAR(128)`. - -### All datetimes are naive UTC (`datetime.utcnow()`) - -All models and services use naive UTC datetimes consistently. Do NOT mix in -`datetime.now(timezone.utc)` (aware datetimes) — SQLAlchemy + PyMySQL + MySQL all work -with naive datetimes; mixing causes comparison failures in token blacklist and emergency -access wait-timer checks. - -### Emergency access wait timer uses `total_seconds()`, not `.days` - -`timedelta.days` truncates sub-day precision. Use `total_seconds() / 86400` for accurate -remaining-time calculations (both in `emergency.py` and `vault.js`). - -### TOTP secret is encrypted server-side (AES-256-GCM) - -`totp_secret` in the `users` table stores **AES-256-GCM ciphertext** (base64), not the raw base32 secret. -`totp_iv` stores the corresponding 12-byte nonce (base64). The server-side key is a 32-byte value -stored as a 64-char hex string in `TOTP_ENCRYPTION_KEY` (`.env`). - -Encryption/decryption is handled exclusively by `auth_service.encrypt_totp_secret()` and -`auth_service.decrypt_totp_secret()`. The plaintext secret exists in memory only for the -duration of the request that verifies a TOTP code. - -**Generating the key:** - -```bash -python -c "import secrets; print(secrets.token_hex(32))" -``` - -**Migration path for existing deployments:** Run migration `a1b2c3d4e5f6` to widen -`totp_secret` to `VARCHAR(255)` and add `totp_iv`, then run `python scripts/reencrypt_totp_secrets.py` -to re-encrypt any existing plaintext secrets atomically. - -### AuditLog must flush before log, then commit together - -When creating/updating/deleting a resource, always call `db.session.flush()` first -to populate the resource's `id`, then call `AuditLog.log(...)`, then `db.session.commit()`. -This ensures the audit row and the business-logic change are committed atomically. - -```python -db.session.add(item) -db.session.flush() # populates item.id -AuditLog.log( - user_id=g.current_user_id, - action='vault_item.create', - resource_type='vault_item', - resource_id=item.id, # now available - ... -) -db.session.commit() # commits both together -``` - -For deletes, capture the `id` before the delete flush, since SQLAlchemy may clear it: - -```python -item_id = item.id -item_name = item.name -db.session.delete(item) -db.session.flush() -AuditLog.log(..., resource_id=item_id, detail=f'Deleted item: "{item_name}"') -db.session.commit() -``` - -### Extension vault key must be extractable (unlike web app) - -The web app uses `extractable: false` for the vault `CryptoKey` — raw bytes can never be -read back. The extension must use `extractable: true` so the key can be serialised as JWK -and stored in `chrome.storage.session` (survives popup close; cleared when the browser -closes). `extension/shared/crypto.js` diverges from `app/static/js/crypto.js` on this point. - -### Extension API_BASE targets production - -`extension/popup/popup.js` has: - -```js -const API_BASE = "https://pwkeeper.ngodanguyen.tech"; -const VAULT_URL = "https://pwkeeper.ngodanguyen.tech/vault"; -``` - -Update these constants if the server URL changes. The bridge content script in -`manifest.json` also targets this domain via `matches`. - ---- - -## Extension Storage Architecture — Critical Rules - -Understanding which storage area to use, and why, is the single most important thing when -working on the extension. Getting this wrong causes silent failures that are very hard to -diagnose. - -### Storage area decision table - -| Data | Where stored | Why | -|---|---|---| -| `access_token`, `vault_key_jwk`, `vault_items` | `chrome.storage.session` | Cleared on browser close (security). Popup and background SW can read it. | -| `refresh_token`, `enc_key_salt`, `pending_save`, `vault_items_cs` | `chrome.storage.local` | Persists across browser restarts and SW termination. Readable by content scripts on ALL Chrome versions. | - -### `chrome.storage.session` is NOT readable by content scripts on Chrome ≤ 110 - -`chrome.storage.session` access for content scripts was only added in **Chrome 111 (March -2023)**. On Chrome 110 and below, calling `chrome.storage.session.get()` from a content -script silently returns `undefined` — it does not throw. This is the root cause of the -autofill suggestion dropdown always showing "No saved passwords" even when the vault was -unlocked and the badge showed a match count. - -**Rule: never read `chrome.storage.session` from a content script.** Use -`chrome.storage.local` instead. - -### `vault_items_cs` — the content-script-safe vault cache - -`fetchAndDecryptVault()` in `popup.js` writes two copies of the decrypted items: - -1. `chrome.storage.session` key `vault_items` — full items including `enc_data`/`iv`, used - by the popup for rendering and by the background badge counter. -2. `chrome.storage.local` key `vault_items_cs` — lightweight copy (strips `enc_data`/`iv`, - keeps `id`, `name`, `item_type`, `plain`) for content script consumption. - -`vault_items_cs` is cleared on sign-out via `chrome.storage.local.remove(['refresh_token', -'enc_key_salt', 'vault_items_cs'])`. - -### `chrome.storage.onChanged` is the reliable push mechanism for content scripts - -Content scripts react to vault updates via `chrome.storage.onChanged`, not via runtime -messages. This listener fires in the **same event loop tick** as the `set()` call — no -message delivery, no service worker intermediary, no race condition: - -```js -chrome.storage.onChanged.addListener(function(changes, area) { - if (area === 'local' && changes.vault_items_cs) { - var allItems = changes.vault_items_cs.newValue || []; - _matchingItems = _filterForHost(allItems); - // re-decorate fields... - } -}); -``` - -### Why `chrome.runtime.sendMessage` from popup cannot reliably reach content scripts - -The message path is: popup → `chrome.runtime.sendMessage` → background SW → -`chrome.tabs.sendMessage` → content script. This chain has two points of failure: - -1. **SW may be killed between popup open and message send.** Chrome MV3 service workers are - terminated aggressively. If the SW dies after `fetchAndDecryptVault()` starts but before - the `sendMessage` call, the message is silently dropped (`.catch(() => {})` hides this). -2. **`chrome.tabs.sendMessage` on Windows Chrome can silently fail** for tabs on domains not - listed in `host_permissions`, even when the content script is already running. - -`VAULT_UPDATED` is still sent as a best-effort mechanism (background forwards it to all -tabs), but the `storage.onChanged` listener is the guaranteed path. Never rely solely on -runtime messages for content script data delivery. - -### `pending_save` must be stored in `chrome.storage.local`, not `chrome.storage.session` - -`chrome.storage.session` is wiped when the MV3 service worker is killed (which happens -within seconds of inactivity). Storing `pending_save` there means the save prompt disappears -before the user sees it. - -`background.js` writes `pending_save` to `chrome.storage.local` on `SAVE_CREDENTIALS`. -`popup.js` `checkPendingSave()` reads and removes it from `chrome.storage.local`. The prompt -survives SW restarts and reappears every popup open until the user acts on it. - -### Service-worker keepalive prevents mid-session `chrome.storage.session` wipes - -The popup sends a `KEEPALIVE` message to the background every 20 seconds via `setInterval`. -The background handler is a no-op (`sendResponse({ ok: true })`), but receiving the message -prevents Chrome from marking the SW as idle and terminating it — which would wipe -`chrome.storage.session` (`access_token`, `vault_key_jwk`, etc.) mid-session. - -### Save-prompt must be a blocking modal overlay, not an inline banner - -The popup window closes instantly when the user clicks anywhere outside it — Chrome -extension constraint, no override possible. Any inline banner inside the popup disappears -with it. The save-prompt is a full-screen dimmed modal overlay (`position: fixed; inset: 0`) -with a centred card, **no ✕ button and no backdrop click handler**. The only exits are -"Save" and "Not now". `checkPendingSave()` uses `.onclick` assignment (not -`.addEventListener`) to prevent duplicate handler bindings across multiple popup opens. - -### "All relevant" tab filters to current domain only — no fallback to all items - -`getTabItems()` for `_activeTab === 'relevant'` does a single filter: -```js -items = items.filter(isMatch).sort((a, b) => a.name.localeCompare(b.name)); -``` -There is no fallback to all items. When empty, `renderList()` shows -`"No saved passwords for github.com."` using `currentHostname()`. - -### Icon button is injected with `position:fixed` outside the field's DOM subtree - -The autofill icon is **not** wrapped around the input field or inserted as a sibling. It is -appended to `document.body` with `position: fixed` and tracked to the field's coordinates -via `getBoundingClientRect()` plus `scroll`/`resize` listeners. This avoids breaking flex/ -grid layouts, React-controlled inputs, and sites with strict CSS selectors. The field's -padding-right is **not** modified. - -### `decorateField()` uses AbortController to cancel stale listeners on re-decoration - -Each call to `decorateField(field, pwField)` creates an `AbortController`, stores it in -`_fieldAbortMap` (a `WeakMap`), and passes `abortSignal` to all `addEventListener` calls. -When `VAULT_UPDATED` fires and fields are re-decorated, `ac.abort()` is called first — this -atomically cancels all focus/input/blur/scroll/resize listeners and removes the icon button -via its own `abortSignal.addEventListener('abort', ...)` cleanup handler. - -**Never close event listeners over the `items` parameter at decoration time.** All -`showDropdown` calls read `_matchingItems` at call time — the module-level variable that is -always current. Closing over a snapshot of items at decoration time was the root cause of -the "stale closure" bug where the dropdown showed empty results after the vault was unlocked. - -### Suggestion dropdown uses `mousedown`, not `click`, for autofill rows - -`click` fires after `blur`. If the user clicks a dropdown row, the field fires `blur` first, -which triggers the outside-click handler and removes the dropdown before `click` fires — -the autofill never happens. `mousedown` fires before `blur`. Combined with -`e.preventDefault()`, this keeps the field focused long enough to fill both username and -password fields. - -### `showDropdown` signature — no `items` parameter - -```js -async function showDropdown(anchorField, pwField, filterText, panel) -``` - -`items` is **not** a parameter. The function reads `_matchingItems` directly (module-level, -always fresh). `panel` is `'credentials'` or `'more'`. The "More options" panel is a second -screen within the same dropdown element, navigated via Back/More options rows. - -### `OPEN_VAULT` and `OPEN_GENERATOR` messages must be handled in background.js - -Content scripts cannot call `chrome.tabs.create` or `chrome.action.openPopup` directly. -These actions require the background service worker. The content script sends the message, -background handles it. `OPEN_GENERATOR` stores `popup_nav: 'generator'` in session storage -and calls `chrome.action.openPopup()`; the popup reads and clears this flag on `init()`. - -### `isVisible()` replaces `offsetParent` check for field detection - -`el.offsetParent === null` returns true (appears invisible) for elements inside -`position: fixed` containers, which is common on modern login forms. `isVisible()` uses -`getBoundingClientRect()` + `getComputedStyle()` checks instead, correctly detecting all -visible fields regardless of their positioning context. - -### `findUsernameField()` has a two-pass fallback - -1. Walk backwards through all inputs in DOM order before `pwField`, return first - `email`/`text`/`tel` input that is visible and enabled. -2. If not found, search within the nearest `
` or `[role="form"]` ancestor. - -This handles SPAs where the username and password fields are not sequential siblings in the -flat DOM. - -### TOTP for vault items — stored in the encrypted `plain` blob, not in the DB schema - -Per-site TOTP secrets are stored as `plain.totp_uri` inside the AES-256-GCM encrypted vault item blob. **No database schema change is required** — the server never sees the secret. This is distinct from the user's account MFA (`users.totp_secret`), which is a server-side AES-256-GCM encrypted field used for extension/web-app login verification. - -`totp_uri` accepts two formats: -- Full `otpauth://totp/...` URI (output from QR code scanner apps like Aegis) -- Plain base32 secret (e.g. `JBSWY3DPEHPK3PXP`) - -`extractTotpSecret()` handles both. The plain object for a password item is: -```js -{ url, username, password, totp_uri, notes } -``` - -### TOTP code generation — pure Web Crypto, no library - -The RFC 6238 TOTP implementation is ~40 lines using `crypto.subtle.sign('HMAC', ...)` with SHA-1. Key steps: -1. Decode base32 secret → `Uint8Array` via `base32ToBytes()` -2. Compute counter = `Math.floor(Date.now() / 1000 / 30)` written as big-endian 64-bit -3. Import key bytes, sign counter with HMAC-SHA-1 -4. Dynamic truncation: `offset = sig[19] & 0x0f`, extract 4 bytes, mask high bit, mod 1,000,000 -5. Left-pad to 6 digits - -Both web app (`vault.js`) and extension (`popup.js`) carry identical copies of this logic. Do not introduce a dependency — the implementations are intentionally self-contained. - -### TOTP tickers must be cleared on every list re-render - -Each vault item with a `totp_uri` starts a `setInterval(tick, 1000)`. If `renderList()` re-renders without clearing them first, intervals accumulate indefinitely — one per render per TOTP item. Always call `_clearTotpTickers()` at the top of `renderList()` (extension) and store the interval IDs in `_totpIntervals`. In the web app, the interval ID is stored in `li.dataset.totpInterval` and cleared when items are re-rendered. Use `codeEl.isConnected` inside the tick callback to bail out gracefully if the element was removed mid-interval. - -### Vault item CSS was entirely missing from `app.css` - -The vault item classes (`vault-item`, `item-name`, `item-sub`, `item-info`, `item-icon`, `item-actions`) had **no CSS rules** in `app.css` — the items rendered through browser defaults and inherited flexbox from parent containers. They appear visually correct only because the HTML structure is clean. When adding TOTP, the relevant vault item rules (`vault-item`, `item-name`, `item-sub`, etc.) were added at the same time. If vault item layout ever breaks, check that these rules are present in `app.css` starting after the `btn-icon[data-action="launch"]` rule. - -### `save_blocklist` — per-site never-save list - -Stored in `chrome.storage.local` as `save_blocklist: string[]` (array of hostnames). Written by the "Never for this site" button in the save banner (`addToBlocklist(hostname)`). Checked at the very start of `watchSubmissions` submit handler via `isBlocked(hostname)` before any credential classification. No UI yet to view or remove entries — manageable via `chrome://extensions/` → PassKeeper → "Inspect views: service worker" → Application → Local Storage as a stop-gap. - -### `_addViewInitialised` guard prevents duplicate event listener binding - -`initAddView()` is called every time the user opens the Add Item view. Event listeners (back, toggle, generate, save, Enter) are bound only once via the `_addViewInitialised` boolean. Fields (except URL and site name, which are pre-filled from the active tab) are cleared on each open. The URL/name pre-fill runs every open via `chrome.tabs.query`. - -### Load extension in Chrome for development - -1. Open `chrome://extensions/` -2. Enable **Developer mode** (top-right toggle) -3. Click **Load unpacked** → select `extension/` folder -4. Reload after any JS/CSS changes; no reload needed for icon or manifest changes. -5. Check content script logs in the **page's** DevTools console (not the popup's console). -6. Check background SW logs via `chrome://extensions/` → PassKeeper → "Service Worker" link. - -### Do NOT use `exclude_matches` with `chrome://` or `chrome-extension://` schemes - -These schemes are invalid in `exclude_matches` and will prevent the extension from loading. -They are already excluded implicitly since `matches` only lists `http://*/*` and -`https://*/*`. - -### Autofill uses native HTMLInputElement setter for framework compatibility - -React/Vue/Angular intercept `el.value = x` but not the native property setter. -The content script uses `Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set` -to set the value, then dispatches `input` + `change` events so frameworks detect the change. - -### Load extension in Chrome for development - -1. Open `chrome://extensions/` -2. Enable **Developer mode** (top-right toggle) -3. Click **Load unpacked** → select `extension/` folder -4. Reload after any JS/CSS changes; no reload needed for icon or manifest changes. - -### Do NOT use `exclude_matches` with `chrome://` or `chrome-extension://` schemes - -These schemes are invalid in `exclude_matches` and will prevent the extension from loading -(`Invalid scheme` error). They are already excluded implicitly since `matches` only lists -`http://*/*` and `https://*/*`. Remove the `exclude_matches` field entirely. - -### Autofill uses native HTMLInputElement setter for framework compatibility - -React/Vue/Angular intercept `el.value = x` but not the native property setter. -The content script uses `Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set` -to set the value, then dispatches `input` + `change` events so frameworks detect the change. - -### JWT `sub` claim must be a string (PyJWT 2.x) - -PyJWT >= 2.0 enforces that the `sub` (subject) claim must be a string. Passing an integer -`user_id` directly causes `InvalidClaimError: Subject must be a string` on decode, even -though encoding succeeds silently. Always convert: `'sub': str(user_id)` when generating -tokens, and `int(payload['sub'])` when reading it back for DB lookups. - -### Do not call `loadFolders()` unconditionally in vault.js `init()` - -`loadFolders()` was previously called at the top of `init()` before the vault key check. -This triggered an API call even when the unlock overlay was showing. If the token was -invalid for any reason, `tryRefreshToken()` ran immediately and redirected to login before -the user could enter their unlock password. `loadFolders()` is already included inside -`loadVault()` via `Promise.all` — do not add a separate call at init. - -### Extension SSO bridge — session shared between web app and extension - -`extension/bridge/bridge.js` is a content script that runs **only** on `pwkeeper.ngodanguyen.tech`. -It bridges sessions in both directions: - -- **Web app → Extension:** `auth.js` dispatches `passkeeper:session` custom event after - login; bridge forwards `access_token`, `refresh_token`, `enc_key_salt` to the background - via `chrome.runtime.sendMessage({ type: 'WEB_SESSION_SYNC', ... })`. Background stores - `access_token` + `enc_key_salt` to `chrome.storage.session` and `refresh_token` + - `enc_key_salt` to `chrome.storage.local` (local persists across browser restarts). - When the extension popup opens it detects tokens without a vault key and shows an - **unlock-only** view (master password only, no email). - -- **Extension → Web app (new tab):** On every page load bridge.js checks whether the web - page has an `access_token` in `sessionStorage`. If not, it reads `refresh_token` + - `enc_key_salt` from `chrome.storage.local`, calls `/api/auth/refresh` to obtain a - **fresh** `access_token`, injects all three into the page's `sessionStorage`/`localStorage`, - and dispatches `passkeeper:ext-login`. This is the primary path when the user opens the - vault URL from the extension popup. - -- **Extension → Web app (already-open tab):** After popup login, popup sends - `EXT_SESSION_SYNC` to background; background finds open vault tabs and sends - `INJECT_SESSION` to the bridge; bridge writes tokens and dispatches `passkeeper:ext-login`. - -- **Logout is shared:** `vault.js` `redirectToLogin()` and `handleLogout()` both dispatch - `passkeeper:logout`; bridge forwards `WEB_SESSION_CLEAR` to background, which clears all - extension storage. - -The popup has three auth views: `view-login` (full login), `view-mfa` (TOTP), and -`view-unlock` (master password only, used when tokens already exist from the web app). - -### vault.js init() waits for extension bridge before redirecting - -`vault.js` `init()` is `async`. If `sessionStorage` has no `access_token` on page load, -it waits up to 800 ms for a `passkeeper:ext-login` event (fired by bridge.js after it -injects fresh tokens). Only if no event arrives within the timeout does it redirect to -`/login`. This prevents the race where vault.js redirected before bridge.js finished its -async `chrome.storage` read and token refresh. - -### tryRefreshToken is a singleton in vault.js - -`vault.js` `loadVault()` calls `Promise.all([apiFetch('/api/vault'), apiFetch('/api/folders')])`. -If the `access_token` is expired both requests get 401 simultaneously and both would call -`tryRefreshToken()` — the second call would send the already-rotated (blacklisted) refresh -token and trigger `redirectToLogin()`. `tryRefreshToken` uses a module-level `_refreshPromise` -so all concurrent callers share one in-flight request and receive the same result. - -### Sharing private key never leaves the client unencrypted - -The ECDH P-256 private key (JWK) is encrypted with the vault key (AES-256-GCM) before -being sent to the server. Stored as `sharing_private_key_enc` + `sharing_private_key_iv`. -The server stores only the encrypted JWK — it cannot derive the ECDH shared secret. - -### Emergency access status machine +All `/api/vault/*` and `/api/folders/*` routes require `Authorization: Bearer `. ``` -invited → (grantee /accept) → accepted -accepted → (grantor /provide) → ready -ready → (grantor /provide) → ready (grantor can re-upload snapshot any time) -ready → (grantee /request) → pending (wait timer starts) -pending → (grantor /deny) → ready (grantee may re-request) -pending (wait_days elapsed) → wait_elapsed=true, grantee may call /vault -``` +POST /api/auth/register +POST /api/auth/login +POST /api/auth/logout +POST /api/auth/refresh +GET /api/auth/me +GET /api/auth/mfa/status +GET /api/auth/mfa/setup +POST /api/auth/mfa/enable +POST /api/auth/mfa/disable +POST /api/auth/mfa/verify +POST /api/auth/change-password # { current_auth_hash, new_auth_hash, new_enc_key_salt, + # items: [{id, enc_data, iv, enc_name?, iv_name?}] } +DELETE /api/auth/account +POST /api/auth/recovery/setup +GET /api/auth/recovery/status +GET /api/auth/recovery/data +GET /api/auth/recovery/items +POST /api/auth/recover -Duplicate active invitations are blocked: a new invitation is rejected if an existing -record exists with status in `['invited', 'accepted', 'ready', 'pending']` for the same -grantor/grantee pair. +GET /api/vault +POST /api/vault # { name, item_type, folder_id, enc_data, iv, enc_name?, iv_name? } +GET /api/vault/ +PUT /api/vault/ # accepts enc_name, iv_name +DELETE /api/vault/ -### TokenBlacklist opportunistic cleanup +GET /api/folders +POST /api/folders +PUT /api/folders/ +DELETE /api/folders/ -`blacklist_token()` in `auth_service.py` calls `TokenBlacklist.cleanup_expired()` after -each blacklist write. This deletes rows where `expires_at <= now()` in the same DB -session. This keeps the table manageable without a separate scheduled job. +GET /api/sharing/keys +POST /api/sharing/keys +GET /api/sharing/public-key +GET /api/sharing +POST /api/sharing +DELETE /api/sharing/ +GET /api/sharing/inbox +POST /api/sharing/inbox//accept -### Failed login attempts are audited - -`auth.py` `login()` logs `auth.login_failed` to `audit_logs` when the password check fails -for a known email. Unknown emails are not logged (no `user_id` to attach to). The 0.1 s -`time.sleep()` before the user lookup mitigates timing-based user enumeration regardless. - -### `wait_days` input is guarded against non-numeric values - -`emergency.py` `create_emergency()` wraps `int(data.get('wait_days', 7))` in -`try/except (TypeError, ValueError)` and returns HTTP 400 on invalid input. Without this, -a non-numeric string would raise an unhandled `ValueError` and return a 500. - -### Nginx upstream block — do not proxy_pass directly to 127.0.0.1:5000 - -The Nginx config uses an `upstream passkeeper_app { server 127.0.0.1:5000; keepalive 32; }` -block rather than `proxy_pass http://127.0.0.1:5000` directly. This enables: - -- **`proxy_next_upstream`** — on `error`, `timeout`, `http_502`, or `http_503`, Nginx retries - once within 10 s, bridging the brief window when Gunicorn is restarting. -- **`keepalive 32`** — connection pool reuse between Nginx and Gunicorn workers. -- **Proxy timeouts** — `proxy_connect_timeout 5s`, `proxy_read_timeout 30s` cap hung connections. - -Never revert to direct `proxy_pass http://127.0.0.1:5000` — it loses all retry capability. - -### Gunicorn must run with `--preload` - -Without `--preload`, each of the 4 workers independently imports the full Flask app on startup. -If one worker crashes during app initialization (e.g. a transient DB connection failure at boot), -it dies silently while Gunicorn reports healthy — resulting in fewer active workers and -`Connection refused` errors under concurrent load. - -With `--preload`, the master process loads the app once and forks workers from the snapshot. -Worker crashes are isolated; the master immediately spawns a replacement without re-importing -the app. This also reduces per-worker memory footprint via copy-on-write. - -**Trade-off:** `--preload` means app code is loaded before workers fork, so code that opens -connections in module scope (not recommended) will share file descriptors across workers. -PassKeeper uses Flask-SQLAlchemy's connection pool which is fork-safe by default. - -### `ProtectSystem=strict` is NOT used in the systemd service - -`ProtectSystem=strict` makes the entire filesystem read-only except for paths listed in -`ReadWritePaths`. Any path that Gunicorn, SQLAlchemy, or the OS touches at runtime that -is missing from that list causes a **silent startup failure** — the service starts, systemd -reports it active, but Gunicorn cannot bind or write logs, producing `Connection refused`. -`PrivateTmp=true` and `NoNewPrivileges=true` are retained as they carry no such risk. - -### Change password is a single atomic operation - -`POST /api/auth/change-password` accepts the new credentials AND a full array of all -re-encrypted vault items in one request. The server updates `master_hash`, `enc_key_salt`, -and every `vault_items` row in a single transaction with rollback on any error. This -prevents the vault ever being in a split-encrypted state (some items with old key, some -with new). The recovery code is also cleared atomically — it was encrypted with the old -vault key and is now invalid. - -### Recovery code is one-time use - -`POST /api/auth/recover` clears `recovery_enc_salt` and `recovery_iv` after a successful -recovery. The recovery code cannot be reused. The user must generate a new one from -Account Settings after logging in. This prevents replay attacks if a recovery code is -ever exposed. - -### Recovery proof — how the server verifies the recovery code without knowing it - -The server cannot verify the recovery code directly (it never stores it). Verification -works via a proof: the client decrypts `recovery_enc_salt` using the recovery key — if -the code is wrong, AES-GCM authentication tag verification fails client-side. The client -then sends `recovery_proof = decrypted_enc_key_salt` (the plaintext). The server checks -`recovery_proof == user.enc_key_salt`. If correct, the client demonstrably had the right -recovery code. This is the same pattern as a HMAC proof without needing a shared secret. - -### Sidebar collapsed state persists in `localStorage` - -`vault.js` reads `localStorage.getItem('sidebar_collapsed')` on every page load and -applies `.collapsed` before paint — no flash of expanded sidebar. The toggle button is -always visible and clickable because `.sidebar.collapsed .sidebar-header` uses -`justify-content: center` with the logo icon/text set to `width: 0; pointer-events: none`. - -### Mobile sidebar uses CSS transform, not display:none - -The mobile sidebar overlay uses `transform: translateX(-100%)` → `transform: translateX(0)` -rather than `display: none` toggle. This allows the CSS transition to animate the slide-in. -The `.mobile-open` class is added by JS; the backdrop (`#sidebar-backdrop`) receives -`.open` simultaneously. `document.body.style.overflow = 'hidden'` prevents the page from -scrolling while the sidebar is open. - -### Vault list content width is constrained by a wrapper div, not the `
    ` itself - -The `#vault-list` `
      ` element is the target JS renders vault items into — its ID must -not change. The max-width constraint lives on a parent `.vault-list-inner` div. The scroll -container is `.vault-list` (the grandparent). Do not apply `max-width` directly to -`#vault-list` or move items to a different container — JS will stop finding them. - -```html -
      - -
      - -
        - -
        -
        +GET /api/emergency +POST /api/emergency +DELETE /api/emergency/ +POST /api/emergency//accept +POST /api/emergency//provide +POST /api/emergency//request +POST /api/emergency//deny +GET /api/emergency//vault ``` --- @@ -891,29 +545,12 @@ container is `.vault-list` (the grandparent). Do not apply `max-width` directly ## Development Setup (Windows) ```bash -# 1. Create virtual environment python -m venv .venv .venv\Scripts\activate - -# 2. Install dependencies pip install -r requirements.txt - -# 3. Configure environment -copy .env.example .env -# Edit .env: set MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DB, SECRET_KEY, JWT_SECRET_KEY - -# 4. Create MySQL database +# Edit .env: MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DB, SECRET_KEY, JWT_SECRET_KEY, TOTP_ENCRYPTION_KEY mysql -u root -p -e "CREATE DATABASE passkeeper CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" - -# 5. Create user and grant privileges -CREATE USER 'user'@'host' IDENTIFIED BY 'password'; -GRANT ALL PRIVILEGES ON passkeeper.* TO 'user'@'host'; -FLUSH PRIVILEGES; - -# 6. Create tables python reset_db.py - -# 7. Run dev server python run.py ``` @@ -921,370 +558,15 @@ python run.py ## Production Deployment (Ubuntu + Nginx + Gunicorn + systemd) -### 1. Server Setup - -```bash -sudo apt update && sudo apt install python3.12 python3.12-venv python3-pip \ - mysql-server nginx certbot python3-certbot-nginx -``` - -### 2. App Deployment - -```bash -cd /var/www/passkeeper -python3.12 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -cp .env.example .env # fill in production values -python reset_db.py # first deploy only -``` - -For subsequent schema changes, use Flask-Migrate: - ```bash +# Schema migrations (all subsequent deployments) flask db upgrade + +# Reload app +sudo systemctl reload passkeeper ``` -### 3. Gunicorn test - -```bash -gunicorn --workers 4 --bind 127.0.0.1:5000 wsgi:app -``` - -### 4. systemd Service (`/etc/systemd/system/passkeeper.service`) - -```ini -[Unit] -Description=PassKeeper Gunicorn daemon -After=network.target mysql.service -Wants=mysql.service -StartLimitIntervalSec=60 -StartLimitBurst=5 - -[Service] -User=spuser -Group=www-data -WorkingDirectory=/home/spuser/PassKeeper -EnvironmentFile=/home/spuser/PassKeeper/.env -ExecStart=/home/spuser/.venv/bin/gunicorn \ - --workers 4 \ - --bind 127.0.0.1:5000 \ - --preload \ - --timeout 30 \ - --graceful-timeout 20 \ - --keep-alive 5 \ - --access-logfile /home/spuser/logs/access.log \ - --error-logfile /home/spuser/logs/error.log \ - --log-level warning \ - wsgi:app -ExecReload=/bin/kill -s USR2 $MAINPID -WatchdogSec=60s -Restart=on-failure -RestartSec=5s -PrivateTmp=true -NoNewPrivileges=true - -[Install] -WantedBy=multi-user.target -``` - -```bash -sudo systemctl daemon-reload -sudo systemctl enable passkeeper -sudo systemctl start passkeeper -journalctl -xeu passkeeper.service -``` - -### 5. Nginx Config (`/etc/nginx/sites-available/passkeeper`) - -```nginx -limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m; -limit_req_zone $binary_remote_addr zone=api_limit:10m rate=60r/s; - -upstream passkeeper_app { - server 127.0.0.1:5000; - keepalive 32; -} - -server { - server_name pwkeeper.ngodanguyen.tech passkeeper.ngodanguyen.tech; - - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; - add_header X-Frame-Options "DENY" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always; - add_header Content-Security-Policy - "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';" - always; - - server_tokens off; - client_max_body_size 1m; - - proxy_connect_timeout 5s; - proxy_read_timeout 30s; - proxy_send_timeout 30s; - proxy_next_upstream error timeout http_502 http_503; - proxy_next_upstream_tries 2; - proxy_next_upstream_timeout 10s; - - location ~ ^/api/auth/(login|register|mfa/verify) { - limit_req zone=auth_limit burst=5 nodelay; - limit_req_status 429; - proxy_pass http://passkeeper_app; - proxy_http_version 1.1; - proxy_set_header Connection ""; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location /static/ { - alias /home/spuser/PassKeeper/app/static/; - expires 30d; - add_header Cache-Control "public, immutable"; - } - - location / { - limit_req zone=api_limit burst=20 nodelay; - limit_req_status 429; - proxy_pass http://passkeeper_app; - proxy_http_version 1.1; - proxy_set_header Connection ""; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - listen 443 ssl; # managed by Certbot - ssl_certificate /etc/letsencrypt/live/pwkeeper.ngodanguyen.tech/fullchain.pem; # managed by Certbot - ssl_certificate_key /etc/letsencrypt/live/pwkeeper.ngodanguyen.tech/privkey.pem; # managed by Certbot - include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot -} - -server { - if ($host = pwkeeper.ngodanguyen.tech) { - return 301 https://$host$request_uri; - } # managed by Certbot - listen 80; - server_name pwkeeper.ngodanguyen.tech passkeeper.ngodanguyen.tech; - return 404; # managed by Certbot -} -``` - -```bash -sudo ln -s /etc/nginx/sites-available/passkeeper /etc/nginx/sites-enabled/ -sudo certbot --nginx -d yourdomain.com -sudo nginx -t && sudo systemctl reload nginx -``` - ---- - -## REST API Endpoints - -All `/api/vault/*` and `/api/folders/*` routes require `Authorization: Bearer `. -All API blueprints are exempt from CSRF (JWT auth makes it unnecessary). - -``` -POST /api/auth/register # { email, auth_hash, enc_key_salt } -POST /api/auth/login # { email, auth_hash } → { access_token, refresh_token, enc_key_salt } - # or → { mfa_required: true, mfa_token, enc_key_salt } if TOTP enabled -POST /api/auth/logout # blacklists both tokens -POST /api/auth/refresh # { refresh_token } → { access_token, refresh_token } (rotates) -GET /api/auth/me # → { id, email, created_at, last_login, totp_enabled, recovery_configured } -GET /api/auth/mfa/status # → { totp_enabled } -GET /api/auth/mfa/setup # → { secret, qr_code, uri } (generates TOTP secret, not stored yet) -POST /api/auth/mfa/enable # { secret, totp_code } — verifies first code & stores AES-256-GCM encrypted secret -POST /api/auth/mfa/disable # { totp_code } — verifies & clears secret -POST /api/auth/mfa/verify # { mfa_token, totp_code } → { access_token, refresh_token } -POST /api/auth/change-password # { current_auth_hash, new_auth_hash, new_enc_key_salt, items: [{id,enc_data,iv}] } -DELETE /api/auth/account # { auth_hash } — permanently deletes account + all data (FK cascade) -POST /api/auth/recovery/setup # { recovery_enc_salt, recovery_iv } — store recovery blob -GET /api/auth/recovery/status # → { recovery_configured } -GET /api/auth/recovery/data # ?email= → { enc_key_salt, recovery_enc_salt, recovery_iv } (unauthenticated) -GET /api/auth/recovery/items # ?email= + X-Recovery-Proof header → { items: [{id,enc_data,iv}] } (unauthenticated) -POST /api/auth/recover # { email, new_auth_hash, new_enc_key_salt, recovery_proof, items } (unauthenticated) - # → { access_token, refresh_token, enc_key_salt } - -GET /api/vault # list all items (encrypted blobs) -POST /api/vault # { name, item_type, folder_id, enc_data, iv } -GET /api/vault/ -PUT /api/vault/ -DELETE /api/vault/ - -GET /api/folders -POST /api/folders # { name } -PUT /api/folders/ # { name } -DELETE /api/folders/ - -GET /api/sharing/keys # → { keys_setup, public_key, private_key_enc, private_key_iv } -POST /api/sharing/keys # { public_key, private_key_enc, private_key_iv } -GET /api/sharing/public-key # ?email=… → { user_id, email, public_key } -GET /api/sharing # list outgoing shares -POST /api/sharing # { item_id, recipient_email, enc_data, iv, item_name, item_type } -DELETE /api/sharing/ # revoke share -GET /api/sharing/inbox # list incoming shares (includes owner_email, owner_public_key) -POST /api/sharing/inbox//accept - -GET /api/emergency # → { grants: [...], access: [...] } -POST /api/emergency # { grantee_email, wait_days } -DELETE /api/emergency/ -POST /api/emergency//accept # grantee accepts invitation -POST /api/emergency//provide # { enc_vault } — grantor uploads ECDH-encrypted snapshot -POST /api/emergency//request # grantee starts wait timer -POST /api/emergency//deny # grantor denies pending request -GET /api/emergency//vault # grantee fetches vault after wait elapsed → { enc_vault, grantor_public_key } -``` - ---- - -## UI / Feature Spec - -### Left Sidebar - -- [x] All Items -- [x] Item type filters (Passwords, Secure Notes, Payment Cards, Bank Accounts, Addresses, Identities) -- [x] Folder list with create (+) and delete (🗑) per folder -- [x] Sharing Center -- [x] Security Dashboard -- [x] Emergency Access -- [x] Account Settings (MFA + Sharing Keys + Change Password + Recovery Code + Delete Account) -- [x] **Collapsible sidebar** — toggle button collapses to 72px icon rail; state persisted in `localStorage`; tooltips on hover in collapsed state -- [ ] Passkeys - -### Vault Main View - -- [x] Search bar (real-time client-side filter) -- [x] Items grouped by folder -- [x] Floating action button (+ Add Item) -- [x] Item row: copy username, copy password, edit, delete (actions always visible) -- [x] **TOTP live code** — password items with `totp_uri` show a live 6-digit code + countdown timer; 🔐 copy button in teal -- [x] Sort by: Name A–Z / Z–A / Newest / Oldest / By folder -- [x] **Content max-width** — vault list and header capped at 900px (1100px on large PC) to prevent excessive stretching on wide displays -- [ ] Grid / List view toggle - -### Responsive Layout - -| Breakpoint | Behaviour | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Phone ≤ 640px** | Sidebar removed from flow; slides in as overlay via hamburger (☰) in top bar; backdrop closes it; modals become bottom sheets; mobile FAB in top bar | -| **Tablet 641–1024px** | Sidebar in flow at 200px; user can collapse to 72px icon rail | -| **Laptop 1025–1440px** | Full 240px sidebar; standard 24px padding | -| **Large PC > 1440px** | 260px sidebar; 40px padding; content areas widen to 1100px max-width | - -### Item Types & Fields - -| Type | Fields | -| ------------ | --------------------------------------------------------------------------- | -| Password | Name, URL, Username, Password, **TOTP URI (optional)**, Notes, Folder | -| Secure Note | Name, Note body, Folder | -| Address | Name, Title, First/Last name, Company, Address, Phone, Email | -| Payment Card | Name, Card number, Expiry, CVV, Cardholder name | -| Bank Account | Bank name, Account type, Routing number, Account number | -| SSN | Name, Number | -| Passkey | Name, Origin, Credential ID, Public key | - -### Browser Extension (Phase 4) - -- [x] Manifest V3 (Chrome/Edge; Firefox needs minor tweaks) -- [x] Popup: LastPass-style UI — search, tabs (All relevant / All items / Recents), item avatars -- [x] Toolbar popup: search vault, copy password, autofill button per item -- [x] **"All relevant" tab:** shows only items matching the current tab's domain; empty state shows the hostname -- [x] Content script: detect login forms, inject PK icon button into **both** username and password fields -- [x] **Suggestion dropdown:** anchored below the focused field; shows site + username + edit icon; filters as user types; keyboard navigation (↑↓ Enter); "More options" panel; ✓ Filled flash on autofill -- [x] Auto-save banner — **"Never for this site"** button adds domain to persistent blocklist; duplicate detection; blocking modal in popup -- [x] **Inline password generator:** length slider, charset checkboxes, strength indicator, copy + refresh -- [x] **Bottom nav:** Vault / Generator / Alerts / Account -- [x] **Account view:** configurable idle lock timeout (1/5/10/30 min / Never); open web vault; sign out -- [x] **Inline Add Item form** — pre-fills URL + site name from current tab; generate button; folder select; saves directly via API without opening web vault -- [x] **TOTP live display** — items with `totp_uri` show a live 6-digit code + countdown timer in the popup row; one-click copy; ticker clears on re-render -- [x] SSO with web app (login once, shared session via bridge.js) - ---- - -## Build Phases - -### Phase 1 — Core Auth & Vault ✅ COMPLETE - -- [x] User registration / login with Argon2id -- [x] Zero-knowledge vault key derivation (PBKDF2, Web Crypto API) -- [x] AES-256-GCM encrypt/decrypt in browser -- [x] Store/retrieve encrypted password items (CRUD) -- [x] Folder create, delete, filter -- [x] Unlock overlay (re-derive vault key after page reload) -- [x] JWT access + refresh token auth -- [x] Sign out - -### Phase 2 — Full Item Types & UI Polish ✅ COMPLETE - -- [x] All 7 item types (password, note, card, bank, address, SSN, passkey) with type-specific forms -- [x] Security dashboard (weak / reused / old passwords with score) -- [x] Sort by: Name A–Z / Z–A / Newest / Oldest / By folder -- [x] UI polish (emoji icons, sidebar navigation, responsive layout) -- [ ] Grid / List view toggle (deferred) - -### Phase 3 — Sharing & MFA ✅ COMPLETE - -- [x] TOTP-based MFA (Google Authenticator / Authy) — setup, enable, disable, verify -- [x] Item sharing via email — ECDH P-256 zero-knowledge re-encryption -- [x] Sharing inbox — accept, view decrypted shared items (read-only detail modal) -- [x] Emergency access — state machine (invited → accepted → ready → pending → vault access) -- [x] Emergency vault view — collapsible in-page panel with decrypted item fields -- [x] JWT token blacklisting on logout + refresh token rotation -- [x] Account Settings modal (MFA management + sharing key generation) -- [x] **Audit logging** — server-side trail for all create/edit/delete actions - -### Phase 4 — Account Management & UI Polish ✅ COMPLETE - -- [x] **Master password change (6A)** — zero-knowledge: client re-encrypts all vault items with new vault key, submits atomically; server verifies current password; recovery code cleared on change -- [x] **Account deletion (6B)** — requires password confirmation; FK cascade deletes all data; audited -- [x] **Account recovery (6C)** — 128-bit recovery code; `recovery_key = PBKDF2(code, 'passkeeper-recovery', 200k)`; `enc_key_salt` encrypted with recovery key stored server-side; one-time use (consumed on recovery); `/recover` page with two-step flow -- [x] **`GET /api/auth/me`** — lightweight profile endpoint (email, MFA status, recovery status) -- [x] **Collapsible sidebar** — 240px ↔ 72px icon rail; `localStorage` persistence; CSS transitions; tooltips via `::after` pseudo-element; toggle button always accessible -- [x] **Full responsive layout** — phone overlay sidebar + mobile top bar, tablet icon rail, laptop standard, large PC wider content areas; modals as bottom sheets on mobile -- [x] **Vault content max-width** — `.vault-list-inner` + `.vault-header-inner` cap content at 900px (1100px on large PC) to prevent over-stretching on wide displays -- [x] **Account Settings modal** expanded — Change Password, Recovery Code, Danger Zone (Delete Account) sections added - -- [x] Manifest V3 extension (Chrome/Edge; Firefox needs minor tweaks) -- [x] Popup: LastPass-style UI — search bar, tabs (All relevant / All items / Recents), colored avatars -- [x] Popup views: Login → MFA → Unlock-only → Vault → Generator → Account → Add Item -- [x] **Inline password generator** — length slider + charset checkboxes + strength indicator + copy/refresh; no external tab -- [x] **Bottom nav:** Vault / Generator / Alerts / Account — shared across authenticated views -- [x] **Account view** — configurable idle lock (1/5/10/30 min / Never stored in `chrome.storage.local`); open web vault; sign out; setting applied immediately to background service worker via `SET_IDLE_TIMEOUT` message -- [x] **Idle lock** — `chrome.idle` API; `applyIdleInterval()` reads user preference on every SW startup; clears session + `vault_items_cs` on idle/locked; respects "Never" setting -- [x] **Broad host permissions** — `host_permissions: ["http://*/*", "https://*/*"]` so `chrome.tabs.sendMessage` reaches all tabs reliably -- [x] **"All relevant" tab** shows ONLY items matching the current tab's domain; empty state shows hostname -- [x] Badge on toolbar icon showing number of matching vault items for current tab -- [x] Content script: injects `position:fixed` PK icon (outside field DOM) into **both** username and password fields; `isVisible()` handles `position:fixed` containers; `AbortController` per field for clean re-decoration -- [x] **Suggestion dropdown:** anchored below field; site + username + edit pencil; filters as user types; **ArrowUp/Down/Enter keyboard navigation**; "More options" panel (Back / Generate / Open vault); **✓ Filled flash** on autofill; closes on Escape or outside mousedown -- [x] **`vault_items_cs` in `chrome.storage.local`** — content-script-safe cache; `storage.onChanged` listener for instant re-decoration; no `chrome.storage.session` reads in content scripts -- [x] Autofill is React/Vue/Angular compatible (native HTMLInputElement setter + events) -- [x] **Duplicate detection before save banner:** `new` / `updated` / `same`; `same` suppressed silently -- [x] **"Never for this site"** — third button on save banner; adds hostname to `save_blocklist` in `chrome.storage.local`; checked before showing banner on submit -- [x] **Save-prompt modal:** blocking overlay, no auto-dismiss, no ✕; folder dropdown from `/api/folders`; `pending_save` in `chrome.storage.local` -- [x] **Inline Add Item form** — `view-add`; pre-fills URL + site name from active tab; generate button; folder select; `POST /api/vault`; returns to vault on success -- [x] **TOTP live display in popup** — items with `plain.totp_uri` show live 6-digit code + countdown; one-click copy; 1s ticker per item; all tickers cleared on re-render via `_clearTotpTickers()` -- [x] Token refresh + session restore (vault key in `chrome.storage.session`) -- [x] **Service-worker keepalive:** popup pings background every 20 s -- [x] SSO bridge (`bridge.js`): login once on web app; logout synced both ways - -### Phase 5 — Production Hardening ✅ COMPLETE - -- [x] **TOTP secrets encrypted at rest** — AES-256-GCM server-side encryption (`TOTP_ENCRYPTION_KEY`); migration `a1b2c3d4e5f6` + `scripts/reencrypt_totp_secrets.py` -- [x] **Rate limiting fixed** — Redis-backed (`RATELIMIT_STORAGE_URI`), shared across all Gunicorn workers; Nginx dual-zone rate limiting as second layer -- [x] **HTTP security headers** — HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, CSP via `@app.after_request` hook -- [x] **CORS locked** — `CORS_ORIGINS` env var (production: domain only, dev: `*`) -- [x] **CSP via HTTP header** — overrides meta tag, applied to all responses including API -- [x] **Failed login audit** — `auth.login_failed` logged to `audit_logs` on bad password -- [x] **`wait_days` input validation** — `TypeError/ValueError` guard in `emergency.py` -- [x] **Nginx hardening** — `upstream` block, `proxy_next_upstream` retry, proxy timeouts, `server_tokens off`, `client_max_body_size`, `Cache-Control: immutable` on static -- [x] **Gunicorn `--preload`** — single app import, isolated worker crashes, lower memory footprint -- [x] **systemd watchdog** — `WatchdogSec=60s`; `Restart=on-failure` + `RestartSec=5s`; `StartLimitBurst=5`; `PrivateTmp`, `NoNewPrivileges` -- [x] **Automated MySQL backups** — `scripts/backup_db.sh` (gzip, 30-day retention); `scripts/backup.cron` (daily 2 AM) -- [x] **Log rotation** — `scripts/passkeeper-logrotate` (daily, 30-day retention, zero-downtime USR1 signal) +For full initial setup instructions (Nginx config, systemd unit, Certbot, etc.) refer to the original deployment guide in this file's history or `scripts/passkeeper-nginx.conf` / `scripts/passkeeper.service`. --- @@ -1303,26 +585,35 @@ argon2-cffi>=23.1 pyjwt>=2.8 python-dotenv>=1.0 gunicorn>=21.0 -pyotp>=2.9.0 # TOTP MFA (Phase 3) -qrcode[pil]>=7.4.2 # QR code generation for MFA setup (Phase 3) -cryptography>=42.0 # AES-256-GCM server-side TOTP secret encryption (Phase 5) -redis>=5.0 # Shared rate-limit storage across Gunicorn workers (Phase 5) +pyotp>=2.9.0 +qrcode[pil]>=7.4.2 +cryptography>=42.0 # AES-256-GCM server-side TOTP secret encryption +redis>=5.0 # Shared rate-limit storage across Gunicorn workers ``` --- +## Migration History + +| Revision | Description | +|------------------|----------------------------------------------------------| +| `71d7158dd3b9` | Add audit_logs table; fix sharing_public_key column type | +| `a1b2c3d4e5f6` | Encrypt TOTP secret at rest (totp_secret → VARCHAR(255), add totp_iv) | +| `b2c3d4e5f6a7` | Add account recovery columns (recovery_enc_salt, recovery_iv) | +| `c3d4e5f6a7b8` | Encrypt vault item name (add enc_name, iv_name to vault_items) | + +--- + ## Notes - Never log decrypted vault data server-side -- All encryption/decryption of vault data happens in the browser (JavaScript Web Crypto API) -- Flask API handles only encrypted blobs — zero-knowledge server -- Browser extension uses same REST API with JWT bearer tokens -- `enc_key_salt` is not secret on its own — it's only useful combined with the master password -- **TOTP secrets are encrypted at rest** using AES-256-GCM with a server-side key (`TOTP_ENCRYPTION_KEY`). The plaintext secret exists in memory only during TOTP verification. This is the user's **account MFA** secret — distinct from per-site TOTP URIs stored in vault item `plain.totp_uri`. -- **Per-site TOTP URIs (`plain.totp_uri`)** are stored client-side only inside the AES-256-GCM encrypted vault blob. The server never sees them. They are decrypted with the vault key and used client-side to generate 6-digit codes via HMAC-SHA-1 (RFC 6238). -- The `audit_logs` table has no foreign key on `user_id` intentionally — audit records should survive user deletion for forensic purposes -- **Redis is required in production** for rate limiting (`RATELIMIT_STORAGE_URI=redis://127.0.0.1:6379/0`). Without it, each Gunicorn worker maintains its own counter and the effective rate limit is multiplied by the number of workers. -- **Recovery code is never stored server-side** — only `recovery_enc_salt` (AES-256-GCM ciphertext of `enc_key_salt`) and `recovery_iv` are stored. The server cannot derive the recovery code or `enc_key_salt` from these alone. -- **Password change clears the recovery code** — after a master password change, `recovery_enc_salt` and `recovery_iv` are set to NULL. The user must regenerate a recovery code from Account Settings. -- **The `#vault-list` element ID must not be renamed** — `vault.js` renders all vault items directly into `document.getElementById('vault-list')`. The max-width constraint is applied to a wrapper div (`.vault-list-inner`), not to the list itself. -- **`save_blocklist`** in `chrome.storage.local` stores an array of hostnames for which the save banner is permanently suppressed. No removal UI yet — manage via DevTools Local Storage inspector on the extension service worker. \ No newline at end of file +- All encryption/decryption of vault data (including item names) happens in the browser +- The server stores only encrypted blobs — zero-knowledge architecture +- `enc_key_salt` is not secret on its own — only useful combined with the master password +- **Redis is required in production** for rate limiting. Without it, each Gunicorn worker maintains its own counter. +- **Recovery code is never stored server-side** — only the AES-256-GCM ciphertext of `enc_key_salt` +- **Password change clears the recovery code** — user must regenerate from Account Settings +- **The `#vault-list` element ID must not be renamed** — `vault.js` renders all vault items into it +- **`save_blocklist`** in `chrome.storage.local` stores hostnames for which the save banner is suppressed +- **`vault_items_cs` is in `chrome.storage.session`** — decrypted data never written to disk (changed from `local`) +- **HIBP checks run progressively** — the security dashboard renders synchronous sections first, then fires parallel k-anonymity requests in the background \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..100f5f6 --- /dev/null +++ b/README.md @@ -0,0 +1,281 @@ +# PassKeeper 🔐 + +A self-hosted, zero-knowledge password manager — web app and browser extension. Modelled after LastPass, built on Flask + MySQL + Web Crypto API. + +Your master password and decrypted vault data **never leave your browser**. The server stores only encrypted blobs it cannot read. + +--- + +## Features + +### Web App +- **Zero-knowledge encryption** — AES-256-GCM client-side, 600k-iteration PBKDF2 vault key derivation +- **7 item types** — Passwords, Secure Notes, Payment Cards, Bank Accounts, Addresses, Identities, Passkeys +- **TOTP/2FA support** — per-site TOTP codes stored inside the encrypted vault blob; live 6-digit display with countdown +- **Folder organisation** — create, rename, delete folders; filter vault by folder +- **Item sharing** — zero-knowledge ECDH P-256 re-encryption; share with any registered user +- **Emergency access** — configurable wait-timer access grant for a trusted contact +- **Security dashboard** — weak, reused, and old password detection + **HaveIBeenPwned k-anonymity breach check** (passwords never transmitted) +- **Account MFA** — TOTP-based login verification (Google Authenticator / Authy) +- **Master password change** — atomic zero-knowledge re-encryption of the entire vault +- **Account recovery** — 128-bit recovery code; server never stores it +- **Audit log** — server-side trail of all create/edit/delete actions; no sensitive data ever logged +- **Encrypted item names** — item names stored as AES-256-GCM ciphertext; server holds only the item type as a label +- **Responsive layout** — phone, tablet, laptop, and large desktop breakpoints; collapsible sidebar + +### Browser Extension (Chrome / Edge — Manifest V3) +- **Autofill** — detects login forms; injects icon into username and password fields only (scored heuristic, not all text inputs) +- **Suggestion dropdown** — anchored below the focused field; filters as you type; keyboard navigation (↑↓ Enter); fills with one click +- **Smart domain matching** — matches vault items by domain name, handles bare domains (`github.com`) and subdomains +- **Auto-save banner** — prompts to save or update credentials on form submit; duplicate detection; "Never for this site" blocklist +- **Inline password generator** — length slider, charset toggles, strength indicator; fully CSPRNG (`crypto.getRandomValues` throughout) +- **TOTP live display** — 6-digit code + countdown timer per item in the popup +- **SSO bridge** — log in once on the web app; extension picks up the session automatically +- **Idle lock** — configurable auto-lock timeout (1 / 5 / 10 / 30 min / Never) +- **Security** — decrypted vault data stored in `chrome.storage.session` only (memory-only, never written to disk) + +--- + +## Security Architecture + +``` +Master Password + │ + ├─ PBKDF2(email, 100k iter) ──► authHash ──► POST /api/auth/login + │ │ + │ Argon2id(authHash) stored in DB + │ + └─ PBKDF2(enc_key_salt, 600k iter) ──► vaultKey (stays in browser memory only) + │ + AES-256-GCM encrypt + │ + enc_data + iv ──► POST /api/vault + enc_name + iv_name (item name, encrypted separately) +``` + +The server is blind to all vault content. A database breach exposes only encrypted ciphertext. + +--- + +## Tech Stack + +| Layer | Technology | +|---|---| +| Backend | Python 3.12, Flask 3.x | +| Database | MySQL 8.x | +| Frontend | Vanilla JS, Web Crypto API, Jinja2 | +| Auth | Argon2id + PBKDF2 + JWT (HS256) | +| Encryption | AES-256-GCM (client-side) | +| Extension | Chrome Manifest V3 | +| Web server | Nginx + Gunicorn + systemd | +| Rate limiting | Flask-Limiter + Redis | +| TLS | Let's Encrypt / Certbot | + +--- + +## Getting Started + +### Prerequisites + +- Python 3.12+ +- MySQL 8.x +- Node.js is **not** required — no build step + +### Development (Windows) + +```bash +# 1. Clone and set up virtual environment +git clone https://github.com/yourname/passkeeper +cd passkeeper +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt + +# 2. Configure environment +copy .env.example .env +``` + +Edit `.env`: + +```env +FLASK_ENV=development +SECRET_KEY=your-secret-key +JWT_SECRET_KEY=your-jwt-secret +MYSQL_HOST=localhost +MYSQL_USER=passkeeper +MYSQL_PASSWORD=yourpassword +MYSQL_DB=passkeeper +TOTP_ENCRYPTION_KEY=64-char-hex-string # python -c "import secrets; print(secrets.token_hex(32))" +``` + +```bash +# 3. Create database +mysql -u root -p -e "CREATE DATABASE passkeeper CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +mysql -u root -p -e "CREATE USER 'passkeeper'@'localhost' IDENTIFIED BY 'yourpassword';" +mysql -u root -p -e "GRANT ALL PRIVILEGES ON passkeeper.* TO 'passkeeper'@'localhost'; FLUSH PRIVILEGES;" + +# 4. Create tables +python reset_db.py + +# 5. Run +python run.py +``` + +Open `http://localhost:5000`. + +### Load the Extension (Chrome) + +1. Open `chrome://extensions/` +2. Enable **Developer mode** (top-right toggle) +3. Click **Load unpacked** → select the `extension/` folder +4. Reload the extension after any JS/CSS changes + +Content script logs appear in the **page's** DevTools console. Background service worker logs are at `chrome://extensions/` → PassKeeper → "Service Worker". + +--- + +## Production Deployment + +### 1. Server dependencies + +```bash +sudo apt update && sudo apt install python3.12 python3.12-venv mysql-server nginx \ + certbot python3-certbot-nginx redis-server +``` + +### 2. Application setup + +```bash +cd /var/www/passkeeper +python3.12 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # set production values +flask db upgrade # run all migrations +``` + +### 3. systemd service + +Copy `scripts/passkeeper.service` to `/etc/systemd/system/passkeeper.service`, then: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now passkeeper +journalctl -xeu passkeeper.service +``` + +Key flags in the service unit: `--preload` (single app import, lower memory), `WatchdogSec=60s`, `Restart=on-failure`. + +### 4. Nginx + +Copy `scripts/passkeeper-nginx.conf` to `/etc/nginx/sites-available/passkeeper`, update `server_name`, then: + +```bash +sudo ln -s /etc/nginx/sites-available/passkeeper /etc/nginx/sites-enabled/ +sudo certbot --nginx -d yourdomain.com +sudo nginx -t && sudo systemctl reload nginx +``` + +The Nginx config uses an `upstream` block with `proxy_next_upstream` for zero-downtime Gunicorn restarts, dual rate-limit zones (auth endpoints and general API), and `Cache-Control: immutable` for static assets. + +### 5. Schema migrations (future updates) + +```bash +flask db upgrade +sudo systemctl reload passkeeper +``` + +--- + +## Environment Variables + +| Variable | Description | Example | +|---|---|---| +| `SECRET_KEY` | Flask session secret | 64-char random hex | +| `JWT_SECRET_KEY` | JWT signing secret | 64-char random hex | +| `MYSQL_HOST` | MySQL host | `localhost` | +| `MYSQL_USER` | MySQL username | `passkeeper` | +| `MYSQL_PASSWORD` | MySQL password | — | +| `MYSQL_DB` | Database name | `passkeeper` | +| `TOTP_ENCRYPTION_KEY` | Server-side AES key for TOTP secrets | 64-char hex | +| `RATELIMIT_STORAGE_URI` | Redis URI for rate limiting | `redis://127.0.0.1:6379/0` | +| `CORS_ORIGINS` | Allowed CORS origins | `https://yourdomain.com` | + +Generate secrets: + +```bash +python -c "import secrets; print(secrets.token_hex(32))" +``` + +--- + +## Project Structure + +``` +passkeeper/ +├── app/ # Flask application +│ ├── models/ # SQLAlchemy models +│ ├── routes/ # API blueprints (auth, vault, folders, sharing, emergency) +│ ├── services/ # Auth service (Argon2id, JWT, TOTP encryption) +│ ├── static/js/ # Client-side crypto + vault UI +│ └── templates/ # Jinja2 HTML templates +├── extension/ # Chrome extension (Manifest V3) +│ ├── popup/ # Popup UI (HTML + CSS + JS) +│ ├── content/ # Content script (form detection, autofill icon, dropdown) +│ ├── shared/ # Shared crypto (vault key, encryptName/decryptName) +│ ├── bridge/ # SSO bridge (web app ↔ extension session sync) +│ └── background.js # Service worker (badges, message relay, idle lock) +├── migrations/ # Alembic migration scripts +├── scripts/ # Nginx config, systemd unit, backup scripts +├── reset_db.py # Dev-only: drop + recreate all tables +├── requirements.txt +├── run.py # Development server entry point +└── wsgi.py # Gunicorn entry point +``` + +--- + +## API Overview + +All vault and folder endpoints require `Authorization: Bearer `. + +| Method | Endpoint | Description | +|---|---|---| +| POST | `/api/auth/register` | Create account | +| POST | `/api/auth/login` | Authenticate; returns tokens or MFA challenge | +| POST | `/api/auth/mfa/verify` | Complete MFA step | +| POST | `/api/auth/refresh` | Rotate refresh token | +| POST | `/api/auth/logout` | Blacklist both tokens | +| GET | `/api/vault` | List all encrypted vault items | +| POST | `/api/vault` | Create item (`enc_data`, `iv`, `enc_name`, `iv_name`) | +| PUT | `/api/vault/` | Update item | +| DELETE | `/api/vault/` | Delete item | +| GET | `/api/folders` | List folders | +| POST | `/api/sharing` | Share item (ECDH re-encryption) | +| POST | `/api/emergency` | Create emergency access grant | +| POST | `/api/auth/change-password` | Atomic vault re-encryption on password change | +| POST | `/api/auth/recover` | Account recovery (one-time use) | + +--- + +## Backup + +Automated MySQL backups with 30-day retention: + +```bash +# Install cron job +sudo cp scripts/passkeeper-logrotate /etc/logrotate.d/passkeeper +crontab scripts/backup.cron +``` + +Manual backup: + +```bash +bash scripts/backup_db.sh +``` + +--- + +## Licence + +MIT — see `LICENSE`. \ No newline at end of file diff --git a/app/routes/vault.py b/app/routes/vault.py index fa3642b..68f394a 100644 --- a/app/routes/vault.py +++ b/app/routes/vault.py @@ -137,3 +137,84 @@ def delete_item(item_id): ) db.session.commit() return jsonify({'message': 'Item deleted'}), 200 + + +# ── Import / Export ─────────────────────────────────────────────────────────── + +@vault_bp.route('/export', methods=['GET']) +@require_jwt +def export_items(): + """ + Return all vault items as an encrypted JSON export payload. + The client receives raw encrypted blobs and wraps them in a + signed JSON envelope — the server never sees plaintext. + Each object: { id, name, item_type, folder_id, enc_data, iv, enc_name, iv_name, + created_at, updated_at } + """ + items = VaultItem.query.filter_by(user_id=g.current_user_id).order_by( + VaultItem.name.asc() + ).all() + AuditLog.log( + user_id=g.current_user_id, + action='vault_item.export', + resource_type='vault_item', + resource_id=None, + detail=f'Exported {len(items)} vault item(s)', + ip_address=_client_ip(), + ) + db.session.commit() + return jsonify([item.to_dict() for item in items]), 200 + + +@vault_bp.route('/import', methods=['POST']) +@require_jwt +def import_items(): + """ + Bulk-import pre-encrypted vault items. + Accepts a JSON array of objects matching the POST /api/vault schema. + Items are imported as-is — the server stores encrypted blobs only. + Duplicate detection is left to the client. + Returns { imported: N, skipped: N } where skipped = malformed rows. + """ + data = request.get_json(silent=True) or [] + if not isinstance(data, list): + return jsonify({'error': 'Request body must be a JSON array'}), 400 + + imported = 0 + skipped = 0 + for row in data: + name = (row.get('name') or '').strip() + item_type = row.get('item_type', 'password') + enc_data = row.get('enc_data', '') + iv = row.get('iv', '') + if not name or item_type not in VALID_TYPES or not enc_data or not iv: + skipped += 1 + continue + folder_id = row.get('folder_id') + enc_name = row.get('enc_name') or None + iv_name = row.get('iv_name') or None + item = VaultItem( + user_id=g.current_user_id, + folder_id=folder_id, + item_type=item_type, + name=name, + enc_data=enc_data, + iv=iv, + enc_name=enc_name, + iv_name=iv_name, + ) + db.session.add(item) + imported += 1 + + if imported: + db.session.flush() + AuditLog.log( + user_id=g.current_user_id, + action='vault_item.import', + resource_type='vault_item', + resource_id=None, + detail=f'Imported {imported} item(s), skipped {skipped} malformed row(s)', + ip_address=_client_ip(), + ) + db.session.commit() + return jsonify({'imported': imported, 'skipped': skipped}), 200 diff --git a/app/static/css/app.css b/app/static/css/app.css index 777d754..7fd5ed0 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -2032,3 +2032,94 @@ html.sidebar-open { font-family: monospace; letter-spacing: 0.05em; } + +/* ── Import / Export view ──────────────────────────────────────────────────── */ + +.import-export-section { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 24px; + margin-bottom: 20px; + max-width: 640px; +} + +.import-export-heading { + font-size: 15px; + font-weight: 700; + color: #111827; + margin: 0 0 8px; +} + +.import-export-desc { + font-size: 13px; + color: #6b7280; + margin: 0 0 16px; + line-height: 1.55; +} + +.import-export-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.import-file-label { + display: inline-flex; + align-items: center; + cursor: pointer; + padding: 7px 14px; + border-radius: 7px; + font-size: 13px; +} + +.import-file-name { + font-size: 13px; + color: #6b7280; +} + +.import-preview { + margin: 14px 0 0; + font-size: 13px; + color: #374151; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 7px; + padding: 12px 14px; + line-height: 1.6; +} + +.import-preview-list { + margin: 6px 0 0 16px; + padding: 0; +} + +.import-note { + color: #6b7280; + font-size: 12px; + margin: 4px 0 0; +} + +.import-error { + color: #dc2626; +} + +.import-result { + margin-top: 14px; + font-size: 13px; + padding: 10px 14px; + border-radius: 7px; +} + +.import-result-ok { + background: #f0fdf4; + color: #15803d; + border: 1px solid #bbf7d0; +} + +.import-result-err { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; +} diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 1aa6fa0..6800516 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -199,7 +199,7 @@ const Vault = (() => { function switchView(view) { _currentView = view; - ["vault", "security", "sharing", "emergency"].forEach((v) => { + ["vault", "security", "sharing", "emergency", "import-export"].forEach((v) => { document .getElementById(`view-${v}`) ?.classList.toggle("hidden", v !== view); @@ -218,6 +218,7 @@ const Vault = (() => { if (view === "security") renderSecurityDashboard(); if (view === "sharing") loadSharingView(); if (view === "emergency") loadEmergencyView(); + if (view === "import-export") loadImportExportView(); } // ── Vault render ────────────────────────────────────────────────────────── @@ -552,8 +553,17 @@ const Vault = (() => { .flat(); const cutoff = Date.now() - 180 * 86400000; + // "Old" only penalises passwords that are ALSO weak or reused. + // A strong, unique password that hasn't changed in 200 days is fine — + // penalising it discourages good password hygiene. + const weakOrReusedIds = new Set([ + ...weak.map((i) => i.id), + ...reused.map((i) => i.id), + ]); const old = pwItems.filter( - (i) => new Date(i.created_at).getTime() < cutoff, + (i) => + new Date(i.created_at).getTime() < cutoff && + weakOrReusedIds.has(i.id), ); const total = pwItems.length; @@ -580,7 +590,7 @@ const Vault = (() => {
        ${weak.length}Weak
        ${reused.length}Reused
        -
        ${old.length}Old (>180d)
        +
        ${old.length}Old & Weak
        `; sectionsEl.innerHTML = ""; @@ -631,7 +641,7 @@ const Vault = (() => { .filter(Boolean), "Same password used on multiple sites.", ); - makeSection("Old Passwords", "🕐", old, "Not changed in over 180 days."); + makeSection("Old Passwords", "🕐", old, "Weak or reused passwords not changed in over 180 days."); // ── HaveIBeenPwned breach check ────────────────────────────────────────── // Run after the synchronous sections are rendered so the UI is immediately @@ -694,6 +704,234 @@ const Vault = (() => { } } + // ── Import / Export View ───────────────────────────────────────────────── + + // Holds parsed rows from a chosen file, ready for import. + let _importRows = []; + + /** + * Parse a Chrome/Bitwarden/1Password CSV into a normalised array of plain objects. + * Supported column sets: + * Chrome: name, url, username, password + * Bitwarden: name, login_uri, login_username, login_password, notes, type + * 1Password: Title, Url, Username, Password, Notes + */ + function _parseCsvImport(text) { + const lines = text.split(/\r?\n/); + if (lines.length < 2) return []; + const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, '').toLowerCase()); + + // Detect format by inspecting header names. + const col = (candidates) => { + for (const c of candidates) { + const idx = headers.indexOf(c); + if (idx !== -1) return idx; + } + return -1; + }; + const iName = col(['name', 'title']); + const iUrl = col(['url', 'login_uri']); + const iUser = col(['username', 'login_username']); + const iPass = col(['password', 'login_password']); + const iNotes = col(['notes', 'note']); + + const rows = []; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + // Simple CSV split — handles quoted fields containing commas. + const cells = []; + let cur = '', inQuote = false; + for (const ch of line + ',') { + if (ch === '"') { inQuote = !inQuote; } + else if (ch === ',' && !inQuote) { cells.push(cur.trim()); cur = ''; } + else { cur += ch; } + } + const get = (idx) => (idx !== -1 && cells[idx] != null ? cells[idx].replace(/^"|"$/g, '') : ''); + const name = get(iName); + const password = get(iPass); + if (!name || !password) continue; + rows.push({ + name, + url: get(iUrl), + username: get(iUser), + password, + notes: get(iNotes), + }); + } + return rows; + } + + let _importViewInitialised = false; + + function loadImportExportView() { + if (_importViewInitialised) return; + _importViewInitialised = true; + + // ── Export ─────────────────────────────────────────────────────────────── + + document.getElementById('btn-export-json')?.addEventListener('click', async () => { + try { + const res = await apiFetch('/api/vault'); + if (!res) return; + const items = await res.json(); + const payload = JSON.stringify({ version: 1, exported_at: new Date().toISOString(), items }, null, 2); + const blob = new Blob([payload], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `passkeeper-export-${new Date().toISOString().slice(0,10)}.json`; + a.click(); + URL.revokeObjectURL(url); + showToast('Encrypted vault exported.'); + console.log('[PassKeeper] Vault exported:', items.length, 'items'); + } catch (err) { + showToast('Export failed: ' + err.message, 'error'); + } + }); + + document.getElementById('btn-export-csv')?.addEventListener('click', async () => { + const vaultKey = VaultSession.getKey(); + if (!vaultKey) { showUnlockOverlay(); return; } + try { + const res = await apiFetch('/api/vault'); + if (!res) return; + const raw = await res.json(); + const decrypted = await Promise.all(raw.map(async (item) => { + try { + const plain = await Crypto.decryptItem(vaultKey, item.enc_data, item.iv); + let displayName = item.name; + if (item.enc_name && item.iv_name) { + const n = await Crypto.decryptName(vaultKey, item.enc_name, item.iv_name); + if (n) displayName = n; + } + return { name: displayName, ...plain }; + } catch { return null; } + })); + const csvRows = [['name','url','username','password','notes']]; + decrypted.filter(Boolean).forEach((r) => { + if (r.password) { + const esc = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`; + csvRows.push([r.name, r.url, r.username, r.password, r.notes].map(esc)); + } + }); + const csv = csvRows.map((r) => r.join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `passkeeper-export-${new Date().toISOString().slice(0,10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + showToast('CSV exported — store it securely.'); + console.log('[PassKeeper] CSV export:', decrypted.filter(Boolean).length, 'items'); + } catch (err) { + showToast('CSV export failed: ' + err.message, 'error'); + } + }); + + // ── Import ─────────────────────────────────────────────────────────────── + + const fileInput = document.getElementById('import-file-input'); + const fileNameEl = document.getElementById('import-file-name'); + const previewEl = document.getElementById('import-preview'); + const confirmBtn = document.getElementById('btn-import-confirm'); + const resultEl = document.getElementById('import-result'); + + fileInput?.addEventListener('change', async () => { + const file = fileInput.files[0]; + if (!file) return; + fileNameEl.textContent = file.name; + previewEl.classList.add('hidden'); + resultEl.classList.add('hidden'); + confirmBtn.disabled = true; + _importRows = []; + + const text = await file.text(); + const isJson = file.name.endsWith('.json'); + + try { + if (isJson) { + // PassKeeper encrypted JSON export. + const parsed = JSON.parse(text); + const items = parsed.items || (Array.isArray(parsed) ? parsed : []); + if (!items.length) throw new Error('No items found in JSON file.'); + _importRows = items; + previewEl.innerHTML = `

        Found ${items.length} encrypted item(s) ready to import.

        +

        These are already encrypted with your vault key — they will be imported as-is.

        `; + } else { + // CSV — decrypt and re-encrypt with current vault key. + const vaultKey = VaultSession.getKey(); + if (!vaultKey) { showUnlockOverlay(); return; } + const rows = _parseCsvImport(text); + if (!rows.length) throw new Error('No valid rows found. Check the CSV format.'); + _importRows = rows; // stored as plaintext — encrypted on confirm + previewEl.innerHTML = `

        Found ${rows.length} password(s) to import.

        +

        Preview (first 5):

        +
          ${rows.slice(0, 5).map((r) => + `
        • ${escHtml(r.name)} — ${escHtml(r.username || '(no username)')}
        • ` + ).join('')}
        + ${rows.length > 5 ? `

        …and ${rows.length - 5} more.

        ` : ''}`; + } + previewEl.classList.remove('hidden'); + confirmBtn.disabled = false; + confirmBtn.dataset.mode = isJson ? 'json' : 'csv'; + } catch (err) { + previewEl.innerHTML = `

        ⚠️ ${escHtml(err.message)}

        `; + previewEl.classList.remove('hidden'); + } + }); + + confirmBtn?.addEventListener('click', async () => { + const vaultKey = VaultSession.getKey(); + if (!vaultKey) { showUnlockOverlay(); return; } + confirmBtn.disabled = true; + confirmBtn.textContent = 'Importing…'; + resultEl.classList.add('hidden'); + + try { + let payload; + if (confirmBtn.dataset.mode === 'json') { + // Already-encrypted items — send directly. + payload = _importRows; + } else { + // Plaintext CSV rows — encrypt each one now. + payload = await Promise.all(_importRows.map(async (row) => { + const plain = { url: row.url || '', username: row.username || '', password: row.password, notes: row.notes || '' }; + const { enc_data, iv } = await Crypto.encryptItem(vaultKey, plain); + const { enc_name, iv_name } = await Crypto.encryptName(vaultKey, row.name); + return { name: 'password', item_type: 'password', enc_data, iv, enc_name, iv_name }; + })); + } + + const res = await apiFetch('/api/vault/import', { + method: 'POST', + body: JSON.stringify(payload), + }); + if (!res) return; + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Import failed.'); + + resultEl.innerHTML = `✅ Imported ${data.imported} item(s)${data.skipped ? `, skipped ${data.skipped} malformed row(s)` : ''}.`; + resultEl.className = 'import-result import-result-ok'; + resultEl.classList.remove('hidden'); + _importRows = []; + confirmBtn.textContent = 'Import items'; + fileInput.value = ''; + fileNameEl.textContent = 'No file chosen'; + previewEl.classList.add('hidden'); + console.log('[PassKeeper] Import complete:', data.imported, 'imported,', data.skipped, 'skipped'); + await loadVault(); + } catch (err) { + resultEl.innerHTML = `⚠️ ${escHtml(err.message)}`; + resultEl.className = 'import-result import-result-err'; + resultEl.classList.remove('hidden'); + confirmBtn.disabled = false; + confirmBtn.textContent = 'Import items'; + } + }); + } + // ── Sharing View ────────────────────────────────────────────────────────── async function loadSharingView() { @@ -2673,6 +2911,9 @@ const Vault = (() => { document .getElementById("sidebar-emergency") ?.addEventListener("click", () => switchView("emergency")); + document + .getElementById("sidebar-import-export") + ?.addEventListener("click", () => switchView("import-export")); document .getElementById("sidebar-generator") ?.addEventListener("click", () => openPasswordGeneratorModal()); diff --git a/app/templates/vault/index.html b/app/templates/vault/index.html index 4e0b6d1..91e6f47 100644 --- a/app/templates/vault/index.html +++ b/app/templates/vault/index.html @@ -122,6 +122,15 @@ 🚨 Emergency Access +