diff --git a/CLAUDE.md b/CLAUDE.md index b99c054..83623e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,13 +52,15 @@ passkeeper/ │ │ ├── shared_item.py # ECDH-encrypted cross-user shares; enc_name/iv_name │ │ ├── emergency_access.py # State machine │ │ ├── recovery_challenge.py # Server-side recovery challenge (multi-worker safe) +│ │ ├── webauthn_credential.py # Passkey / WebAuthn credentials (one row per key) │ │ └── audit_log.py │ ├── routes/ │ │ ├── auth.py # Register, login, MFA, logout, refresh, change-password, recovery │ │ ├── vault.py # CRUD + GET /export + POST /import │ │ ├── folders.py │ │ ├── sharing.py -│ │ └── emergency.py +│ │ ├── emergency.py +│ │ └── webauthn.py # Passkey registration, authentication, credential management │ ├── services/ │ │ └── auth_service.py # Argon2id, JWT, blacklist, @require_jwt, TOTP encrypt/decrypt, │ │ # TOTP replay helpers (is_totp_code_used / mark_totp_code_used) @@ -101,7 +103,8 @@ passkeeper/ │ ├── d4e5f6a7b8c9_add_lockout_and_mfa_backup_codes.py │ ├── e5f6a7b8c9d0_add_recovery_challenges_table.py │ ├── f6a7b8c9d0e1_add_totp_used_codes_table.py # TOTP replay prevention -│ └── g7h8i9j0k1l2_encrypt_shared_item_name.py # enc_name/iv_name on shared_items +│ ├── g7h8i9j0k1l2_encrypt_shared_item_name.py # enc_name/iv_name on shared_items +│ └── h8i9j0k1l2m3_add_webauthn_credentials_table.py # Passkey / WebAuthn credentials ├── scripts/ │ ├── reencrypt_totp_secrets.py │ ├── backup_db.sh / backup.cron / passkeeper-logrotate @@ -190,6 +193,21 @@ CREATE TABLE recovery_challenges ( ); -- (folders, token_blacklist, emergency_access, audit_logs — standard schemas) + +-- WebAuthn / Passkey Credentials +CREATE TABLE webauthn_credentials ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + credential_id VARCHAR(512) NOT NULL UNIQUE, -- base64url authenticator credential ID + public_key TEXT NOT NULL, -- COSE public key, base64url + sign_count BIGINT NOT NULL DEFAULT 0, -- clone detection counter + transports VARCHAR(255), -- JSON list e.g. '["internal","hybrid"]' + aaguid VARCHAR(64), -- authenticator AAGUID + name VARCHAR(128) NOT NULL DEFAULT 'Passkey', -- user-assigned label + created_at DATETIME NOT NULL, + last_used_at DATETIME, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); ``` --- @@ -206,6 +224,7 @@ CREATE TABLE recovery_challenges ( | `e5f6a7b8c9d0` | Add recovery_challenges table | | `f6a7b8c9d0e1` | Add totp_used_codes table (TOTP replay prevent.) | | `g7h8i9j0k1l2` | Add enc_name/iv_name to shared_items | +| `h8i9j0k1l2m3` | Add webauthn_credentials table (passkeys) | --- @@ -221,6 +240,7 @@ CREATE TABLE recovery_challenges ( - **Argon2id:** double-hashes `authHash` server-side; transparently rehashes on login if parameters are upgraded - **JWT:** HS256, 15 min access / 7 day refresh, JTI blacklisted on logout - **MFA:** TOTP secret AES-256-GCM encrypted at rest; each code is single-use (replay prevented via `totp_used_codes` table, 120s TTL) +- **Passkeys / WebAuthn:** server authentication via FIDO2; ZK model preserved — WebAuthn proves identity to the server but the vault key is still derived from the master password client-side; `sign_count` updated on every assertion for clone detection - **Sharing:** ECDH P-256 zero-knowledge re-encryption; item name also encrypted with shared key - **Password generator:** fully CSPRNG (`_cryptoRandInt` rejection-sampling) - **Decrypted vault data:** `chrome.storage.session` only — never to disk @@ -252,6 +272,64 @@ Requires Chrome 111+. `content.js` `onChanged` listener watches `area === 'sessi ## Key Implementation Details +### WebAuthn / Passkey + +**Library:** `py-webauthn` (`webauthn>=2.0`). Installed via `pip install webauthn`. + +**Config** (`.env` / environment): +``` +WEBAUTHN_RP_ID=pwkeeper.ngodanguyen.tech # effective domain, no scheme/port +WEBAUTHN_RP_NAME=PassKeeper +WEBAUTHN_ORIGINS=https://pwkeeper.ngodanguyen.tech # comma-separated in env +``` +In development: `WEBAUTHN_RP_ID=localhost`, `WEBAUTHN_ORIGINS=http://localhost:5000`. + +**Flows:** + +*Registration* (requires active JWT — user must be logged in): +``` +POST /api/webauthn/register/begin → PublicKeyCredentialCreationOptions +POST /api/webauthn/register/complete → verifies attestation, stores credential +``` + +*Authentication* (unauthenticated — replaces password login): +``` +POST /api/webauthn/authenticate/begin → PublicKeyCredentialRequestOptions +POST /api/webauthn/authenticate/complete → verifies assertion → { access_token, refresh_token, enc_key_salt } +``` +Client still prompts for master password after successful assertion to derive vault key. + +*Management* (requires JWT): +``` +GET /api/webauthn/credentials → list registered passkeys +PATCH /api/webauthn/credentials/ → rename a passkey +DELETE /api/webauthn/credentials/ → remove a passkey +``` + +**Challenge storage:** challenge bytes are stored in the Flask session (signed cookie, `SECRET_KEY`). No DB row needed. Challenge is consumed (`session.pop`) on the `/complete` call. + +**Clone detection:** `sign_count` is read before verification and updated after. `py-webauthn` raises on a decreasing counter. + +**ZK preserved:** a WebAuthn assertion authenticates the user to the *server* only. The vault key `PBKDF2(masterPassword, enc_key_salt, 600k)` is never sent to or derivable by the server. After a passkey login the client must still enter the master password to unlock the vault. + +**Client-side (`PasskeyAuth` in `auth.js`):** +- `loginWithPasskey(email)` — begin → `navigator.credentials.get()` → complete → stores tokens → redirects to `/vault` +- `registerPasskey(name)` — begin → `navigator.credentials.create()` → complete +- `_bufToB64url` / `_b64urlToBuf` — ArrayBuffer ↔ base64url conversion helpers +- `_credentialToJson(cred)` — serialises `PublicKeyCredential` for the server + +**Settings UI (`vault.js` `loadPasskeys()`):** renders registered passkeys list with rename/delete; wires "Add passkey" button; hides the section if `window.PublicKeyCredential` is absent. + +**`auth.js` login wiring:** "Sign in with Passkey" button on `login.html` calls `PasskeyAuth.loginWithPasskey(email)`. On success, stores tokens and navigates to `/vault` — unlock overlay fires if master password field was empty. + +**Authenticator attachment:** currently `PLATFORM` (biometrics / device passkey). To support roaming authenticators (YubiKey, phone-as-key), remove or change `authenticator_attachment` in `register_begin`. + +**Exception names** (`webauthn>=2.0`): +- `InvalidRegistrationResponse` — use in `register_complete` +- `InvalidAuthenticationResponse` — use in `authenticate_complete` +- `InvalidCBORData` — malformed CBOR attestation +- NOT `InvalidAuthenticatorResponse` (does not exist) + ### TOTP replay prevention Every accepted TOTP code is recorded in `totp_used_codes` (user_id + code, 120s TTL). A second attempt with the same code within that window returns `400 Verification code already used`. Applies to: `mfa_enable`, `mfa_disable`, `mfa_verify`, `mfa_backup_codes_regenerate`. The table is pruned by the APScheduler cleanup job alongside `token_blacklist` and `recovery_challenges`. @@ -433,6 +511,9 @@ Audit log details **never** contain plaintext item names, shared item names, or | `sharing.py` | `sharing_keys.create/update` | ECDH key setup | | `sharing.py` | `shared_item.create/delete/accept` | Sharing (detail: type + id) | | `emergency.py` | `emergency_access.*` | All EA state transitions | +| `webauthn.py` | `webauthn.register` | Passkey registered | +| `webauthn.py` | `webauthn.auth_success` / `webauthn.auth_failed` | Passkey login attempt | +| `webauthn.py` | `webauthn.rename` / `webauthn.delete` | Credential management | --- @@ -466,6 +547,14 @@ GET|POST /api/emergency DELETE /api/emergency/ POST /api/emergency//accept|provide|request|deny GET /api/emergency//vault + +POST /api/webauthn/register/begin # start passkey registration (JWT required) +POST /api/webauthn/register/complete # finish registration + store credential +POST /api/webauthn/authenticate/begin # start passkey login (unauthenticated) +POST /api/webauthn/authenticate/complete # verify assertion → tokens + enc_key_salt +GET /api/webauthn/credentials # list registered passkeys (JWT required) +PATCH /api/webauthn/credentials/ # rename a passkey +DELETE /api/webauthn/credentials/ # remove a passkey ``` --- @@ -497,6 +586,7 @@ pymysql>=1.1 argon2-cffi>=23.1 pyjwt>=2.8 python-dotenv>=1.0 gunicorn>=21.0 pyotp>=2.9.0 qrcode[pil]>=7.4.2 cryptography>=42.0 # TOTP secret encryption redis>=5.0 # Rate-limit storage (required in production) +webauthn>=2.0 # Passkey / WebAuthn (py-webauthn) ``` --- diff --git a/README.md b/README.md index 3d6cc6b..fca6f94 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A self-hosted, zero-knowledge password manager — web app and Chrome/Firefox ex - **Security dashboard** — weak / reused / old / **no 2FA saved** / **HaveIBeenPwned breach check** (k-anonymity — passwords never transmitted) - **Import / Export** — encrypted JSON backup; CSV export (plaintext, handle carefully); import from Chrome, Bitwarden, and 1Password CSV formats (RFC 4180 compliant parser) - **Account MFA** — TOTP-based login (Google Authenticator / Authy); single-use code enforcement prevents replay attacks +- **Passkeys / WebAuthn** — register device biometrics or hardware keys as a sign-in method; master password still required to unlock vault (zero-knowledge preserved); manage passkeys in Account Settings - **Master password change** — atomic zero-knowledge re-encryption of entire vault including item names - **Account recovery** — 128-bit recovery code; server never stores it; challenge-response proof prevents forgery - **Audit log** — server-side trail of all create/edit/delete/import/export actions; no plaintext names ever logged @@ -76,6 +77,7 @@ Sharing: ECDH(Alice_priv, Bob_pub) ──► sharedKey ──► AES-256-GCM(en - **folder_id ownership** — all create/update/import operations validate folder belongs to current user - **Audit log privacy** — item names and shared item names never appear in server-side audit logs - **Extension fingerprinting** — `web_accessible_resources: []` blocks external pages from probing extension files +- **Passkey / WebAuthn** — FIDO2 assertion proves identity to the server without a password; vault key still derived from master password client-side; `sign_count` updated on each use for clone detection A database breach exposes only encrypted ciphertext. The server cannot read vault names, passwords, tags, or shared item names. @@ -88,7 +90,7 @@ A database breach exposes only encrypted ciphertext. The server cannot read vaul | Backend | Python 3.12, Flask 3.x | | Database | MySQL 8.x | | Frontend | Vanilla JS, Web Crypto API, Jinja2 | -| Auth | Argon2id + PBKDF2 + JWT (HS256) | +| Auth | Argon2id + PBKDF2 + JWT (HS256) + WebAuthn (FIDO2) | | Encryption | AES-256-GCM (client-side) | | Extension | Chrome MV3 / Firefox MV2 | | Web server | Nginx + Gunicorn + systemd | @@ -198,6 +200,9 @@ flask db upgrade && sudo systemctl reload passkeeper | `TOTP_ENCRYPTION_KEY` | Server-side AES key for TOTP secrets (64-char hex) | | `RATELIMIT_STORAGE_URI` | Redis URI — required in production (`redis://127.0.0.1:6379/0`) | | `CORS_ORIGINS` | Allowed origins (`*` in dev, domain in prod) | +| `WEBAUTHN_RP_ID` | Passkey relying party ID — effective domain, no scheme (`pwkeeper.ngodanguyen.tech`) | +| `WEBAUTHN_RP_NAME` | Passkey relying party display name (`PassKeeper`) | +| `WEBAUTHN_ORIGINS` | Comma-separated allowed origins for WebAuthn (`https://pwkeeper.ngodanguyen.tech`) | --- @@ -249,6 +254,12 @@ All vault/folder endpoints require `Authorization: Bearer `. | POST | `/api/emergency` | Create emergency access grant | | POST | `/api/auth/change-password` | Atomic vault re-encryption | | POST | `/api/auth/recover` | Account recovery (one-time) | +| POST | `/api/webauthn/register/begin` | Start passkey registration | +| POST | `/api/webauthn/register/complete` | Finish passkey registration | +| POST | `/api/webauthn/authenticate/begin` | Start passkey login | +| POST | `/api/webauthn/authenticate/complete` | Complete passkey login → tokens | +| GET | `/api/webauthn/credentials` | List registered passkeys | +| DELETE | `/api/webauthn/credentials/` | Remove a passkey | --- diff --git a/app/static/css/app.css b/app/static/css/app.css index c6a3b79..8d8c83a 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -2463,3 +2463,90 @@ html.sidebar-open { height: 1px; background: var(--border); } + +/* ── Vault health notifications ─────────────────────────────────────────────── */ + +/* Sidebar badge — shows issue count on Security link */ +.sidebar-badge { + margin-left: auto; + background: #c0392b; + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 1; + padding: 2px 5px; + border-radius: 10px; + min-width: 16px; + text-align: center; +} + +.sidebar-badge.badge-warn { + background: #d97706; +} + +/* Health banner — dismissible strip above the vault list */ +.health-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + font-size: 13px; + border-bottom: 1px solid transparent; + flex-shrink: 0; +} + +.health-banner.banner-danger { + background: #fef2f2; + border-color: #fecaca; + color: #991b1b; +} + +.health-banner.banner-warn { + background: #fffbeb; + border-color: #fde68a; + color: #92400e; +} + +.health-banner.banner-ok { + background: #f0fdf4; + border-color: #bbf7d0; + color: #166534; +} + +.health-banner-icon { + font-size: 16px; + flex-shrink: 0; +} + +.health-banner-text { + flex: 1; + line-height: 1.4; +} + +.health-banner-link { + background: none; + border: none; + cursor: pointer; + font-size: 12px; + font-weight: 600; + text-decoration: underline; + color: inherit; + padding: 0; + flex-shrink: 0; +} + +.health-banner-dismiss { + background: none; + border: none; + cursor: pointer; + font-size: 16px; + color: inherit; + opacity: 0.6; + padding: 0 2px; + line-height: 1; + flex-shrink: 0; +} + +.health-banner-dismiss:hover { + opacity: 1; +} \ No newline at end of file diff --git a/app/static/js/vault.js b/app/static/js/vault.js index a543359..b1778ff 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -234,6 +234,13 @@ const Vault = (() => { renderFolderList(); renderTagList(); applyCurrentFilter(); + // Reset banner dismissed flag so fresh health results are visible. + const banner = document.getElementById("health-banner"); + if (banner) banner.dataset.dismissed = "0"; + // Run health checks in the background — updates badge + banner without + // blocking the vault render. Results are cached so opening the Security + // tab doesn't re-run HIBP checks. + runBackgroundHealthCheck(); } catch (err) { showToast("Failed to load vault: " + err.message, "error"); } finally { @@ -678,6 +685,141 @@ const Vault = (() => { } } + // ── Background vault health checks ────────────────────────────────────────── + // + // Runs after every vault load. Computes weak/reused counts synchronously, + // then fires HIBP checks in parallel. Updates the Security sidebar badge + // and a dismissible top banner without requiring the user to open the + // Security tab. Results are cached so re-opening the tab skips re-checking. + + let _healthCache = null; // { weak, reused, breached } — populated after first run + let _hibpRunning = false; // prevents concurrent HIBP runs + + async function runBackgroundHealthCheck() { + if (_hibpRunning) return; + _hibpRunning = true; + _healthCache = null; + + try { + const pwItems = _items.filter( + (i) => i.item_type === "password" && i.plain?.password, + ); + if (!pwItems.length) { + _updateHealthUI({ weak: 0, reused: 0, breached: 0 }); + return; + } + + // Synchronous metrics — instant. + const weak = pwItems.filter((i) => { + const p = i.plain.password; + if (p.length < 10) return true; + return ( + [/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(p)) + .length < 2 + ); + }); + + const passCounts = {}; + pwItems.forEach((i) => { + const p = i.plain.password; + (passCounts[p] = passCounts[p] || []).push(i); + }); + const reused = Object.values(passCounts) + .filter((a) => a.length > 1) + .flat(); + + // Update badge immediately with sync results — HIBP will update again. + _updateHealthUI({ weak: weak.length, reused: reused.length, breached: null }); + + // HIBP — k-anonymity, runs in parallel. + const hibpResults = await Promise.all( + pwItems.map(async (item) => ({ + item, + count: await checkHibp(item.plain.password), + })), + ); + const breachedItems = hibpResults.filter((r) => r.count > 0).map((r) => r.item); + + _healthCache = { + weak: weak.length, + reused: reused.length, + breached: breachedItems.length, + breachedItems, + hibpResults, + }; + + _updateHealthUI({ + weak: weak.length, + reused: reused.length, + breached: breachedItems.length, + }); + } catch (err) { + console.error("[PassKeeper] Background health check failed:", err); + } finally { + _hibpRunning = false; + } + } + + function _updateHealthUI({ weak, reused, breached }) { + const badge = document.getElementById("security-badge"); + const banner = document.getElementById("health-banner"); + if (!badge || !banner) return; + + const knownBreached = breached !== null; + const issueCount = + (weak || 0) + (reused || 0) + (knownBreached ? (breached || 0) : 0); + const hasBreaches = knownBreached && breached > 0; + const hasSyncIssues = (weak || 0) + (reused || 0) > 0; + + // ── Sidebar badge ────────────────────────────────────────────────────── + if (issueCount > 0) { + badge.textContent = issueCount > 99 ? "99+" : String(issueCount); + badge.classList.remove("hidden", "badge-warn"); + if (hasBreaches) { + badge.classList.remove("badge-warn"); // red (default) + } else { + badge.classList.add("badge-warn"); // amber + } + } else { + badge.classList.add("hidden"); + } + + // ── Banner ───────────────────────────────────────────────────────────── + // Don't re-render if user already dismissed it this session. + if (banner.dataset.dismissed === "1") return; + if (issueCount === 0) { + banner.classList.add("hidden"); + return; + } + + const parts = []; + if (hasBreaches) + parts.push(`${breached} breached password${breached !== 1 ? "s" : ""}`); + if ((weak || 0) > 0) + parts.push(`${weak} weak password${weak !== 1 ? "s" : ""}`); + if ((reused || 0) > 0) + parts.push(`${reused} reused password${reused !== 1 ? "s" : ""}`); + + const severity = hasBreaches ? "danger" : "warn"; + const icon = hasBreaches ? "🚨" : "⚠️"; + + banner.className = `health-banner banner-${severity}`; + banner.innerHTML = ` + ${icon} + ${parts.join(" · ")} + + `; + banner.classList.remove("hidden"); + + document.getElementById("btn-health-banner-view")?.addEventListener("click", () => { + switchView("security"); + }); + document.getElementById("btn-health-banner-dismiss")?.addEventListener("click", () => { + banner.classList.add("hidden"); + banner.dataset.dismissed = "1"; + }); + } + async function renderSecurityDashboard() { const summaryEl = document.getElementById("security-summary"); const sectionsEl = document.getElementById("security-sections"); @@ -828,8 +970,8 @@ const Vault = (() => { ); // ── HaveIBeenPwned breach check ────────────────────────────────────────── - // Run after the synchronous sections are rendered so the UI is immediately - // useful. The HIBP API is queried in parallel for all passwords. + // Use cached results from the background check when available — avoids + // re-querying HIBP every time the user opens the Security tab. const hibpSection = document.createElement("div"); hibpSection.className = "sec-section"; hibpSection.innerHTML = ` @@ -842,13 +984,19 @@ const Vault = (() => { `; sectionsEl.appendChild(hibpSection); - // Run all HIBP checks in parallel — k-anonymity: only 5-char SHA-1 prefix sent. - const hibpResults = await Promise.all( - pwItems.map(async (item) => ({ - item, - count: await checkHibp(item.plain.password), - })), - ); + // Use cached results if available, otherwise run fresh checks. + let hibpResults; + if (_healthCache?.hibpResults) { + hibpResults = _healthCache.hibpResults; + } else { + // Run all HIBP checks in parallel — k-anonymity: only 5-char SHA-1 prefix sent. + hibpResults = await Promise.all( + pwItems.map(async (item) => ({ + item, + count: await checkHibp(item.plain.password), + })), + ); + } const breached = hibpResults.filter((r) => r.count > 0).map((r) => r.item); if (!breached.length) { @@ -4173,4 +4321,4 @@ const Vault = (() => { } })(); -document.addEventListener("DOMContentLoaded", Vault.init); +document.addEventListener("DOMContentLoaded", Vault.init); \ No newline at end of file diff --git a/app/templates/vault/index.html b/app/templates/vault/index.html index 115d180..510b808 100644 --- a/app/templates/vault/index.html +++ b/app/templates/vault/index.html @@ -119,6 +119,7 @@ > 🛡️ Security +