diff --git a/docs/SECURITY.md b/docs/SECURITY.md index ee40461..c9e60be 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -14,9 +14,35 @@ | Vault item plaintext | ❌ never | Only the client (with master password) decrypts | | Item count | ✅ | Acceptable leak | | Item type (login/note/...) | ✅ | Acceptable leak | +| TOTP shared secret (opt-in)| ✅ when 2FA enabled| RFC 6238 verifier requires the secret — see below | +| TOTP recovery codes | ❌ Argon2id-hashed | Plaintext shown to user once, never persisted | ## Threats considered +### Optional TOTP (2FA) — what it changes + +Enabling 2FA adds a per-user TOTP secret (RFC 4226 / 6238) to the +`users` row. **This is the one server-stored secret that vault data is +not derived from.** Crucially: + +- **Vault contents stay zero-knowledge.** A breach that leaks both the + auth_key hash and the TOTP secret still does **not** enable vault + decryption — the symmetric key is encrypted with the master key, and + the master key is never on the server. +- **Login auth widens by one secret.** An attacker who exfiltrates the + TOTP secret can compute valid OTPs for that user and bypass the 2FA + challenge — but they still need the master password to get past the + first factor and to actually decrypt anything. +- **Recovery codes are stored as Argon2id hashes**, identical posture to + the auth_key hash. A leak of the recovery-codes column does not + enable login. +- **Disable always requires a fresh code** (current OTP or recovery + code). A stolen access token alone can't downgrade the auth posture. + +If you need a true zero-knowledge second factor, leave TOTP off and use +the upcoming WebAuthn / passkey path (tracked) which uses public-key +crypto — the server only stores the public key, never a verifier secret. + ### Database breach Attacker pulls a full dump. They have: emails, Argon2 hashes of auth keys, KDF parameters, encrypted blobs. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 140ab5f..39d6e43 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -355,6 +355,47 @@ The file shape is intentionally stable (`{ format, version, vault, items: [...] }`) so a future Restore flow can consume it without a schema bump. +### 5g. Two-factor authentication (TOTP) + +Open the **Settings** link in the sidebar's user card → click **Set up 2FA**. + +The setup flow has three steps: + +1. **Scan the QR** with your authenticator (Google Authenticator, + 1Password, Authy, Microsoft Authenticator, …). If your phone can't + reach the screen, expand "Can't scan? Type this manually" and copy + the base32 secret directly into the app. +2. **Confirm the first code.** Type the 6-digit code your authenticator + shows. Passman verifies against the stored secret and only then + flips the `totp_enabled` flag — if you abandon the flow before this + step your account stays at single-factor. +3. **Save your recovery codes.** Ten single-use codes are shown + *exactly once*. Each lets you log in if you lose your phone. Copy + them, download the `.txt`, or write them down — Passman keeps only + Argon2id hashes server-side, so this list cannot be retrieved later. + +Once enabled, login becomes two-step: email + master password, then a +6-digit code. Recovery codes work in place of the 6-digit code (and +are consumed on first use — `9 remaining` becomes `8`). + +> **Trade-off, plainly stated.** TOTP is **not** zero-knowledge — +> RFC 6238 requires the verifier to know the shared secret, so the +> server now holds your OTP secret alongside the existing auth_key +> hash. Vault contents stay zero-knowledge: a server breach that leaks +> both still can't decrypt your credentials. The benefit is that +> credential-stuffing or phishing attacks against the password alone +> can't get past login. +> +> If you need the strongest server-side guarantee, leave 2FA off — +> the original posture (auth_key + master-password-derived key) is +> already strong against offline attacks. WebAuthn / passkeys are the +> tracked alternative for users who want a second factor without +> server-side secrets. + +To turn 2FA off: Settings → **Disable 2FA** → enter a current code or +a recovery code. A stolen access token alone can't downgrade — the +disable endpoint always requires fresh proof. + --- ## 6. Chrome extension diff --git a/package-lock.json b/package-lock.json index a33dada..7322b6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1287,7 +1287,6 @@ "version": "22.10.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.20.0" @@ -1300,6 +1299,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -1428,6 +1436,30 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1485,6 +1517,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001792", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", @@ -1516,6 +1557,35 @@ "node": ">=18" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1548,6 +1618,21 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.352", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.352.tgz", @@ -1555,6 +1640,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -1662,6 +1753,19 @@ } } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1687,12 +1791,30 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/hash-wasm": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==", "license": "MIT" }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1725,6 +1847,18 @@ "node": ">=6" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1801,6 +1935,51 @@ ], "license": "MIT" }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1828,6 +2007,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -1857,6 +2045,23 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1924,6 +2129,21 @@ "react-dom": ">=16.8" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/rollup": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", @@ -1988,6 +2208,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2019,6 +2245,32 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2081,7 +2333,6 @@ "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -2307,6 +2558,12 @@ } } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2324,6 +2581,26 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -2331,6 +2608,41 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zustand": { "version": "5.0.13", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", @@ -2403,6 +2715,8 @@ "version": "0.1.0", "dependencies": { "@passman/core": "*", + "@types/qrcode": "^1.5.6", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.28.0", diff --git a/packages/web/package.json b/packages/web/package.json index 1f49cd6..87f922e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -13,6 +13,8 @@ }, "dependencies": { "@passman/core": "*", + "@types/qrcode": "^1.5.6", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.28.0", diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 1aa226f..39000ab 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { LoginPage } from "./pages/LoginPage.js"; import { RegisterPage } from "./pages/RegisterPage.js"; +import { SettingsPage } from "./pages/SettingsPage.js"; import { VaultPage } from "./pages/VaultPage.js"; export function App() { @@ -12,6 +13,7 @@ export function App() { } /> } /> } /> + } /> ); diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 7b5a0da..d96c3a6 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -39,6 +39,31 @@ export interface TokenPair { encrypted_symmetric_key: string; } +/** `POST /sessions` returns this when the user has 2FA enabled — phase-1 + * only. The client must follow up with `POST /sessions/otp` carrying the + * same `otp_token` plus a 6-digit code (or a recovery code). */ +export interface OtpChallenge { + requires_otp: true; + otp_token: string; + otp_expires_in: number; +} + +export type LoginResponse = TokenPair | OtpChallenge; + +export function isOtpChallenge(r: LoginResponse): r is OtpChallenge { + return (r as OtpChallenge).requires_otp === true; +} + +export interface TotpStatus { + enabled: boolean; + recovery_codes_remaining: number; +} + +export interface TotpSetup { + provisioning_uri: string; + secret_base32: string; +} + export interface VaultItem { id: string; item_type: string; @@ -83,11 +108,37 @@ export const api = { request(`/accounts/kdf?email=${encodeURIComponent(email)}`), login: (email: string, authKey: string) => - request("/sessions", { + request("/sessions", { method: "POST", body: JSON.stringify({ email, auth_key: authKey }), }), + loginOtp: (otpToken: string, code: string) => + request("/sessions/otp", { + method: "POST", + body: JSON.stringify({ otp_token: otpToken, code }), + }), + + totpStatus: (token: string) => + request("/account/totp/status", {}, token), + + totpSetup: (token: string) => + request("/account/totp/setup", { method: "POST" }, token), + + totpConfirm: (token: string, code: string) => + request<{ recovery_codes: string[] }>( + "/account/totp/confirm", + { method: "POST", body: JSON.stringify({ code }) }, + token, + ), + + totpDisable: (token: string, code: string) => + request( + "/account/totp/disable", + { method: "POST", body: JSON.stringify({ code }) }, + token, + ), + logout: (token: string, refreshToken: string) => request("/sessions", { method: "DELETE", diff --git a/packages/web/src/pages/LoginPage.tsx b/packages/web/src/pages/LoginPage.tsx index fae8116..dce0b7e 100644 --- a/packages/web/src/pages/LoginPage.tsx +++ b/packages/web/src/pages/LoginPage.tsx @@ -1,13 +1,33 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { deriveLoginAuthKey, unlock } from "@passman/core"; +import { + deriveLoginAuthKey, + unlock, + type KdfParams, + type VaultSession, +} from "@passman/core"; -import { ApiError, api } from "../api/client.js"; +import { ApiError, api, isOtpChallenge, type TokenPair } from "../api/client.js"; import { useBranding } from "../branding/index.js"; import { useSession } from "../stores/session.js"; import { BrandHeader } from "./vault/BrandHeader.js"; +/** + * Login is now two-step when the user has 2FA enabled. + * + * Step 1 stays the same: derive auth_key from email + master password, + * POST /sessions. The server replies with either a TokenPair (no 2FA) or + * an OtpChallenge (`requires_otp: true`). + * + * Step 2 only runs if we got a challenge: prompt the user for an + * authenticator code, POST /sessions/otp. We hold the master password + + * KDF params in component state across the two steps so the post-OTP + * `unlock` call can derive the symmetric key without re-prompting. + * + * The held password is wiped from state as soon as the vault key is + * derived (we set the session and clear local state in the same render). + */ export function LoginPage() { const nav = useNavigate(); const setSession = useSession((s) => s.setSession); @@ -17,41 +37,41 @@ export function LoginPage() { const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + // Phase-2 carry: present only when the server replied with an OTP challenge. + const [pending, setPending] = useState<{ + otpToken: string; + kdfParams: KdfParams; + password: string; + } | null>(null); + const [otpCode, setOtpCode] = useState(""); + async function onSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); setBusy(true); try { - // 1. Get KDF params (server returns decoy params for unknown emails; - // we don't try to detect that here — the login attempt below will fail.) const kdf = await api.kdfLookup(email); - const kdfParams = { + const kdfParams: KdfParams = { salt: kdf.kdf_salt, timeCost: kdf.kdf_time_cost, memoryCost: kdf.kdf_memory_cost, parallelism: kdf.kdf_parallelism, }; - - // 2. Derive auth key client-side and authenticate. const authKey = await deriveLoginAuthKey(password, kdfParams); - const tokens = await api.login(email, authKey); + const resp = await api.login(email, authKey); - // 3. Decrypt the symmetric key with our master key (re-derived inside `unlock`). - const vault = await unlock(password, kdfParams, tokens.encrypted_symmetric_key); + if (isOtpChallenge(resp)) { + // Carry the master password forward — we still need it after OTP + // verification to derive the vault symmetric key. + setPending({ otpToken: resp.otp_token, kdfParams, password }); + return; + } - setSession({ - email, - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - vault, - }); - nav("/vault"); + await completeLogin(resp, kdfParams, password); } catch (e) { if (e instanceof ApiError && e.status === 401) { setError("Invalid email or password."); } else if (e instanceof Error && /tag/i.test(e.message)) { - // GCM tag mismatch on unlock = wrong password (server accepted us, but - // our master key can't decrypt the sym key — implies a corrupted record). setError("Vault decryption failed. Wrong password?"); } else { setError("Login failed. Try again."); @@ -61,13 +81,98 @@ export function LoginPage() { } } + async function onSubmitOtp(e: React.FormEvent) { + e.preventDefault(); + if (!pending) return; + setError(null); + setBusy(true); + try { + const tokens = await api.loginOtp(pending.otpToken, otpCode.trim()); + await completeLogin(tokens, pending.kdfParams, pending.password); + } catch (e) { + setError( + e instanceof ApiError && e.status === 401 + ? "That code didn't match. Try again or use a recovery code." + : "Login failed. Try again.", + ); + } finally { + setBusy(false); + } + } + + async function completeLogin( + tokens: TokenPair, + kdfParams: KdfParams, + pw: string, + ): Promise { + const vault: VaultSession = await unlock( + pw, + kdfParams, + tokens.encrypted_symmetric_key, + ); + setSession({ + email, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + vault, + }); + setPending(null); + setOtpCode(""); + setPassword(""); + nav("/vault"); + } + + if (pending) { + return ( +
+ +

Two-factor verification

+

+ Enter the 6-digit code from your authenticator app, or one of your + recovery codes. +

+
+ + {error &&

{error}

} + +
+

+ +

+
+ ); + } + return (

Unlock your vault

- {branding.tagline && ( -

{branding.tagline}

- )} + {branding.tagline &&

{branding.tagline}

}