Compare commits

...
Author SHA1 Message Date
dependabot[bot]andGitHub e0d70a666f chore(deps): bump the npm-minor-and-patch group across 1 directory with 12 updates
Bumps the npm-minor-and-patch group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.101.2` | `5.101.4` |
| [@zip.js/zip.js](https://github.com/gildas-lormeau/zip.js) | `2.8.26` | `2.8.34` |
| [lucide-preact](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-preact) | `1.22.0` | `1.28.0` |
| [preact](https://github.com/preactjs/preact) | `10.29.3` | `10.29.7` |
| [@preact/preset-vite](https://github.com/preactjs/preset-vite) | `2.10.5` | `2.10.6` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.0.1` | `26.1.2` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.5.2` | `10.5.4` |
| [opencc-js](https://github.com/nk2028/opencc-js) | `1.3.2` | `1.4.1` |
| [postcss](https://github.com/postcss/postcss) | `8.5.23` | `8.5.25` |
| [tsx](https://github.com/privatenumber/tsx) | `4.22.4` | `4.23.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.1.3` | `8.2.0` |
| [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler) | `4.105.0` | `4.116.0` |



Updates `@tanstack/react-query` from 5.101.2 to 5.101.4
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.101.4/packages/react-query)

Updates `@zip.js/zip.js` from 2.8.26 to 2.8.34
- [Release notes](https://github.com/gildas-lormeau/zip.js/releases)
- [Commits](https://github.com/gildas-lormeau/zip.js/compare/v2.8.26...v2.8.34)

Updates `lucide-preact` from 1.22.0 to 1.28.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.28.0/packages/lucide-preact)

Updates `preact` from 10.29.3 to 10.29.7
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.29.3...10.29.7)

Updates `@preact/preset-vite` from 2.10.5 to 2.10.6
- [Release notes](https://github.com/preactjs/preset-vite/releases)
- [Changelog](https://github.com/preactjs/preset-vite/blob/main/CHANGELOG.md)
- [Commits](https://github.com/preactjs/preset-vite/commits)

Updates `@types/node` from 26.0.1 to 26.1.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `autoprefixer` from 10.5.2 to 10.5.4
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.5.2...10.5.4)

Updates `opencc-js` from 1.3.2 to 1.4.1
- [Release notes](https://github.com/nk2028/opencc-js/releases)
- [Changelog](https://github.com/nk2028/opencc-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/nk2028/opencc-js/compare/v1.3.2...v1.4.1)

Updates `postcss` from 8.5.23 to 8.5.25
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.23...8.5.25)

Updates `tsx` from 4.22.4 to 4.23.1
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.4...v4.23.1)

Updates `vite` from 8.1.3 to 8.2.0
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.2.0/packages/vite)

Updates `wrangler` from 4.105.0 to 4.116.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.116.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.101.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@zip.js/zip.js"
  dependency-version: 2.8.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: lucide-preact
  dependency-version: 1.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: preact
  dependency-version: 10.29.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@preact/preset-vite"
  dependency-version: 2.10.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 26.1.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: autoprefixer
  dependency-version: 10.5.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: opencc-js
  dependency-version: 1.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: postcss
  dependency-version: 8.5.25
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: tsx
  dependency-version: 4.23.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: vite
  dependency-version: 8.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: wrangler
  dependency-version: 4.116.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-02 21:04:59 +00:00
shuaiplus 652952379b fix: stop accepting backup blob credentials from the URL 2026-07-26 16:00:39 +08:00
shuaiplus 34fd2f0259 build: optimize Rolldown chunk splitting 2026-07-25 22:49:02 +08:00
EnvyBackyardandGitHub df493b4697 feat: expand password generator options (#329) 2026-07-25 22:23:02 +08:00
shuaiplus f6403b8cab fix: upgrade PostCSS to patch path traversal vulnerability 2026-07-25 21:44:22 +08:00
shuaiplus a0128908e1 fix: align API responses with Bitwarden 2026.7 clients 2026-07-25 21:25:51 +08:00
shuaiplus 8e5d9e294b fix: update sharp to 0.35.0 2026-07-23 18:28:00 +08:00
shuaiplus 6ffdf05dc6 fix: enable official desktop browser integration settings
Return Bitwarden's desktop-ui-settings-dialog feature state so supported desktop clients use the official settings dialog with browser integration controls.

Fixes #315
2026-07-23 01:09:44 +08:00
shuaiplus 82d9f61163 fix: handle unavailable browser cryptography
Detect insecure or unsupported browser contexts before account registration and show localized HTTPS guidance instead of leaking a SubtleCrypto runtime error.

Fixes #320
2026-07-23 00:54:52 +08:00
zu1kandshuaiplus f761fffd58 fix: align WebAuthn connectors with Bitwarden clients
Add official-compatible mobile and desktop connector flows, preserve exact .html asset paths, and cover the protocol and framing behavior with regression tests.

Fixes #326
2026-07-23 00:39:34 +08:00
shuaiplus 39c076b2e1 chore: release v1.8.0 2026-07-17 11:38:42 +08:00
shuaiplus a50a503788 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-17 11:33:41 +08:00
shuaiplus 6cbc06f833 feat: add Web Vault visibility switch 2026-07-17 11:32:43 +08:00
ph4nt0merandGitHub 72d8ec9cba fix: auto-refresh remote backup directory when cache is stale (#312)
When entering the cloud backup page, the remote backup directory list only showed cached data and required a manual click of the refresh button to see new backup files. This change adds a TTL-based auto-refresh that fetches fresh data when the cache is older than 5 minutes.

Changes:
- Added refreshedAt tracking per cache key in persisted state
- Added REMOTE_BROWSER_REFRESH_TTL_MS (5 min) constant
- Added useEffect that triggers auto-refresh when destination is selected and cached data is stale
- Stamped refresh timestamps after successful API responses
- Cleaned up timestamps on destination delete and settings save
2026-07-16 12:30:00 +08:00
shuaiplus 299eda597f fix(auth): align API keys and exclude device trust backups 2026-07-13 17:41:00 +08:00
shuaiplus 19de8d6e57 fix(auth): require complete password change key data 2026-07-13 17:02:59 +08:00
shuaiplus 63b642b251 fix(storage): safely batch folder deletion binds 2026-07-13 13:30:52 +08:00
shuaiplus e943357067 fix(web): preserve cipher types 6-8 during import 2026-07-13 13:13:42 +08:00
shuaiplus 573451c52f feat(yubico): refactor Yubico credential management and enhance settings UI 2026-07-13 13:05:04 +08:00
shuaiplus b731a014f1 fix(auth): prevent unexpected session logout 2026-07-13 02:11:12 +08:00
shuaiplus e25ec159bb feat: enhance navigation layout and improve mobile settings UI 2026-07-12 23:16:01 +08:00
shuaiplus b093c01fd7 chore: update version to 1.7.4 in package.json, package-lock.json, and app-version.ts 2026-07-12 22:13:33 +08:00
shuaiplus fa611dc843 fix: require master password for admin and wipe-device actions
Gate invite management, user ban/delete, and delete-all-devices behind
masterPasswordHash verification, matching backup step-up auth. The web UI
prompts for the master password in the shared confirm dialog.
2026-07-12 20:43:27 +08:00
shuaiplus 3c581d1fb1 fix: block IPv6 loopback in backup destination URL checks
Expand compressed IPv6 hostnames before the private-address allowlist so
forms like ::1 cannot bypass SSRF protection for WebDAV/S3 backup endpoints.
Also reject IPv4-mapped addresses written as ::ffff:hex:hex.
2026-07-12 20:21:45 +08:00
shuaiplus fb376797d2 feat: update PasswordGeneratorPage styles and improve layout responsiveness 2026-07-12 01:59:15 +08:00
shuaiplus 99b50275a6 feat: add Password Security feature with scanning and reporting capabilities 2026-07-12 01:50:21 +08:00
shuaiplus dfc98008cb Add password generator feature and update localization files 2026-07-11 18:49:26 +08:00
92 changed files with 13523 additions and 1208 deletions
+2
View File
@@ -78,6 +78,8 @@
- If the site reports a missing `JWT_SECRET`, add it as a **Secret** in Workers settings. In production use a random string of at least 32 characters; do not use temporary or example values. - If the site reports a missing `JWT_SECRET`, add it as a **Secret** in Workers settings. In production use a random string of at least 32 characters; do not use temporary or example values.
- To hide the Web Vault, add a text variable named `HIDE_WEB_VAULT` with the value `1` under **Workers settings → Variables and Secrets**. While enabled, server-hosted frontend pages and static assets return `404 Not Found`, while the login, sync, attachment, icon, notification, and other server endpoints used by Bitwarden clients remain available; an already installed or cached PWA can continue using its local frontend. Delete the variable (or change it to anything other than `1`) to restore the server-hosted Web Vault.
- In this flow you hand code to Cloudflare to build and deploy. `wrangler.toml` or `wrangler.kv.toml` in the repo defines binding names; the Worker initializes the D1 schema on first request—no manual SQL upload. - In this flow you hand code to Cloudflare to build and deploy. `wrangler.toml` or `wrangler.kv.toml` in the repo defines binding names; the Worker initializes the D1 schema on first request—no manual SQL upload.
+2
View File
@@ -78,6 +78,8 @@
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。 - 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
- 如需隐藏 Web Vault,在 Workers 的“设置 → 变量和机密”中添加文本变量 `HIDE_WEB_VAULT`,值设为 `1`。启用后,服务器上的前端页面和静态资源统一返回 `404 Not Found`,Bitwarden 客户端所需的登录、同步、附件、图标、通知等服务端接口仍可使用;已经安装或缓存的 PWA 可以继续使用本地前端。删除该变量(或将值改为非 `1`)即可恢复服务器上的 Web Vault。
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml``wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。 - 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml``wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
+389
View File
@@ -0,0 +1,389 @@
<!--
Release note writing rules:
1. Always add the newest release at the top of this file; do not delete older release notes.
2. Move previous releases down unchanged, then write the new release above them.
3. Use this exact release structure:
- One H1 title: "# vX.Y.Z - Short English Summary".
- English section first with "### Added", "### Improved", and "### Fixed".
- Then a horizontal rule "---".
- Chinese section second with "### 新增", "### 改进", and "### 修复".
4. Use numbered items under each group. Each item must be:
- "1. **Short feature/fix title.** One concise paragraph explaining what changed and why it matters."
- No one-line commit dumps, no raw changelog lists, no vague marketing copy.
- Merge related commits into one readable item instead of listing every commit separately.
5. Keep the tone user-friendly and concrete:
- Explain behavior, compatibility, UI impact, migration impact, or bug impact in plain language.
- Be concise but specific; one item is usually 1-3 sentences.
- Avoid internal-only implementation details unless they explain a user-visible change.
6. Add GitHub commit links at the end of every numbered item:
- English: "Commit: [abc1234](...)." or "Commits: [abc1234](...), [def5678](...)."
- Chinese: "提交:[abc1234](...)。" or "提交:[abc1234](...)、[def5678](...)。"
7. The English and Chinese versions should match in content and ordering, not be two different summaries.
-->
# v1.8.0 - Deployment Control, Session Reliability, and Compatibility Fixes
### Added
1. **Web Vault visibility control.** Cloudflare Workers deployments can now set `HIDE_WEB_VAULT=1` to return `404 Not Found` for server-hosted Web Vault pages and static assets while keeping Bitwarden login, sync, attachment, icon, notification, and compatibility endpoints available. Removing the variable restores the Web Vault, and already installed or cached PWAs can continue using their local frontend. Commit: [d990b2c](https://github.com/shuaiplus/nodewarden/commit/d990b2c71ff43a43f4598cad715b09c673e87b53).
### Improved
1. **Simpler desktop and mobile navigation.** The Web Vault now uses a consistent grouped sidebar with persistent expanded sections, clearer separation between tools, settings, and system management, and matching mobile settings navigation. This removes the previous layout-mode picker and makes frequently used destinations easier to find. Commit: [e25ec15](https://github.com/shuaiplus/nodewarden/commit/e25ec159bb2cd07ec6b3a794032a8a2978340d3d).
1. **Safer YubiKey validation credential management.** Yubico validation credentials now use centralized, concurrency-safe initialization; only active administrators can view or replace configured credentials, and credential initialization and reconfiguration are recorded in the security audit log. Regular users can still enroll YubiKeys without gaining access to server-wide credentials. Commit: [573451c](https://github.com/shuaiplus/nodewarden/commit/573451c52f02978dee5ab8379ff86e59da805437).
1. **Bitwarden-compatible personal API keys and safer backups.** Personal API keys can now be viewed after master-password verification and rotated without revoking unrelated sessions. Legacy hashed keys continue to authenticate until the user explicitly rotates them, while new backups exclude personal API keys and runtime authentication or device-trust state; older archives remain importable, but that runtime state is intentionally ignored during restore. Commit: [299eda5](https://github.com/shuaiplus/nodewarden/commit/299eda597ff8a07bf0b7ddfb6e3a5e7f800096db).
### Fixed
1. **Unexpected logout during temporary refresh failures.** Refresh sessions now use client-aware sliding lifetimes with an absolute upper bound, and transient network or service failures no longer turn a locked Web Vault into a forced full login. The Web Vault preserves the locked session, shows a retry path, and keeps official desktop, mobile, browser-extension, and web-cookie flows compatible. Commit: [b731a01](https://github.com/shuaiplus/nodewarden/commit/b731a014f1d86552615110cdf543f809a5c6a7e6).
1. **Complete key data required for master-password changes.** Password changes now reject incomplete or inconsistent authentication and unlock data, require the newly wrapped user key, and prevent KDF settings from being changed through the password-only endpoint. This avoids accepting a password update that could leave the account unable to decrypt its vault. Commit: [19de8d6](https://github.com/shuaiplus/nodewarden/commit/19de8d6e5769be463f973c8f2ec4de2e1530005c).
1. **Extended vault item imports preserve their data.** Web imports now correctly map bank accounts, driver's licenses, and passports instead of reducing item types 6-8 to incomplete generic drafts. Import summaries also report these item types by name. Commit: [e943357](https://github.com/shuaiplus/nodewarden/commit/e943357067236deeaa76ac0003020fa986abf2ab).
1. **Reliable bulk folder deletion.** Bulk folder deletion now calculates Cloudflare D1 bind limits correctly and batches the related cipher cleanup and folder removal statements safely, preventing large selections from failing or leaving partially updated folder references. Commit: [63b642b](https://github.com/shuaiplus/nodewarden/commit/63b642b2511207f435546802e197b6842f5c7aca).
1. **Fresh remote backup directory listings.** Opening a remote backup destination now automatically refreshes directory data when the saved cache is older than five minutes, so newly created backup files appear without requiring a manual refresh. Commit: [72d8ec9](https://github.com/shuaiplus/nodewarden/commit/72d8ec9cbadcb1b74d032deb5e5eea96e785d9c4).
---
### 新增
1. **Web Vault 显示开关。** Cloudflare Workers 部署现在可以设置 `HIDE_WEB_VAULT=1`,让服务器托管的 Web Vault 页面和静态资源统一返回 `404 Not Found`,同时继续提供 Bitwarden 客户端所需的登录、同步、附件、图标、通知和兼容接口。删除变量即可恢复 Web Vault,已经安装或缓存的 PWA 仍可继续使用本地前端。提交:[d990b2c](https://github.com/shuaiplus/nodewarden/commit/d990b2c71ff43a43f4598cad715b09c673e87b53)。
### 改进
1. **更简洁的桌面端和移动端导航。** Web Vault 现在统一使用分组侧边栏,并会保存各分组的展开状态;工具、设置和系统管理的层级更加清楚,移动端设置入口也同步调整。原有布局模式选择器已移除,常用功能更容易查找。提交:[e25ec15](https://github.com/shuaiplus/nodewarden/commit/e25ec159bb2cd07ec6b3a794032a8a2978340d3d)。
1. **更安全的 YubiKey 验证凭据管理。** Yubico 验证凭据现在通过统一且可防并发冲突的流程初始化;只有状态正常的管理员能够查看或替换服务器凭据,初始化和重新配置操作也会写入安全审计日志。普通用户仍可绑定 YubiKey,但无法接触服务器级凭据。提交:[573451c](https://github.com/shuaiplus/nodewarden/commit/573451c52f02978dee5ab8379ff86e59da805437)。
1. **兼容 Bitwarden 的个人 API Key 与更安全的备份。** 用户现在可以在验证主密码后查看个人 API Key,并在不撤销无关会话的情况下轮换密钥。旧版哈希密钥在用户主动轮换前仍可继续认证;新备份不再包含个人 API Key、运行中的认证状态或设备信任状态,旧备份仍能导入,但其中这类运行时状态会被主动忽略。提交:[299eda5](https://github.com/shuaiplus/nodewarden/commit/299eda597ff8a07bf0b7ddfb6e3a5e7f800096db)。
### 修复
1. **临时刷新失败导致意外退出登录。** 刷新会话现在根据客户端采用滑动有效期,并保留绝对最长时限;临时网络或服务故障不会再把已锁定的 Web Vault 直接变成完整登录页。Web Vault 会保留锁定会话并提供重试入口,同时兼容官方桌面端、移动端、浏览器扩展和 Web Cookie 会话。提交:[b731a01](https://github.com/shuaiplus/nodewarden/commit/b731a014f1d86552615110cdf543f809a5c6a7e6)。
1. **修改主密码时必须提交完整密钥数据。** 密码修改接口现在会拒绝不完整或不一致的认证与解锁数据,强制要求新的用户密钥包装结果,并禁止通过仅修改密码的接口顺带更改 KDF 设置,避免出现密码已更新但保险库无法解密的账户状态。提交:[19de8d6](https://github.com/shuaiplus/nodewarden/commit/19de8d6e5769be463f973c8f2ec4de2e1530005c)。
1. **扩展保险库条目导入时完整保留数据。** Web 导入现在会正确映射银行账户、驾驶证和护照,不再把类型 6-8 转换为字段缺失的通用草稿;导入结果摘要也会按名称显示这些条目类型。提交:[e943357](https://github.com/shuaiplus/nodewarden/commit/e943357067236deeaa76ac0003020fa986abf2ab)。
1. **可靠的批量文件夹删除。** 批量删除文件夹时现在会正确计算 Cloudflare D1 的绑定参数上限,并安全批处理密码条目引用清理和文件夹删除语句,避免大量选择时失败或留下只更新了一部分的文件夹引用。提交:[63b642b](https://github.com/shuaiplus/nodewarden/commit/63b642b2511207f435546802e197b6842f5c7aca)。
1. **远端备份目录自动显示最新文件。** 打开远端备份目标时,如果已保存的目录缓存超过五分钟,页面会自动获取最新数据,新生成的备份文件无需手动点击刷新即可出现。提交:[72d8ec9](https://github.com/shuaiplus/nodewarden/commit/72d8ec9cbadcb1b74d032deb5e5eea96e785d9c4)。
# v1.7.4 - Password Tools, Localization, and Security Hardening
### Added
1. **Built-in password generator.** The web vault now provides a dedicated generator for creating strong, configurable passwords, including memorable passphrases backed by the EFF word list. Generated values can be used while creating or editing vault items without leaving the app. Commits: [dfc9800](https://github.com/shuaiplus/nodewarden/commit/dfc98008cb58e9ed01b21ba158bb2584291462a3), [fb37679](https://github.com/shuaiplus/nodewarden/commit/fb376797d266003f8e23b64870f3638fde35d428).
1. **Password security dashboard.** A new password-security view scans the vault and reports weak, reused, exposed, and missing two-factor-authentication passwords, helping users prioritize account cleanup. Commit: [99b5027](https://github.com/shuaiplus/nodewarden/commit/99b50275a6a845e6ebbbae4d647350df939457f9).
1. **Duplicate-item selection tools.** Duplicate results are grouped by color and sorted alphabetically, and the vault now lets users select the unique items from a duplicate group for quicker review and cleanup. Commits: [0992170](https://github.com/shuaiplus/nodewarden/commit/099217062a4cb3a3caacce7513354bb388e8d76c), [39d9df7](https://github.com/shuaiplus/nodewarden/commit/39d9df78ea324fb2d1509221606408b1bb610118).
1. **Five additional interface languages.** Finnish, German, French, Italian, and Swedish are now available in the web vault, expanding the supported interface languages to ten. Commits: [dd90d7b](https://github.com/shuaiplus/nodewarden/commit/dd90d7b8b88a8a49ff1423bb36abb4eeb8f2f329), [9caa064](https://github.com/shuaiplus/nodewarden/commit/9caa0644888c25db835f8c5c93b8341ed80a42fe).
### Improved
1. **Passkey unlock and Bitwarden-client compatibility.** Account passkeys can now unlock the web vault directly, and supported FIDO2 origins, worker-routed fill-assist, Android asset-links checks, and web bootstrap paths are recognized more consistently. Commits: [8c65cb2](https://github.com/shuaiplus/nodewarden/commit/8c65cb2e80c6e5454fb53dbd7ea45cb83bf88ef7), [db31792](https://github.com/shuaiplus/nodewarden/commit/db31792cefc0f21fd543faf21407107a53b8dac2).
1. **Clearer offline and Send experiences.** The app now clearly indicates when it is operating offline, Send pages use improved date formatting, and closing dialogs retain their title through the animation to avoid a visual flash. Commits: [aae614a](https://github.com/shuaiplus/nodewarden/commit/aae614a079b5fa151e2bb98506f1b4fceac29072), [04cb475](https://github.com/shuaiplus/nodewarden/commit/04cb4759358b85029e3e32a5cab6ca39cbbef744), [525b773](https://github.com/shuaiplus/nodewarden/commit/525b773cf4799913ac24e34857348e3aa176608b).
1. **Simplified new-device sign-in.** New-device verification is no longer enforced, removing an extra login step for devices that are otherwise able to authenticate successfully. Commit: [14dff8e](https://github.com/shuaiplus/nodewarden/commit/14dff8ee6a59b741d86a42b25451116b120ac404).
### Fixed
1. **Backup destination SSRF protection.** Backup destination validation now rejects IPv6 loopback addresses, closing a path that could otherwise bypass loopback-host safeguards. Commit: [3c581d1](https://github.com/shuaiplus/nodewarden/commit/3c581d1fb1d92da9e00d3ff139c46f080462e6e8).
1. **Sensitive admin actions require the master password.** Administrative operations and device-wipe actions now require master-password confirmation, reducing the impact of an unattended or compromised web session. Commit: [fa611dc](https://github.com/shuaiplus/nodewarden/commit/fa611dc8430fc80744662feaaf3912341d5b63f2).
---
### 新增
1. **内置密码生成器。** Web 保险库现在提供专用的密码生成器,可创建高强度且可配置的密码,也支持基于 EFF 词表的易记口令短语。生成的值可直接用于新建或编辑保险库条目,无需离开应用。提交:[dfc9800](https://github.com/shuaiplus/nodewarden/commit/dfc98008cb58e9ed01b21ba158bb2584291462a3)、[fb37679](https://github.com/shuaiplus/nodewarden/commit/fb376797d266003f8e23b64870f3638fde35d428)。
1. **密码安全仪表板。** 新增的密码安全视图会扫描保险库,并报告弱密码、重复密码、已泄露密码和缺少双因素认证的密码,帮助用户优先处理需要清理的账户。提交:[99b5027](https://github.com/shuaiplus/nodewarden/commit/99b50275a6a845e6ebbbae4d647350df939457f9)。
1. **重复条目选择工具。** 重复项结果会按颜色分组并按字母顺序排序;保险库现在还可以从重复组中选择唯一条目,以便更快地审查和清理。提交:[0992170](https://github.com/shuaiplus/nodewarden/commit/099217062a4cb3a3caacce7513354bb388e8d76c)、[39d9df7](https://github.com/shuaiplus/nodewarden/commit/39d9df78ea324fb2d1509221606408b1bb610118)。
1. **新增五种界面语言。** Web 保险库现在提供芬兰语、德语、法语、意大利语和瑞典语,支持的界面语言扩展至十种。提交:[dd90d7b](https://github.com/shuaiplus/nodewarden/commit/dd90d7b8b88a8a49ff1423bb36abb4eeb8f2f329)、[9caa064](https://github.com/shuaiplus/nodewarden/commit/9caa0644888c25db835f8c5c93b8341ed80a42fe)。
### 改进
1. **通行密钥解锁和 Bitwarden 客户端兼容性。** 账户通行密钥现在可以直接解锁 Web 保险库;同时,对受支持 FIDO2 来源、Worker 路由的 fill-assist、Android asset-links 检查和 Web 启动路径的识别更加一致。提交:[8c65cb2](https://github.com/shuaiplus/nodewarden/commit/8c65cb2e80c6e5454fb53dbd7ea45cb83bf88ef7)、[db31792](https://github.com/shuaiplus/nodewarden/commit/db31792cefc0f21fd543faf21407107a53b8dac2)。
1. **更清晰的离线和 Send 使用体验。** 应用现在会明确提示离线运行状态,Send 页面采用了更好的日期格式,关闭对话框时会在动画期间保留标题,避免视觉闪烁。提交:[aae614a](https://github.com/shuaiplus/nodewarden/commit/aae614a079b5fa151e2bb98506f1b4fceac29072)、[04cb475](https://github.com/shuaiplus/nodewarden/commit/04cb4759358b85029e3e32a5cab6ca39cbbef744)、[525b773](https://github.com/shuaiplus/nodewarden/commit/525b773cf4799913ac24e34857348e3aa176608b)。
1. **简化新设备登录。** 不再强制执行新设备验证,能够正常完成身份验证的设备无需额外登录步骤。提交:[14dff8e](https://github.com/shuaiplus/nodewarden/commit/14dff8ee6a59b741d86a42b25451116b120ac404)。
### 修复
1. **备份目标的 SSRF 防护。** 备份目标校验现在会拒绝 IPv6 回环地址,堵住了可能绕过回环主机保护的路径。提交:[3c581d1](https://github.com/shuaiplus/nodewarden/commit/3c581d1fb1d92da9e00d3ff139c46f080462e6e8)。
1. **敏感管理员操作需要主密码。** 管理员操作和设备擦除操作现在需要确认主密码,降低无人值守或会话遭入侵时的影响。提交:[fa611dc](https://github.com/shuaiplus/nodewarden/commit/fa611dc8430fc80744662feaaf3912341d5b63f2)。
# v1.7.3 - Stronger Two-Step Login and Client Compatibility
### Added
1. **YubiKey OTP and passkey two-step login.** NodeWarden now supports YubiKey OTP as a managed two-factor provider and adds passkey-based two-factor authentication, including setup screens, WebAuthn fallback connector handling, multi-provider login prompts, and safer WebAuthn response normalization. Commits: [f63b745](https://github.com/shuaiplus/nodewarden/commit/f63b745), [c019c93](https://github.com/shuaiplus/nodewarden/commit/c019c93), [e73ae3d](https://github.com/shuaiplus/nodewarden/commit/e73ae3d), [d8cc88d](https://github.com/shuaiplus/nodewarden/commit/d8cc88d).
1. **Bitwarden extended vault item types.** Vault items now cover bank accounts, driver's licenses, and passports in addition to the existing login, card, identity, secure note, and SSH key flows. The web vault can create, display, decrypt, import, and export these item types with clearer sidebar icons. Commits: [109593d](https://github.com/shuaiplus/nodewarden/commit/109593d), [9de0d3b](https://github.com/shuaiplus/nodewarden/commit/9de0d3b).
1. **More Bitwarden client compatibility endpoints.** Added device verification settings, device registration routes, admin auth-request compatibility, fill-assist alignment, and push relay installation handling so more official Bitwarden client flows receive expected responses. Unsupported email verification and KDF routes now return explicit unsupported responses instead of ambiguous failures. Commits: [e376a84](https://github.com/shuaiplus/nodewarden/commit/e376a84), [8b2f98b](https://github.com/shuaiplus/nodewarden/commit/8b2f98b), [f0e5233](https://github.com/shuaiplus/nodewarden/commit/f0e5233), [56b301f](https://github.com/shuaiplus/nodewarden/commit/56b301f), [fd46dff](https://github.com/shuaiplus/nodewarden/commit/fd46dff), [cde4555](https://github.com/shuaiplus/nodewarden/commit/cde4555).
### Improved
1. **TOTP QR scanning and Bitwarden-compatible TOTP behavior.** Uploading TOTP QR codes now falls back to `jsQR` when browser `BarcodeDetector` support is incomplete, handles transparent PNGs correctly, validates uploaded QR images, and throttles camera fallback decoding to reduce CPU usage. TOTP storage and decryption behavior is also aligned more closely with Bitwarden clients. Commits: [b0a679b](https://github.com/shuaiplus/nodewarden/commit/b0a679b), [d024798](https://github.com/shuaiplus/nodewarden/commit/d024798), [73bbe8b](https://github.com/shuaiplus/nodewarden/commit/73bbe8b), [6e72220](https://github.com/shuaiplus/nodewarden/commit/6e72220), [8a5b210](https://github.com/shuaiplus/nodewarden/commit/8a5b210).
1. **Settings, device management, and localization polish.** Device management now lives inside Settings with updated navigation, the two-step provider UI is more responsive, and new settings, audit-log, and validation messages are localized across supported languages. This makes the security settings area easier to scan on desktop and mobile. Commits: [c7eb6c6](https://github.com/shuaiplus/nodewarden/commit/c7eb6c6), [062c966](https://github.com/shuaiplus/nodewarden/commit/062c966), [12af18e](https://github.com/shuaiplus/nodewarden/commit/12af18e), [c53d71f](https://github.com/shuaiplus/nodewarden/commit/c53d71f), [01ff627](https://github.com/shuaiplus/nodewarden/commit/01ff627).
1. **Encrypted Send password visibility and editing.** Password-protected Sends now show a lock indicator in the list, display masked password dots when editing an existing protected Send, and provide a compact trash-icon control for removing the stored password. This makes password state visible without exposing the password itself. Commits: [a870142](https://github.com/shuaiplus/nodewarden/commit/a870142), [ebc8e8e](https://github.com/shuaiplus/nodewarden/commit/ebc8e8e).
1. **Website icon behavior and workflow maintenance.** Website icons are now always available without the old `WEBSITE_ICONS_ENABLED` environment toggle, while icon requests keep privacy protections. The global-domains sync workflow also validates its ref before running. Commits: [57c5ef9](https://github.com/shuaiplus/nodewarden/commit/57c5ef9), [c643874](https://github.com/shuaiplus/nodewarden/commit/c643874), [680e287](https://github.com/shuaiplus/nodewarden/commit/680e287).
### Fixed
1. **Authentication, token, and rate-limit hardening.** API keys are stored as hashes, password rotation and JWT handling were tightened, user cache invalidates on token handling, remembered 2FA tokens survive a bad password attempt, the current access-token session is revoked correctly, and known rate-limit reset bypasses were closed. Commits: [1545881](https://github.com/shuaiplus/nodewarden/commit/1545881), [439683d](https://github.com/shuaiplus/nodewarden/commit/439683d), [60dd298](https://github.com/shuaiplus/nodewarden/commit/60dd298), [d9a36fe](https://github.com/shuaiplus/nodewarden/commit/d9a36fe), [1bad32f](https://github.com/shuaiplus/nodewarden/commit/1bad32f), [2df43cc](https://github.com/shuaiplus/nodewarden/commit/2df43cc), [ae168be](https://github.com/shuaiplus/nodewarden/commit/ae168be).
1. **User data isolation and request validation.** Storage reads are scoped by user, Send file routes gate access more strictly, anonymous notification hub requests are validated, and multipart backup/upload requests now have caps. This reduces the chance of cross-user data reads or oversized requests reaching deeper handlers. Commits: [baf5699](https://github.com/shuaiplus/nodewarden/commit/baf5699), [8c481a1](https://github.com/shuaiplus/nodewarden/commit/8c481a1), [23c53bd](https://github.com/shuaiplus/nodewarden/commit/23c53bd), [5142846](https://github.com/shuaiplus/nodewarden/commit/5142846).
1. **Backup, restore, and download safety.** Remote backup deletes are verified, archives and backup blobs are validated before use, destination secrets are redacted from settings responses, backup/download token flows are harder to misuse, and WebAuthn credential purpose survives backup export/import. A backup uploader redirect guard was also reverted to restore compatible remote behavior. Commits: [0cef6a0](https://github.com/shuaiplus/nodewarden/commit/0cef6a0), [00e0ec0](https://github.com/shuaiplus/nodewarden/commit/00e0ec0), [5c8f01b](https://github.com/shuaiplus/nodewarden/commit/5c8f01b), [cc4a830](https://github.com/shuaiplus/nodewarden/commit/cc4a830), [f532d3a](https://github.com/shuaiplus/nodewarden/commit/f532d3a), [a366acb](https://github.com/shuaiplus/nodewarden/commit/a366acb).
1. **Import compatibility and encrypted-field validation.** Imports now validate payload structure and ZIP entries before processing, and plaintext FIDO2 credential, SSH key, and password-history fields are rejected instead of being silently accepted and later dropped. This makes failed imports clearer and protects encrypted vault fields from incompatible plaintext data. Commits: [cf14704](https://github.com/shuaiplus/nodewarden/commit/cf14704), [1ec6ed4](https://github.com/shuaiplus/nodewarden/commit/1ec6ed4).
1. **Admin, audit, WebAuthn, and backup endpoint edge cases.** Admin audit-clears are recorded, passkey 2FA status is reported correctly, WebAuthn extension origins are constrained, and auth-request plus backup endpoint checks were tightened around sensitive flows. Commits: [d028b19](https://github.com/shuaiplus/nodewarden/commit/d028b19), [ace00e8](https://github.com/shuaiplus/nodewarden/commit/ace00e8), [7ac6ae5](https://github.com/shuaiplus/nodewarden/commit/7ac6ae5).
---
### 新增
1. **YubiKey OTP 和通行密钥两步登录。** NodeWarden 现在支持将 YubiKey OTP 作为可管理的双因素提供商,并新增基于通行密钥的双因素认证,包含设置界面、WebAuthn 备用连接器处理、多提供商登录提示,以及更安全的 WebAuthn 响应规范化。提交:[f63b745](https://github.com/shuaiplus/nodewarden/commit/f63b745)、[c019c93](https://github.com/shuaiplus/nodewarden/commit/c019c93)、[e73ae3d](https://github.com/shuaiplus/nodewarden/commit/e73ae3d)、[d8cc88d](https://github.com/shuaiplus/nodewarden/commit/d8cc88d)。
1. **Bitwarden 扩展保险库条目类型。** 除现有登录、银行卡、身份、安全笔记和 SSH 密钥流程外,保险库条目现在还覆盖银行账户、驾驶证和护照。Web 保险库可以创建、展示、解密、导入和导出这些条目类型,并提供更清晰的侧边栏图标。提交:[109593d](https://github.com/shuaiplus/nodewarden/commit/109593d)、[9de0d3b](https://github.com/shuaiplus/nodewarden/commit/9de0d3b)。
1. **更多 Bitwarden 客户端兼容端点。** 新增设备验证设置、设备注册路由、管理员认证请求兼容、fill-assist 对齐和推送中继安装处理,让更多官方 Bitwarden 客户端流程能获得预期响应。不支持的邮箱验证和 KDF 路由现在会返回明确的不支持响应,而不是含糊失败。提交:[e376a84](https://github.com/shuaiplus/nodewarden/commit/e376a84)、[8b2f98b](https://github.com/shuaiplus/nodewarden/commit/8b2f98b)、[f0e5233](https://github.com/shuaiplus/nodewarden/commit/f0e5233)、[56b301f](https://github.com/shuaiplus/nodewarden/commit/56b301f)、[fd46dff](https://github.com/shuaiplus/nodewarden/commit/fd46dff)、[cde4555](https://github.com/shuaiplus/nodewarden/commit/cde4555)。
### 改进
1. **TOTP 二维码扫描和 Bitwarden 兼容 TOTP 行为。** 上传 TOTP 二维码时,如果浏览器 `BarcodeDetector` 支持不完整,现在会回退到 `jsQR`,并正确处理透明 PNG、校验上传的二维码图片、限制摄像头回退解码频率以降低 CPU 占用。TOTP 的存储和解密行为也更贴近 Bitwarden 客户端。提交:[b0a679b](https://github.com/shuaiplus/nodewarden/commit/b0a679b)、[d024798](https://github.com/shuaiplus/nodewarden/commit/d024798)、[73bbe8b](https://github.com/shuaiplus/nodewarden/commit/73bbe8b)、[6e72220](https://github.com/shuaiplus/nodewarden/commit/6e72220)、[8a5b210](https://github.com/shuaiplus/nodewarden/commit/8a5b210)。
1. **设置、设备管理和本地化打磨。** 设备管理现在整合进设置页并更新了导航,两步验证提供商界面在响应式布局下更顺手,新的设置、审计日志和校验消息也补齐了受支持语言的本地化。安全设置区域在桌面和移动端都更容易浏览。提交:[c7eb6c6](https://github.com/shuaiplus/nodewarden/commit/c7eb6c6)、[062c966](https://github.com/shuaiplus/nodewarden/commit/062c966)、[12af18e](https://github.com/shuaiplus/nodewarden/commit/12af18e)、[c53d71f](https://github.com/shuaiplus/nodewarden/commit/c53d71f)、[01ff627](https://github.com/shuaiplus/nodewarden/commit/01ff627)。
1. **加密 Send 的密码状态展示与编辑。** 受密码保护的 Send 现在会在列表中显示锁定标记,编辑已有受保护 Send 时会显示密码掩码圆点,并提供紧凑的垃圾桶图标用于移除已保存密码。这样可以看清密码状态,同时不暴露密码本身。提交:[a870142](https://github.com/shuaiplus/nodewarden/commit/a870142)、[ebc8e8e](https://github.com/shuaiplus/nodewarden/commit/ebc8e8e)。
1. **网站图标行为和工作流维护。** 网站图标现在无需旧的 `WEBSITE_ICONS_ENABLED` 环境开关即可始终可用,同时图标请求仍保留隐私保护。global-domains 同步工作流也会在运行前校验引用。提交:[57c5ef9](https://github.com/shuaiplus/nodewarden/commit/57c5ef9)、[c643874](https://github.com/shuaiplus/nodewarden/commit/c643874)、[680e287](https://github.com/shuaiplus/nodewarden/commit/680e287)。
### 修复
1. **认证、令牌和速率限制加固。** API key 现在以哈希形式存储,密码轮换和 JWT 处理更严格,令牌处理时会使用户缓存失效,错误密码不会丢失已记住的 2FA token,当前访问令牌会被正确撤销,并关闭了已知的速率限制重置绕过路径。提交:[1545881](https://github.com/shuaiplus/nodewarden/commit/1545881)、[439683d](https://github.com/shuaiplus/nodewarden/commit/439683d)、[60dd298](https://github.com/shuaiplus/nodewarden/commit/60dd298)、[d9a36fe](https://github.com/shuaiplus/nodewarden/commit/d9a36fe)、[1bad32f](https://github.com/shuaiplus/nodewarden/commit/1bad32f)、[2df43cc](https://github.com/shuaiplus/nodewarden/commit/2df43cc)、[ae168be](https://github.com/shuaiplus/nodewarden/commit/ae168be)。
1. **用户数据隔离和请求校验。** 存储读取现在按用户限定范围,Send 文件路由更严格地拦截访问,匿名通知 hub 请求会被校验,并且多段备份/上传请求增加了上限。这降低了跨用户数据读取或超大请求进入深层处理器的风险。提交:[baf5699](https://github.com/shuaiplus/nodewarden/commit/baf5699)、[8c481a1](https://github.com/shuaiplus/nodewarden/commit/8c481a1)、[23c53bd](https://github.com/shuaiplus/nodewarden/commit/23c53bd)、[5142846](https://github.com/shuaiplus/nodewarden/commit/5142846)。
1. **备份、恢复和下载安全性。** 远端备份删除现在会被验证,归档和备份 blob 使用前会校验,目标配置里的密钥会在设置响应中脱敏,备份/下载令牌流程更难被误用,WebAuthn 凭据用途也会在备份导出/导入中保留。备份上传器的重定向防护也已回退,以恢复兼容的远端行为。提交:[0cef6a0](https://github.com/shuaiplus/nodewarden/commit/0cef6a0)、[00e0ec0](https://github.com/shuaiplus/nodewarden/commit/00e0ec0)、[5c8f01b](https://github.com/shuaiplus/nodewarden/commit/5c8f01b)、[cc4a830](https://github.com/shuaiplus/nodewarden/commit/cc4a830)、[f532d3a](https://github.com/shuaiplus/nodewarden/commit/f532d3a)、[a366acb](https://github.com/shuaiplus/nodewarden/commit/a366acb)。
1. **导入兼容性和加密字段校验。** 导入流程现在会在处理前校验 payload 结构和 ZIP 条目,明文 FIDO2 凭据、SSH 密钥和密码历史字段会被拒绝,而不是先被静默接受再在响应时丢弃。这让失败导入更清楚,也保护加密保险库字段不接收不兼容的明文数据。提交:[cf14704](https://github.com/shuaiplus/nodewarden/commit/cf14704)、[1ec6ed4](https://github.com/shuaiplus/nodewarden/commit/1ec6ed4)。
1. **管理员、审计、WebAuthn 和备份端点边界情况。** 管理员清空审计日志会被记录,通行密钥 2FA 状态会正确上报,WebAuthn 扩展来源会受到限制,并且认证请求与备份端点围绕敏感流程的校验也更严格。提交:[d028b19](https://github.com/shuaiplus/nodewarden/commit/d028b19)、[ace00e8](https://github.com/shuaiplus/nodewarden/commit/ace00e8)、[7ac6ae5](https://github.com/shuaiplus/nodewarden/commit/7ac6ae5)。
---
# v1.7.2 - New Backup Providers, WebAuthn PRF, and UI Polish
### Added
1. **Three new S3-compatible backup providers: Backblaze B2, Cloudflare R2, and Tigris.** Each new destination comes with detailed provider-specific recommendations, storage-class guidance, and localization strings across all five supported languages. You can now back up to more services without custom scripting. Commits: [1acc31e](https://github.com/shuaiplus/nodewarden/commit/1acc31e), [c3dc53b](https://github.com/shuaiplus/nodewarden/commit/c3dc53b), [ff85698](https://github.com/shuaiplus/nodewarden/commit/ff85698).
2. **WebAuthn PRF (pseudorandom function) extension support.** Credential creation and assertion now pass browser-compatible PRF extension requests, support excluding PRF extensions where the client doesn't need them, and handle the underlying passkey operations more robustly. This improves WebAuthn compatibility with modern browsers and password managers that rely on PRF for per-credential keys. Commits: [8942e5b](https://github.com/shuaiplus/nodewarden/commit/8942e5b), [31cfd19](https://github.com/shuaiplus/nodewarden/commit/31cfd19), [6a1a835](https://github.com/shuaiplus/nodewarden/commit/6a1a835), [bf6ac7b](https://github.com/shuaiplus/nodewarden/commit/bf6ac7b).
3. **Backup import locking and checksum verification.** Restoring a full backup now acquires an exclusive lock so concurrent imports cannot collide, and the importer verifies file checksums before applying the data. This makes disaster recovery safer when multiple admins might trigger restores. Commit: [e9272ec](https://github.com/shuaiplus/nodewarden/commit/e9272ec).
4. **Fullscreen layout toggle.** The web vault can now switch to fullscreen mode with a dedicated toggle button, with corresponding localization updates. Useful for kiosk-mode or presentation setups. Commit: [d722815](https://github.com/shuaiplus/nodewarden/commit/d722815).
5. **Fill-assist API handlers.** NodeWarden now implements Bitwarden-compatible credential fill-assist endpoints, letting clients fetch credentials inline via the new `POST /fill-assist` route. Device response types are also updated to include the fields needed by the fill-assist flow. Commit: [e4215b4](https://github.com/shuaiplus/nodewarden/commit/e4215b4).
6. **Device selection and removal in SecurityDevicesPage.** The security devices panel now supports selecting individual trusted devices and removing them directly from the web UI, so you no longer need to use the API to revoke a specific device. Commit: [a5ad16a](https://github.com/shuaiplus/nodewarden/commit/a5ad16a).
7. **Delete invalid organization invitations.** Admins can now detect and remove dangling or invalid invitations from the admin panel, helping keep the invitation list clean. The API also renamed `revokeInvite` to `deleteInvite` for clearer semantics. Commits: [0d1bb19](https://github.com/shuaiplus/nodewarden/commit/0d1bb19), [f82dcc3](https://github.com/shuaiplus/nodewarden/commit/f82dcc3).
8. **validFolderIds support in cipher responses.** Sync and cipher responses now include a `validFolderIds` field so clients can distinguish real folders from orphaned references. The folder repository also validates folder existence more strictly. Commit: [82f968e](https://github.com/shuaiplus/nodewarden/commit/82f968e).
9. **Pending auth request loading state.** The pending login-request panel shows a refreshing indicator while fetching or updating the request list, providing clearer feedback during auth request workflows. Commit: [4378e1b](https://github.com/shuaiplus/nodewarden/commit/4378e1b).
### Improved
1. **Enhanced Bitwarden CSV import with custom field and multiline support.** The CSV parser now recognizes custom fields and restores their metadata correctly during import. It also preserves multiline values such as SSH private keys—previously, any line without a `: ` delimiter was silently dropped, truncating private keys to the first line. Text fields containing newlines now survive a full export-import round-trip. Commits: [5eeaf4e](https://github.com/shuaiplus/nodewarden/commit/5eeaf4e), [68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0).
2. **Consolidated security devices UI.** Device management and authorized devices sections are merged into a single coherent card on SecurityDevicesPage, and the pending-auth-requests panel has been removed from the general SettingsPage to reduce clutter. The device list also includes improved selection controls. Commit: [c694f1b](https://github.com/shuaiplus/nodewarden/commit/c694f1b).
3. **Refined app-shell styles and dark mode consistency.** Removed redundant global styles, cleaned up shell component spacing, and improved dark-mode visual consistency across the header, sidebar, and main content areas. Commit: [1bfb9a6](https://github.com/shuaiplus/nodewarden/commit/1bfb9a6).
4. **Backup and restore error messages across all locales.** New error strings for backup/restore edge cases—lock failures, checksum mismatches, missing files—are now localized in all five supported languages (en, es, ru, zh-CN, zh-TW), with improved UI prompts for backup browser refresh scenarios. Commit: [4cd9ad0](https://github.com/shuaiplus/nodewarden/commit/4cd9ad0).
5. **Updated project wiki link and removed obsolete security scripts.** The issue-template wiki link now points to the correct URL, and the old local security scanning scripts and workflows have been removed in favor of GitHub-native security automation (CodeQL, security-extra workflows). Commit: [e31f82c](https://github.com/shuaiplus/nodewarden/commit/e31f82c).
6. **Security automation and dependency hardening.** Added GitHub-native CodeQL and security-extra workflows, overrode a `ws` vulnerability, and upgraded CI actions to pinned major versions (checkout v7, setup-node v6, create-pull-request v8). Dependencies refreshed include TypeScript 6.0, `@types/node` 26, `lucide-preact` 1.x, and many others across npm and GitHub Actions. Commits: [64f26e7](https://github.com/shuaiplus/nodewarden/commit/64f26e7), [32b3d2a](https://github.com/shuaiplus/nodewarden/commit/32b3d2a), [5dd9dff](https://github.com/shuaiplus/nodewarden/commit/5dd9dff), [8d292ca](https://github.com/shuaiplus/nodewarden/commit/8d292ca), [5bd7dab](https://github.com/shuaiplus/nodewarden/commit/5bd7dab), [99f2d7f](https://github.com/shuaiplus/nodewarden/commit/99f2d7f), [fb9a2ae](https://github.com/shuaiplus/nodewarden/commit/fb9a2ae), [c87e6ac](https://github.com/shuaiplus/nodewarden/commit/c87e6ac).
### Fixed
1. **CSV import truncating multiline field values.** `parseBitwardenCsvFieldLines` previously discarded any line that did not contain a `: ` delimiter, silently dropping SSH private keys and other multiline content to only the first line. The parser now accumulates continuation lines correctly, restoring full private key content through a CSV round-trip. Commit: [68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0).
---
### 新增
1. **三个新的 S3 兼容备份提供商:Backblaze B2、Cloudflare R2 和 Tigris。** 每个新目标都带有详细的提供商建议、存储层级指导和五种语言的本地化字符串,无需额外脚本即可将备份扩展到更多存储服务。提交:[1acc31e](https://github.com/shuaiplus/nodewarden/commit/1acc31e)、[c3dc53b](https://github.com/shuaiplus/nodewarden/commit/c3dc53b)、[ff85698](https://github.com/shuaiplus/nodewarden/commit/ff85698)。
2. **WebAuthn PRF(伪随机函数)扩展支持。** 创建和断言凭证时会传递浏览器兼容的 PRF 扩展请求,支持在不需要时排除 PRF 扩展,并且底层密钥操作更健壮。这改善了与依赖 PRF 做每凭据密钥派生功能的现代浏览器和密码管理器的兼容性。提交:[8942e5b](https://github.com/shuaiplus/nodewarden/commit/8942e5b)、[31cfd19](https://github.com/shuaiplus/nodewarden/commit/31cfd19)、[6a1a835](https://github.com/shuaiplus/nodewarden/commit/6a1a835)、[bf6ac7b](https://github.com/shuaiplus/nodewarden/commit/bf6ac7b)。
3. **备份导入加锁和校验和验证。** 完整恢复备份时现在会获取独占锁,防止并发导入冲突;导入前还会验证文件校验和再应用数据。多管理员可能同时触发恢复时,该机制让灾难恢复更加安全。提交:[e9272ec](https://github.com/shuaiplus/nodewarden/commit/e9272ec)。
4. **全屏布局切换。** Web 保险库现在可以通过专用按钮切换全屏模式,附带对应本地化更新。适合信息亭模式或展示等场景。提交:[d722815](https://github.com/shuaiplus/nodewarden/commit/d722815)。
5. **Fill-assist API 处理器。** NodeWarden 现在实现了与 Bitwarden 兼容的凭据填充辅助端点,客户端可以通过新的 `POST /fill-assist` 路由内联获取凭据。设备响应类型也补上了 fill-assist 流程需要的字段。提交:[e4215b4](https://github.com/shuaiplus/nodewarden/commit/e4215b4)。
6. **安全设备页的设备选择与删除。** 设备面板现在支持在 Web UI 中直接选择单个可信设备并移除,无需通过 API 手动撤销指定设备。提交:[a5ad16a](https://github.com/shuaiplus/nodewarden/commit/a5ad16a)。
7. **删除无效邀请码。** 管理员现在可以在管理面板中检测并删除悬空或无效的邀请,保持邀请列表整洁。API 也将 `revokeInvite` 改名为 `deleteInvite`,语义更清晰。提交:[0d1bb19](https://github.com/shuaiplus/nodewarden/commit/0d1bb19)、[f82dcc3](https://github.com/shuaiplus/nodewarden/commit/f82dcc3)。
8. **密码条目响应增加 validFolderIds。** 同步和密码条目响应现在包含 `validFolderIds` 字段,方便客户端区分真实文件夹和孤立引用;文件夹存储也加强了对文件夹存在性的校验。提交:[82f968e](https://github.com/shuaiplus/nodewarden/commit/82f968e)。
9. **待处理认证请求的加载状态。** 待处理的登录请求面板现在会在获取或更新请求列表时显示刷新指示器,为认证请求操作提供更清晰的反馈。提交:[4378e1b](https://github.com/shuaiplus/nodewarden/commit/4378e1b)。
### 改进
1. **增强的 Bitwarden CSV 导入——自定义字段和多行支持。** CSV 解析器现在可以识别自定义字段并在导入时正确恢复其元数据。同时保留了 SSH 私钥等多行值——之前任何不带 `: ` 分隔符的行都会被丢弃,导致私钥只保留第一行。包含换行符的文本字段现在可以完整通过导出-导入周期。提交:[5eeaf4e](https://github.com/shuaiplus/nodewarden/commit/5eeaf4e)、[68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0)。
2. **整合安全设备界面。** 设备管理和已授权设备两个部分合并为 SecurityDevicesPage 上的一个统一卡片;待处理认证请求面板从 SettingsPage 中移除以减少杂乱。设备列表也改进了选择操作。提交:[c694f1b](https://github.com/shuaiplus/nodewarden/commit/c694f1b)。
3. **精简应用外壳样式与暗色模式一致性。** 移除了冗余全局样式,清理了外壳组件间距,改善了头部、侧边栏和主内容区在暗色模式下的视觉一致性。提交:[1bfb9a6](https://github.com/shuaiplus/nodewarden/commit/1bfb9a6)。
4. **备份/恢复错误消息全语言本地化。** 备份/恢复边界场景(加锁失败、校验和不匹配、文件缺失)的新错误字符串已在五种支持语言(en、es、ru、zh-CN、zh-TW)中完成本地化,同时改进了备份浏览器刷新场景下的界面提示。提交:[4cd9ad0](https://github.com/shuaiplus/nodewarden/commit/4cd9ad0)。
5. **更新项目 Wiki 链接并移除过时安全脚本。** 议题模板中的 Wiki 链接已指向正确 URL;老旧的本地安全扫描脚本和工作流已移除,改用 GitHub 原生安全自动化(CodeQL、security-extra 工作流)。提交:[e31f82c](https://github.com/shuaiplus/nodewarden/commit/e31f82c)。
6. **安全自动化和依赖加固。** 新增 GitHub 原生 CodeQL 和 security-extra 工作流;覆盖了 `ws` 的已知漏洞;将 CI Action 升级到钉死的主要版本(checkout v7、setup-node v6、create-pull-request v8)。依赖升级包括 TypeScript 6.0、`@types/node` 26、`lucide-preact` 1.x,以及 npm 和 GitHub Actions 的多项更新。提交:[64f26e7](https://github.com/shuaiplus/nodewarden/commit/64f26e7)、[32b3d2a](https://github.com/shuaiplus/nodewarden/commit/32b3d2a)、[5dd9dff](https://github.com/shuaiplus/nodewarden/commit/5dd9dff)、[8d292ca](https://github.com/shuaiplus/nodewarden/commit/8d292ca)、[5bd7dab](https://github.com/shuaiplus/nodewarden/commit/5bd7dab)、[99f2d7f](https://github.com/shuaiplus/nodewarden/commit/99f2d7f)、[fb9a2ae](https://github.com/shuaiplus/nodewarden/commit/fb9a2ae)、[c87e6ac](https://github.com/shuaiplus/nodewarden/commit/c87e6ac)。
### 修复
1. **CSV 导入截断多行字段值。** `parseBitwardenCsvFieldLines` 之前会丢弃任何不包含 `: ` 分隔符的行,导致 SSH 私钥等多行内容被静默截断为仅第一行。解析器现已正确累积后续行,使私钥等完整内容能够通过 CSV 导出-导入周期完好保留。提交:[68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0)。
# v1.7.1 - Security Hardening Update
Thanks to GN998 for responsibly reporting security issues addressed in this release.
### Added
1. **No new user-facing features.** This patch release intentionally focuses on security fixes and defensive hardening rather than new product functionality. Commits: [7279668](https://github.com/shuaiplus/nodewarden/commit/7279668), [850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f), [a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c), [23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3).
### Improved
1. **Stronger security defaults.** NodeWarden now applies more conservative handling around sensitive authentication, backup, and file-delivery flows while keeping existing clients compatible. Upgrade is recommended for all deployments. Commits: [7279668](https://github.com/shuaiplus/nodewarden/commit/7279668), [850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f), [a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c), [23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3).
### Fixed
1. **High-priority security fixes.** This release closes multiple reported security issues across sensitive server-side flows and response hardening without exposing operational details in the public notes. Commits: [7279668](https://github.com/shuaiplus/nodewarden/commit/7279668), [850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f), [a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c), [23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3).
2. **Security dependency overrides.** Package overrides were added for selected transitive dependencies so installs resolve to patched versions where applicable. Commit: [0daad46](https://github.com/shuaiplus/nodewarden/commit/0daad46).
---
### 新增
感谢 GN998 负责任地报告了本次发布中修复的安全问题。
1. **没有新增面向用户的功能。** 本次补丁发布刻意专注于安全修复和防护加固,不包含新的产品功能。提交:[7279668](https://github.com/shuaiplus/nodewarden/commit/7279668)、[850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f)、[a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c)、[23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3)。
### 改进
1. **更稳妥的安全默认行为。** NodeWarden 对敏感认证、备份和文件响应流程采用了更保守的处理方式,同时保持现有客户端兼容。建议所有部署尽快升级。提交:[7279668](https://github.com/shuaiplus/nodewarden/commit/7279668)、[850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f)、[a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c)、[23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3)。
### 修复
1. **高优先级安全修复。** 本次发布修复了多项已报告的安全问题,覆盖敏感服务端流程和响应加固;公开说明中不会展开可操作的攻击细节。提交:[7279668](https://github.com/shuaiplus/nodewarden/commit/7279668)、[850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f)、[a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c)、[23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3)。
2. **安全依赖覆盖。** 为部分传递依赖添加了版本覆盖,让安装时尽可能解析到已修复版本。提交:[0daad46](https://github.com/shuaiplus/nodewarden/commit/0daad46)。
# v1.7.0 - Faster Multi-Device Sync, Mobile Push, and a Smoother Vault
### Added
1. **Resource-level realtime sync.** NodeWarden now sends Bitwarden-style notifications for cipher, folder, and Send create, update, and delete events. The web app can refresh only the affected resource instead of reloading the full vault every time, and state-changing operations such as attachment uploads, attachment deletes, public Send access counts, and Send file downloads also emit the right updates. Commits: [fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c), [42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b), [045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f), [46ba8b9](https://github.com/shuaiplus/nodewarden/commit/46ba8b9), [f096681](https://github.com/shuaiplus/nodewarden/commit/f096681).
2. **Bitwarden mobile push relay support.** Devices can now store `push_uuid` and `push_token`, register or unregister through the Bitwarden push relay, and receive mobile push notifications when vault resources change. The database schema includes the new push fields and indexes needed to detect push-capable devices. Commit: [79ed7c9](https://github.com/shuaiplus/nodewarden/commit/79ed7c9).
3. **Bitwarden CSV export.** The web app can now export a Bitwarden-compatible CSV file alongside the existing JSON, encrypted JSON, and attachment ZIP formats. Multiple login URIs are serialized safely, and non-login item types such as cards, identities, and SSH keys are preserved as clearly as possible in field text. Commits: [b024226](https://github.com/shuaiplus/nodewarden/commit/b024226), [a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e).
4. **More duplicate detection modes.** Duplicate search can now compare exact item content, login site plus credentials, username plus password, or password alone. Duplicate groups are color-coded, and the mobile vault UI includes the new duplicate mode selector and improved filter controls. Commits: [7b3be2c](https://github.com/shuaiplus/nodewarden/commit/7b3be2c), [b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f).
5. **S3 addressing style selection.** Backup destinations can now choose between path-style and virtual-hosted-style S3 URLs, improving compatibility with more S3-compatible providers and self-hosted object storage. Commit: [a818316](https://github.com/shuaiplus/nodewarden/commit/a818316).
### Improved
1. **Web vault updates feel immediate.** Creating, editing, deleting, archiving, restoring, and moving items; creating or deleting folders; and creating, updating, or deleting Sends now update the local encrypted snapshot, decrypted lists, and revision timestamp directly. This reduces visible lag after successful actions and makes cached vault validation work better with resource-level sync. Commits: [42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b), [045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f).
2. **Better Bitwarden client compatibility.** Profile and sync responses now include fields such as `organizationsNew`, `policiesNew`, and `V2UpgradeToken`; `/api/accounts/keys` supports GET; password change and password verification accept newer `authenticationData` and `unlockData` request shapes; and device routes work with both `/api/devices` and `/devices`. Cipher responses also preserve stored `edit`, `viewPassword`, and `permissions` flags instead of resetting them. Commits: [add921b](https://github.com/shuaiplus/nodewarden/commit/add921b), [f9fe532](https://github.com/shuaiplus/nodewarden/commit/f9fe532).
3. **Cleaner mobile and narrow-screen UI.** Topbar controls, network status, theme switching, and lock buttons now share more consistent sizing and styling. The vault list search, sorting, filtering, create button, and bulk selection toolbar are more compact on mobile, and mobile filter menus can switch between all items, favorites, archive, trash, duplicates, types, and folders. Commits: [7e0406f](https://github.com/shuaiplus/nodewarden/commit/7e0406f), [16bde22](https://github.com/shuaiplus/nodewarden/commit/16bde22), [cd2ec82](https://github.com/shuaiplus/nodewarden/commit/cd2ec82), [c1f5795](https://github.com/shuaiplus/nodewarden/commit/c1f5795).
4. **More tolerant TOTP handling.** TOTP codes are grouped more naturally for 5-digit, 6-digit, 8-digit, and other lengths, and the TOTP list no longer overflows narrow screens because of fixed column widths. `otpauth://` parsing is also more tolerant of unusual parameter encoding, with more stable Steam-code detection. Commits: [9e0908f](https://github.com/shuaiplus/nodewarden/commit/9e0908f), [d5c2ab2](https://github.com/shuaiplus/nodewarden/commit/d5c2ab2).
5. **Less jumpy network status.** The web app no longer switches offline after one short failed probe. It uses a longer timeout, waits for repeated failures, and lets normal API successes or failures update the network state, reducing false offline unlock fallbacks when the service is reachable but slow. Commit: [b4dfb04](https://github.com/shuaiplus/nodewarden/commit/b4dfb04).
6. **More complete backups.** Full instance backups now include trusted two-factor device tokens and restore them during import. The importer validates token ownership, device identifiers, expiration times, and duplicates, so remembered two-factor devices can survive a full migration. Commit: [f6169b7](https://github.com/shuaiplus/nodewarden/commit/f6169b7).
### Fixed
1. **Realtime notification correctness.** Resource notification type numbers now match Bitwarden semantics, while NodeWarden-specific device status and backup progress notifications use internal values to avoid conflicts with official Send update types. SignalR MessagePack invocations now include `streamIds`, pending auth request notifications refresh the login request list, and the web app ignores notifications sent by the current device to avoid redundant refreshes. Commits: [fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c), [9a21504](https://github.com/shuaiplus/nodewarden/commit/9a21504), [4900de0](https://github.com/shuaiplus/nodewarden/commit/4900de0).
2. **Attachment and Send download details.** Public attachment and Send file downloads now include `Content-Disposition` filenames and `X-Content-Type-Options: nosniff`, making browser downloads keep better filenames and reducing content sniffing issues. Attachment delete responses now include both uppercase and lowercase field forms for broader client compatibility. Commit: [add921b](https://github.com/shuaiplus/nodewarden/commit/add921b).
3. **Deleted item and bulk action edge cases.** Vault paging now detects deleted items from both database columns and older JSON payload fields, preventing old deleted items from appearing in the normal vault list. Bulk archive skips deleted items, and duplicate detection now uses decrypted password history instead of encrypted stored text. Commits: [add921b](https://github.com/shuaiplus/nodewarden/commit/add921b), [b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f).
4. **Export, dialog, and toast polish.** CSV export now escapes login URIs correctly inside a single CSV cell; some dialog dismissal behavior is more stable; login and unlock success toasts are less noisy; and the toast close button now uses a styled SVG icon. Commits: [b024226](https://github.com/shuaiplus/nodewarden/commit/b024226), [a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e), [8f2704f](https://github.com/shuaiplus/nodewarden/commit/8f2704f), [907126d](https://github.com/shuaiplus/nodewarden/commit/907126d).
5. **S3 backup URL construction.** Virtual-hosted-style backup operations now use the `bucket.endpoint` form for upload, download, delete, and existence checks, while avoiding duplicate bucket names when the endpoint already includes the bucket. Path-style mode keeps the existing `endpoint/bucket` behavior. Commit: [a818316](https://github.com/shuaiplus/nodewarden/commit/a818316).
---
### 新增
1. **资源级实时同步。** NodeWarden 现在会按 Bitwarden 风格发送密码条目、文件夹、Send 的新增、更新和删除通知。Web 端收到通知后可以只刷新受影响的资源,而不是每次都重新同步整个保险库;附件上传、附件删除、公开 Send 访问计数、Send 文件下载等会改变状态的操作,也会触发对应更新。提交:[fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c)、[42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b)、[045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f)、[46ba8b9](https://github.com/shuaiplus/nodewarden/commit/46ba8b9)、[f096681](https://github.com/shuaiplus/nodewarden/commit/f096681)。
2. **Bitwarden 移动端推送中继支持。** 设备现在可以保存 `push_uuid``push_token`,通过 Bitwarden push relay 注册或注销,并在保险库资源变化时尝试接收移动端推送。数据库结构也补上了推送字段和索引,用于识别哪些设备可以被推送。提交:[79ed7c9](https://github.com/shuaiplus/nodewarden/commit/79ed7c9)。
3. **Bitwarden CSV 导出。** 除了原有 JSON、加密 JSON 和带附件 ZIP 导出,现在 Web 端可以直接导出 Bitwarden 兼容 CSV。多个登录 URI 会按 CSV 规则安全序列化,卡片、身份、SSH Key 等非登录类型也会尽量保留到字段文本中,方便迁移或人工整理。提交:[b024226](https://github.com/shuaiplus/nodewarden/commit/b024226)、[a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e)。
4. **更多重复项检测模式。** 重复项现在可以按完全一致、登录站点加凭据、用户名加密码、单独密码等方式判断。重复组会用颜色辅助区分,移动端保险库也补上了重复项模式选择和更完整的筛选入口。提交:[7b3be2c](https://github.com/shuaiplus/nodewarden/commit/7b3be2c)、[b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f)。
5. **S3 地址样式选择。** 远程备份目标现在可以选择 path-style 或 virtual-hosted-style,兼容更多 S3 服务和自建对象存储。提交:[a818316](https://github.com/shuaiplus/nodewarden/commit/a818316)。
### 改进
1. **Web 保险库操作反馈更及时。** 创建、编辑、删除、归档、恢复、移动条目,创建或删除文件夹,以及创建、更新、删除 Send 时,前端会直接更新本地加密快照、解密列表和修订时间。这样操作成功后列表更快跟上,也让资源级同步下的本地缓存校验更稳定。提交:[42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b)、[045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f)。
2. **Bitwarden 客户端兼容性更好。** 账户资料和同步响应补齐了 `organizationsNew``policiesNew``V2UpgradeToken` 等字段;`/api/accounts/keys` 支持 GET;改密和校验密码接口兼容较新的 `authenticationData``unlockData` 请求结构;设备路由同时兼容 `/api/devices``/devices`。密码条目响应也会保留已存储的 `edit``viewPassword``permissions`,避免跨客户端编辑时权限标记被重置。提交:[add921b](https://github.com/shuaiplus/nodewarden/commit/add921b)、[f9fe532](https://github.com/shuaiplus/nodewarden/commit/f9fe532)。
3. **移动端和小屏界面更顺手。** 顶部栏按钮、网络状态、主题切换、锁定按钮的尺寸和样式更统一。保险库列表里的搜索、排序、筛选、创建按钮和批量选择工具栏在移动端更紧凑;移动筛选菜单可以直接切换全部、收藏、归档、回收站、重复项、类型和文件夹。提交:[7e0406f](https://github.com/shuaiplus/nodewarden/commit/7e0406f)、[16bde22](https://github.com/shuaiplus/nodewarden/commit/16bde22)、[cd2ec82](https://github.com/shuaiplus/nodewarden/commit/cd2ec82)、[c1f5795](https://github.com/shuaiplus/nodewarden/commit/c1f5795)。
4. **TOTP 展示和解析更稳。** 验证码会按 5 位、6 位、8 位等不同长度更自然地分组,列表在窄屏下不会再被固定列宽撑破。`otpauth://` 解析也更能容忍特殊参数编码,Steam 类验证码识别更稳定。提交:[9e0908f](https://github.com/shuaiplus/nodewarden/commit/9e0908f)、[d5c2ab2](https://github.com/shuaiplus/nodewarden/commit/d5c2ab2)。
5. **网络状态不再过度敏感。** Web 端不会因为一次短暂探测失败就立刻判定离线,而是延长探测超时并等待连续失败;普通 API 请求成功或失败也会反向更新网络状态。在线但网络较慢时,不容易误进入离线解锁流程。提交:[b4dfb04](https://github.com/shuaiplus/nodewarden/commit/b4dfb04)。
6. **备份内容更完整。** 完整实例备份现在会导出和还原可信二步验证设备令牌。导入时会校验令牌所属用户、设备标识、过期时间和重复项,让“记住此设备”的二步验证状态在完整迁移后也能保留下来。提交:[f6169b7](https://github.com/shuaiplus/nodewarden/commit/f6169b7)。
### 修复
1. **实时通知类型和刷新逻辑。** 资源通知的类型编号调整为与 Bitwarden 官方语义一致,NodeWarden 自定义的设备状态和备份进度通知改用内部编号,避免和官方 Send 更新类型冲突。SignalR MessagePack 调用补齐了 `streamIds`,认证请求通知会刷新待处理登录请求列表,Web 端也会忽略当前设备自己发出的通知,避免重复刷新。提交:[fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c)、[9a21504](https://github.com/shuaiplus/nodewarden/commit/9a21504)、[4900de0](https://github.com/shuaiplus/nodewarden/commit/4900de0)。
2. **附件和 Send 文件下载细节。** 公开附件和 Send 文件下载现在会带上 `Content-Disposition` 文件名和 `X-Content-Type-Options: nosniff`,浏览器保存文件时更接近原文件名,也减少类型嗅探问题。删除附件的响应同时提供大小写两套字段,兼容不同客户端读取方式。提交:[add921b](https://github.com/shuaiplus/nodewarden/commit/add921b)。
3. **已删除条目和批量操作边界。** 保险库分页查询现在会同时识别数据库列和历史 JSON 数据里的删除时间,避免旧数据中已删除条目出现在正常列表。批量归档会跳过已删除条目,重复项判断也会使用已解密的密码历史,避免加密文本影响结果。提交:[add921b](https://github.com/shuaiplus/nodewarden/commit/add921b)、[b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f)。
4. **导出、弹窗和提示细节。** CSV 导出中的登录 URI 会按单行 CSV 单元格正确转义;部分弹窗关闭行为更稳定;登录或解锁成功后的 toast 更克制,避免重复提示;toast 关闭按钮换成了 SVG 图标并调整了样式。提交:[b024226](https://github.com/shuaiplus/nodewarden/commit/b024226)、[a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e)、[8f2704f](https://github.com/shuaiplus/nodewarden/commit/8f2704f)、[907126d](https://github.com/shuaiplus/nodewarden/commit/907126d)。
5. **S3 备份地址拼接。** 选择 virtual-hosted-style 时,备份上传、下载、删除和存在性检查会使用 `bucket.endpoint` 形式;如果 endpoint 已经带有 bucket,也不会重复拼接 bucket。path-style 仍保持原有 `endpoint/bucket` 形式。提交:[a818316](https://github.com/shuaiplus/nodewarden/commit/a818316)。
+5
View File
@@ -132,6 +132,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
device_identifier TEXT, device_identifier TEXT,
device_session_stamp TEXT, device_session_stamp TEXT,
security_stamp TEXT,
created_at INTEGER,
last_used_at INTEGER,
absolute_expires_at INTEGER,
client_type TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
+447 -391
View File
File diff suppressed because it is too large Load Diff
+20 -14
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.3", "version": "1.8.0",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus", "author": "shuaiplus",
"license": "LGPL-3.0", "license": "LGPL-3.0",
@@ -15,6 +15,11 @@
"domains:sync": "node scripts/sync-global-domains.mjs", "domains:sync": "node scripts/sync-global-domains.mjs",
"i18n": "node scripts/i18n-validate.cjs", "i18n": "node scripts/i18n-validate.cjs",
"i18n:validate": "node scripts/i18n-validate.cjs", "i18n:validate": "node scripts/i18n-validate.cjs",
"test:config-compatibility": "tsx --test scripts/config-compatibility.test.ts",
"test:web-crypto": "tsx --test scripts/web-crypto-availability.test.ts",
"test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs",
"test:webauthn-connector": "node --test scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
"test:webauthn-connectors": "node --test scripts/webauthn-mobile-connector.test.mjs scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
"deploy": "wrangler deploy", "deploy": "wrangler deploy",
"deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml", "deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml",
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo" "deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
@@ -46,30 +51,31 @@
"undici": ">=7.28.0", "undici": ">=7.28.0",
"@babel/core": ">=7.29.6", "@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1", "esbuild": ">=0.28.1",
"ws": "8.21.0" "ws": "8.21.0",
"sharp": "0.35.0"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260630.1", "@cloudflare/workers-types": "^4.20260630.1",
"@preact/preset-vite": "^2.10.5", "@preact/preset-vite": "^2.10.6",
"@types/node": "^26.0.1", "@types/node": "^26.1.2",
"autoprefixer": "^10.5.2", "autoprefixer": "^10.5.4",
"opencc-js": "^1.3.2", "opencc-js": "^1.4.1",
"postcss": "^8.5.16", "postcss": "^8.5.25",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"tsx": "^4.22.4", "tsx": "^4.23.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.1.3", "vite": "^8.2.0",
"wrangler": "^4.105.0" "wrangler": "^4.116.0"
}, },
"dependencies": { "dependencies": {
"@noble/hashes": "^2.2.0", "@noble/hashes": "^2.2.0",
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.4",
"@zip.js/zip.js": "^2.8.26", "@zip.js/zip.js": "^2.8.34",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"jsqr": "1.4.0", "jsqr": "1.4.0",
"lucide-preact": "^1.22.0", "lucide-preact": "^1.28.0",
"preact": "^10.29.3", "preact": "^10.29.7",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"wouter": "^3.10.0" "wouter": "^3.10.0"
} }
+12
View File
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildConfigResponse } from '../src/config-response';
test('config enables the official Bitwarden desktop settings dialog', () => {
const body = buildConfigResponse('https://vault.example.test');
assert.equal(body.featureStates['desktop-ui-settings-dialog'], true);
assert.equal(body.environment.vault, 'https://vault.example.test');
assert.equal(body.object, 'config');
});
@@ -0,0 +1,128 @@
import { handleGetApiKey, handleRotateApiKey } from '../src/handlers/accounts.ts';
import { hashApiKey, verifyApiKey } from '../src/utils/api-key.ts';
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function createUserRow(apiKey) {
return {
id: 'user-1',
email: 'user@example.com',
name: 'User',
master_password_hint: null,
master_password_hash: 'master-proof',
key: 'wrapped-user-key',
private_key: null,
public_key: null,
kdf_type: 0,
kdf_iterations: 600000,
kdf_memory: null,
kdf_parallelism: null,
security_stamp: 'security-stamp-original',
role: 'user',
status: 'active',
verify_devices: 0,
totp_secret: null,
totp_recovery_code: null,
yubikey_key1: null,
yubikey_key2: null,
yubikey_key3: null,
yubikey_key4: null,
yubikey_key5: null,
yubikey_nfc: 0,
api_key: apiKey,
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
};
}
function createDb(apiKey) {
const state = {
user: createUserRow(apiKey),
userWrites: 0,
refreshDeletes: 0,
auditActions: [],
};
const db = {
prepare(sql) {
let bindings = [];
const statement = {
bind(...values) {
bindings = values;
return statement;
},
async first() {
if (/FROM users WHERE id = \?/i.test(sql)) return { ...state.user };
return null;
},
async all() {
return { results: [] };
},
async run() {
if (/INSERT INTO users\(/i.test(sql)) {
state.userWrites += 1;
state.user.security_stamp = bindings[12];
state.user.api_key = bindings[24];
state.user.updated_at = bindings[26];
}
if (/DELETE FROM refresh_tokens/i.test(sql)) state.refreshDeletes += 1;
if (/INSERT INTO audit_logs/i.test(sql)) state.auditActions.push(bindings[2]);
return { meta: { changes: 1 } };
},
};
return statement;
},
async batch(statements) {
return statements.map(() => ({ success: true, meta: { changes: 1 } }));
},
};
return { db, state };
}
function request() {
return new Request('https://nodewarden.example/api/accounts/api-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash: 'master-proof' }),
});
}
function env(db) {
return { DB: db, JWT_SECRET: 'test-secret-at-least-thirty-two-characters' };
}
const view = createDb('ExistingReadableApiKey1234567');
const viewResponse = await handleGetApiKey(request(), env(view.db), 'user-1');
const viewBody = await viewResponse.json();
assert(viewResponse.status === 200, 'Viewing an existing readable API key failed');
assert(viewBody.apiKey === 'ExistingReadableApiKey1234567', 'View did not return the existing API key');
assert(view.state.userWrites === 0, 'View unexpectedly rewrote the user');
assert(view.state.refreshDeletes === 0, 'View unexpectedly revoked refresh tokens');
assert(view.state.auditActions.includes('account.api_key.view'), 'View audit action is missing');
const rotate = createDb('ExistingReadableApiKey1234567');
const rotateResponse = await handleRotateApiKey(request(), env(rotate.db), 'user-1');
const rotateBody = await rotateResponse.json();
assert(rotateResponse.status === 200, 'API key rotation failed');
assert(rotateBody.apiKey !== 'ExistingReadableApiKey1234567', 'Rotation returned the old API key');
assert(rotate.state.user.api_key === rotateBody.apiKey, 'Rotation did not persist the returned API key');
assert(rotate.state.user.security_stamp === 'security-stamp-original', 'Rotation changed securityStamp');
assert(rotate.state.refreshDeletes === 0, 'Rotation revoked unrelated refresh tokens');
assert(!(await verifyApiKey('ExistingReadableApiKey1234567', rotate.state.user.api_key)), 'Old API key still authenticates');
assert(await verifyApiKey(rotateBody.apiKey, rotate.state.user.api_key), 'Rotated API key does not authenticate');
const legacyPlain = 'LegacyHashedApiKey123456789';
const legacy = createDb(await hashApiKey(legacyPlain));
const legacyResponse = await handleGetApiKey(request(), env(legacy.db), 'user-1');
assert(legacyResponse.status === 409, 'Legacy hashed key view should require explicit rotation');
assert(legacy.state.userWrites === 0, 'Legacy hashed key was silently rotated');
assert(await verifyApiKey(legacyPlain, legacy.state.user.api_key), 'Legacy hashed API key stopped authenticating');
const missing = createDb(null);
const missingResponse = await handleGetApiKey(request(), env(missing.db), 'user-1');
const missingBody = await missingResponse.json();
assert(missingResponse.status === 200 && !!missingBody.apiKey, 'Missing legacy API key was not initialized');
assert(missing.state.userWrites === 1, 'Missing legacy API key initialization was not persisted');
console.log('Bitwarden-compatible API key view and rotation semantics: PASS');
@@ -0,0 +1,138 @@
import { unzipSync, zipSync } from 'fflate';
import {
buildBackupArchive,
parseBackupArchive,
validateBackupPayloadContents,
} from '../src/services/backup-archive.ts';
import { importBackupArchiveBytes } from '../src/services/backup-import.ts';
const forbiddenRuntimeTables = [
'devices',
'refresh_tokens',
'auth_requests',
'trusted_two_factor_device_tokens',
'account_passkey_challenges',
'used_attachment_download_tokens',
];
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function sqlTouchesTable(sql, table) {
return new RegExp(`\\b(?:from|into|table)\\s+[\"']?${table}\\b`, 'i').test(sql);
}
function emptyBackupDb(extra = {}) {
return {
config: [],
users: [],
domain_settings: [],
user_revisions: [],
folders: [],
ciphers: [],
attachments: [],
webauthn_credentials: [],
...extra,
};
}
function archiveBytes(db, tableCounts = {}) {
const encoder = new TextEncoder();
return zipSync({
'manifest.json': encoder.encode(JSON.stringify({
formatVersion: 1,
exportedAt: new Date(0).toISOString(),
appVersion: 'test',
storageKind: null,
tableCounts,
includes: { attachments: false },
blobSummary: { attachmentFiles: 0, totalBytes: 0, largestObjectBytes: 0 },
attachmentBlobs: [],
})),
'db.json': encoder.encode(JSON.stringify(db)),
}, { level: 0 });
}
function createD1Mock({ exportMode = false } = {}) {
const preparedSql = [];
const db = {
prepare(sql) {
preparedSql.push(sql);
let bindings = [];
const statement = {
sql,
bind(...values) {
bindings = values;
return statement;
},
async all() {
if (exportMode) return { results: [] };
return { results: [] };
},
async first() {
if (/SELECT sql FROM sqlite_master/i.test(sql)) {
const table = String(bindings[0] || '').trim();
return { sql: `CREATE TABLE ${table} (id TEXT)` };
}
if (/SELECT COUNT\(\*\).*FROM config__restore/i.test(sql)) return { count: 1 };
if (/SELECT COUNT\(\*\)/i.test(sql)) return { count: 0 };
return null;
},
async run() {
return { meta: { changes: 0 } };
},
};
return statement;
},
async batch(statements) {
return statements.map(() => ({ success: true, meta: { changes: 0 } }));
},
};
return { db, preparedSql };
}
const exportMock = createD1Mock({ exportMode: true });
const exported = await buildBackupArchive({ DB: exportMock.db }, new Date(0), { includeAttachments: false });
const exportedZip = unzipSync(exported.bytes);
const exportedManifest = JSON.parse(new TextDecoder().decode(exportedZip['manifest.json']));
const exportedDb = JSON.parse(new TextDecoder().decode(exportedZip['db.json']));
for (const table of forbiddenRuntimeTables) {
assert(!(table in exportedDb), `Export contains forbidden runtime table: ${table}`);
assert(!(table in exportedManifest.tableCounts), `Manifest counts forbidden runtime table: ${table}`);
assert(!exportMock.preparedSql.some((sql) => sqlTouchesTable(sql, table)), `Export queried forbidden runtime table: ${table}`);
}
const legacyDb = emptyBackupDb({
devices: [{ device_identifier: 'device-secret' }],
refresh_tokens: [{ token: 'refresh-secret' }],
auth_requests: [{ access_code: 'approval-secret' }],
trusted_two_factor_device_tokens: [{ token: 'remember-secret' }],
account_passkey_challenges: [{ challenge_hash: 'challenge-secret' }],
used_attachment_download_tokens: [{ token_hash: 'download-secret' }],
});
const legacyArchive = archiveBytes(legacyDb, {
devices: 1,
refresh_tokens: 1,
auth_requests: 1,
trusted_two_factor_device_tokens: 1,
account_passkey_challenges: 1,
used_attachment_download_tokens: 1,
});
const parsedLegacy = parseBackupArchive(legacyArchive);
validateBackupPayloadContents(parsedLegacy.payload, parsedLegacy.files);
for (const table of forbiddenRuntimeTables) {
assert(!(table in parsedLegacy.payload.db), `Legacy runtime table was not ignored: ${table}`);
}
const restoreMock = createD1Mock();
await importBackupArchiveBytes(legacyArchive, { DB: restoreMock.db }, 'actor', false);
for (const table of forbiddenRuntimeTables) {
assert(
!restoreMock.preparedSql.some((sql) => sqlTouchesTable(sql, table)),
`Restore touched forbidden runtime table: ${table}`
);
}
console.log('backup runtime authentication state exclusion: PASS');
@@ -0,0 +1,38 @@
import { normalizeBackupEndpointUrl } from '../src/services/backup-config.ts';
import fs from 'node:fs';
const scratch = process.env.SCRATCH || '.';
const cases = [
'http://127.0.0.1',
'http://169.254.169.254',
'http://[::1]',
'http://[0:0:0:0:0:0:0:1]',
'http://[::2]',
'http://[::]',
'http://[fe80::1]',
'http://[fc00::1]',
'https://example.com',
];
const out = [];
for (const url of cases) {
try {
const normalized = normalizeBackupEndpointUrl(url, 'WebDAV server URL');
out.push({ url, allowed: true, normalized });
} catch (e) {
out.push({ url, allowed: false, error: e instanceof Error ? e.message : String(e) });
}
}
const path = `${scratch}/poc-normalizeBackupEndpointUrl.json`;
fs.writeFileSync(path, JSON.stringify(out, null, 2));
console.log(JSON.stringify(out, null, 2));
// Security expectation: IPv6 loopback must NOT be allowed.
const loopback = out.find((row) => row.url === 'http://[::1]');
if (loopback?.allowed) {
console.error('FINDING_CONFIRMED: normalizeBackupEndpointUrl accepts http://[::1]');
process.exitCode = 2;
} else {
console.log('IPv6 loopback rejected as expected');
}
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerAccount } from '../webapp/src/lib/api/auth';
import {
requireWebCrypto,
WebCryptoUnavailableError,
} from '../webapp/src/lib/crypto';
const supportedCrypto = {
subtle: {
importKey: () => Promise.reject(new Error('not used by capability checks')),
},
getRandomValues: <T>(array: T): T => array,
} as unknown as Crypto;
function restoreGlobalProperty(name: string, descriptor: PropertyDescriptor | undefined): void {
if (descriptor) {
Object.defineProperty(globalThis, name, descriptor);
return;
}
delete (globalThis as unknown as Record<string, unknown>)[name];
}
test('Web Crypto guard rejects insecure browser contexts', () => {
assert.throws(
() => requireWebCrypto({ crypto: supportedCrypto, isSecureContext: false }),
WebCryptoUnavailableError
);
});
test('Web Crypto guard rejects secure contexts without SubtleCrypto', () => {
const cryptoWithoutSubtle = {
getRandomValues: <T>(array: T): T => array,
} as unknown as Crypto;
assert.throws(
() => requireWebCrypto({ crypto: cryptoWithoutSubtle, isSecureContext: true }),
WebCryptoUnavailableError
);
});
test('Web Crypto guard accepts a secure supported browser', () => {
assert.equal(
requireWebCrypto({ crypto: supportedCrypto, isSecureContext: true }),
supportedCrypto
);
});
test('registration returns an actionable error without contacting the backend', async () => {
const cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
const secureContextDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'isSecureContext');
const fetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch');
let fetchCalled = false;
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true });
Object.defineProperty(globalThis, 'isSecureContext', { value: false, configurable: true });
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
value: async () => {
fetchCalled = true;
return new Response(null, { status: 500 });
},
});
try {
const result = await registerAccount({
email: 'first@example.test',
name: 'First Admin',
password: 'correct horse battery staple',
fallbackIterations: 600_000,
});
assert.deepEqual(result, {
ok: false,
message: 'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.',
});
assert.equal(fetchCalled, false);
} finally {
restoreGlobalProperty('crypto', cryptoDescriptor);
restoreGlobalProperty('isSecureContext', secureContextDescriptor);
restoreGlobalProperty('fetch', fetchDescriptor);
}
});
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import type { Env } from '../src/types';
import { getConfiguredWebAuthnAllowedOrigins } from '../src/utils/origins';
import { applyCors, handleCors } from '../src/utils/response';
const env = {} as Env;
test('only the iframe connector drops anti-framing headers', () => {
const connectorRequest = new Request('https://vault.example.test/webauthn-connector.html');
const connector = applyCors(connectorRequest, new Response('<!doctype html>'), env);
assert.equal(connector.headers.get('X-Frame-Options'), null);
assert.doesNotMatch(connector.headers.get('Content-Security-Policy') || '', /frame-ancestors/);
assert.match(connector.headers.get('Content-Security-Policy') || '', /script-src 'self'/);
for (const path of ['/', '/webauthn-fallback-connector.html', '/webauthn-mobile-connector.html']) {
const request = new Request(`https://vault.example.test${path}`);
const response = applyCors(request, new Response('<!doctype html>'), env);
assert.equal(response.headers.get('X-Frame-Options'), 'DENY');
assert.match(response.headers.get('Content-Security-Policy') || '', /frame-ancestors 'none'/);
}
});
test('official Bitwarden desktop origin receives credentialed CORS', () => {
assert.ok(getConfiguredWebAuthnAllowedOrigins(env).includes('bw-desktop-file://bundle'));
const preflight = handleCors(new Request('https://vault.example.test/api/sync', {
method: 'OPTIONS',
headers: {
Origin: 'bw-desktop-file://bundle',
'Access-Control-Request-Headers': 'authorization, content-type',
},
}), env);
assert.equal(preflight.headers.get('Access-Control-Allow-Origin'), 'bw-desktop-file://bundle');
assert.equal(preflight.headers.get('Access-Control-Allow-Credentials'), 'true');
});
test('Worker assets preserve exact official connector .html paths', async () => {
for (const configUrl of [
new URL('../wrangler.toml', import.meta.url),
new URL('../wrangler.kv.toml', import.meta.url),
]) {
const config = await readFile(configUrl, 'utf8');
const assetsSection = config.match(/\[assets\]([\s\S]*?)(?=\n\[|$)/)?.[1] || '';
assert.match(assetsSection, /^\s*html_handling\s*=\s*"none"\s*$/m);
}
});
+126
View File
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
buildCredentialData,
normalizePublicKeyOptions,
parseConnectorRequest,
resolveParentChannel,
} from '../webapp/public/webauthn-connector.js';
function encodeBase64Utf8(value) {
return Buffer.from(value, 'utf8').toString('base64');
}
const publicKeyOptions = {
challenge: 'AQID',
allowCredentials: [{ id: 'BAUG', type: 'public-key', transports: ['usb'] }],
timeout: 60000,
rpId: 'vault.example.test',
};
test('parses the official desktop/browser V1 connector request', () => {
const params = new URLSearchParams({
data: encodeBase64Utf8(JSON.stringify(publicKeyOptions)),
parent: encodeURIComponent('file:///C:/Program Files/Bitwarden/resources/app/index.html'),
btnText: encodeURIComponent('Read security key'),
btnAwaitingInteractionText: encodeURIComponent('Awaiting security key interaction...'),
v: '1',
});
const request = parseConnectorRequest(params);
assert.equal(request.parentUrl, 'file:///C:/Program Files/Bitwarden/resources/app/index.html');
assert.equal(request.parentProtocol, 'file:');
assert.deepEqual(JSON.parse(request.webauthnJson), publicKeyOptions);
assert.equal(request.buttonText, 'Read security key');
assert.equal(request.awaitingText, 'Awaiting security key interaction...');
});
test('keeps V2 parsing compatible with the shared official connector protocol', () => {
const params = new URLSearchParams({
data: encodeBase64Utf8(JSON.stringify({ data: JSON.stringify(publicKeyOptions) })),
parent: encodeURIComponent('chrome-extension://nngceckbapebfimnlniiiahkandclblb/popup/index.html'),
v: '2',
});
assert.deepEqual(JSON.parse(parseConnectorRequest(params).webauthnJson), publicKeyOptions);
});
test('normalizes WebAuthn challenge and allowed credential IDs', () => {
const normalized = normalizePublicKeyOptions(JSON.stringify(publicKeyOptions));
assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3]);
assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6]);
});
test('emits the exact assertion shape consumed by official Bitwarden clients', () => {
const output = JSON.parse(buildCredentialData({
id: 'credential-id',
rawId: Uint8Array.from([1, 2, 3]).buffer,
type: 'public-key',
getClientExtensionResults: () => ({ appid: false }),
response: {
authenticatorData: Uint8Array.from([4, 5]).buffer,
clientDataJSON: Uint8Array.from([6, 7]).buffer,
signature: Uint8Array.from([8, 9]).buffer,
},
}));
assert.deepEqual(output, {
id: 'credential-id',
rawId: 'AQID',
type: 'public-key',
extensions: { appid: false },
response: {
authenticatorData: 'BAU',
clientDataJson: 'Bgc',
signature: 'CAk',
},
});
});
test('accepts legacy file and current official desktop parent origins', () => {
assert.deepEqual(resolveParentChannel({
parentProtocol: 'file:',
parentUrl: 'file:///C:/Bitwarden/index.html',
}, 'https://vault.example.test'), {
eventOrigin: 'null',
targetOrigin: 'file:///C:/Bitwarden/index.html',
});
assert.deepEqual(resolveParentChannel({
parentProtocol: 'bw-desktop-file:',
parentUrl: 'bw-desktop-file://bundle/index.html',
}, 'https://vault.example.test'), {
eventOrigin: 'bw-desktop-file://bundle',
targetOrigin: 'bw-desktop-file://bundle/index.html',
});
});
test('accepts configured official extension origins and rejects arbitrary parents', () => {
const extension = 'chrome-extension://nngceckbapebfimnlniiiahkandclblb';
assert.deepEqual(resolveParentChannel({
parentProtocol: 'chrome-extension:',
parentUrl: `${extension}/popup/index.html`,
}, 'https://vault.example.test', [extension]), {
eventOrigin: extension,
targetOrigin: extension,
});
assert.throws(() => resolveParentChannel({
parentProtocol: 'https:',
parentUrl: 'https://attacker.example/frame',
}, 'https://vault.example.test', []), /Untrusted parent/);
});
test('uses the official postMessage message contract and iframe-sized fallback styling', async () => {
const [html, source, viteConfig] = await Promise.all([
readFile(new URL('../webapp/public/webauthn-connector.html', import.meta.url), 'utf8'),
readFile(new URL('../webapp/public/webauthn-connector.js', import.meta.url), 'utf8'),
readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8'),
]);
assert.match(html, /id="webauthn-button"/);
assert.match(html, /min-height:\s*40px/);
assert.match(html, /background:\s*#2563eb/);
assert.match(source, /post\('info\|ready'\)/);
assert.match(source, /post\(`success\|\$\{buildCredentialData\(credential\)\}`\)/);
assert.match(source, /post\(`error\|\$\{browserErrorMessage\(error\)\}`\)/);
assert.match(source, /event\.data === 'stop'/);
assert.match(source, /event\.data === 'start'/);
assert.match(viteConfig, /endsWith\('-connector\.html'\)/);
});
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
base64UrlFromBuffer,
buildCallbackUrl,
buildCredentialData,
decodeBase64Utf8,
normalizePublicKeyOptions,
parseConnectorRequest,
resolveMobileCallbackUri,
} from '../webapp/public/webauthn-mobile-connector.js';
function encodeBase64Utf8(value) {
return Buffer.from(value, 'utf8').toString('base64');
}
function v2Search(payload, extra = '') {
return `?data=${encodeURIComponent(encodeBase64Utf8(JSON.stringify(payload)))}&parent=bitwarden%3A__webauthn-callback&v=2${extra}`;
}
const assertionOptions = {
challenge: 'AQID-v8',
rpId: 'vault.example.com',
timeout: 60000,
userVerification: 'preferred',
allowCredentials: [{ id: 'BAUGBwg', type: 'public-key', transports: ['internal'] }],
};
test('parses the current Bitwarden Android V2 connector payload', () => {
const request = parseConnectorRequest(v2Search({
btnReturnText: 'Return to app', btnText: 'Authenticate', data: JSON.stringify(assertionOptions),
headerText: 'Verify your identity', mobile: true,
}, '&client=mobile&deeplinkScheme=bitwarden'), 'vault.example.com');
assert.equal(request.callbackUri, 'bitwarden://webauthn-callback');
assert.equal(request.headerText, 'Verify your identity');
assert.equal(request.buttonText, 'Authenticate');
assert.equal(request.returnButtonText, 'Return to app');
assert.deepEqual(JSON.parse(request.webauthnJson), assertionOptions);
});
test('uses callbackUri only as a signal and never as the redirect target', () => {
const trustedLooking = parseConnectorRequest(v2Search({
callbackUri: 'https://bitwarden.eu/webauthn-callback', data: assertionOptions,
}).replace('&parent=bitwarden%3A__webauthn-callback', ''));
const attacker = parseConnectorRequest(v2Search({
callbackUri: 'https://attacker.example/capture', data: assertionOptions,
}).replace('&parent=bitwarden%3A__webauthn-callback', ''));
assert.equal(trustedLooking.callbackUri, 'bitwarden://webauthn-callback');
assert.equal(attacker.callbackUri, 'bitwarden://webauthn-callback');
});
test('treats any non-HTTPS deeplinkScheme as the fixed Bitwarden custom scheme', () => {
const request = parseConnectorRequest(v2Search({ mobile: true, data: assertionOptions }, '&deeplinkScheme=untrusted'));
assert.equal(request.callbackUri, 'bitwarden://webauthn-callback');
});
test('supports Android custom-scheme and official HTTPS App Link callbacks', () => {
const payload = { mobile: true, data: assertionOptions };
const custom = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=bitwarden'));
const eu = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.bitwarden.eu');
const selfHosted = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.example.com');
assert.equal(custom.callbackUri, 'bitwarden://webauthn-callback');
assert.equal(eu.callbackUri, 'https://bitwarden.eu/webauthn-callback');
assert.equal(selfHosted.callbackUri, 'https://bitwarden.com/webauthn-callback');
});
test('supports V1 mobile requests and requires a recognized mobile signal', () => {
const encoded = encodeURIComponent(encodeBase64Utf8(JSON.stringify(assertionOptions)));
assert.equal(parseConnectorRequest(`?data=${encoded}&v=1&client=mobile`).callbackUri, 'bitwarden://webauthn-callback');
assert.equal(resolveMobileCallbackUri({ payload: {}, hostname: 'vault.example.com' }), null);
assert.throws(() => parseConnectorRequest(`?data=${encoded}&v=1`), /return target/i);
});
test('decodes UTF-8 and normalizes WebAuthn binary fields without mutation', () => {
assert.equal(decodeBase64Utf8(encodeBase64Utf8('验证身份')), '验证身份');
const original = structuredClone(assertionOptions);
const normalized = normalizePublicKeyOptions(original);
assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3, 250, 255]);
assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6, 7, 8]);
assert.deepEqual(original, assertionOptions);
});
test('serializes the exact assertion shape emitted by Bitwarden common-webauthn', () => {
const serialized = JSON.parse(buildCredentialData({
id: 'credential-id', rawId: Uint8Array.from([1, 2, 255]).buffer, type: 'public-key',
getClientExtensionResults: () => ({ appid: false }),
response: {
authenticatorData: Uint8Array.from([3, 4]).buffer,
clientDataJSON: Uint8Array.from([5, 6]).buffer,
signature: Uint8Array.from([7, 8]).buffer,
userHandle: Uint8Array.from([9, 10]).buffer,
},
}));
assert.deepEqual(serialized, {
id: 'credential-id',
rawId: 'AQL_',
type: 'public-key',
extensions: { appid: false },
response: { authenticatorData: 'AwQ', clientDataJson: 'BQY', signature: 'Bwg' },
});
assert.equal(base64UrlFromBuffer(Uint8Array.from([251, 255])), '-_8');
});
test('encodes success and error callbacks safely', () => {
assert.equal(buildCallbackUrl('bitwarden://webauthn-callback', 'data', '{"id":"a+b"}'), 'bitwarden://webauthn-callback?data=%7B%22id%22%3A%22a%2Bb%22%7D');
assert.equal(buildCallbackUrl('bitwarden://webauthn-callback?source=nodewarden', 'error', 'Not allowed'), 'bitwarden://webauthn-callback?source=nodewarden&error=Not%20allowed');
});
test('HTML matches the fallback connector visual structure', async () => {
const html = await readFile(new URL('../webapp/public/webauthn-mobile-connector.html', import.meta.url), 'utf8');
assert.match(html, /id="webauthn-header"/);
assert.match(html, /id="webauthn-button"/);
assert.match(html, /class="connector-card"/);
assert.match(html, /class="brand"/);
assert.match(html, /class="form"/);
assert.match(html, /class="msg"/);
assert.match(html, /src="\/nodewarden-logo\.svg"/);
assert.match(html, /src="\/webauthn-mobile-connector\.js"/);
assert.match(html, /default-src 'none'/);
});
test('runtime uses Bitwarden-compatible replacement navigation', async () => {
const source = await readFile(new URL('../webapp/public/webauthn-mobile-connector.js', import.meta.url), 'utf8');
assert.match(source, /window\.location\.replace\(uri\)/);
assert.doesNotMatch(source, /location\.assign/);
assert.doesNotMatch(source, /safeCallbackFromPayload/);
});
test('Service Worker keeps connector navigations out of the SPA shell', async () => {
const config = await readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8');
assert.match(config, /url\.pathname\.endsWith\('-connector\.html'\)/);
assert.match(config, /connectorNavigation\(request\)/);
assert.match(config, /WebAuthn connector is unavailable while offline/);
});
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.7.3'; export const APP_VERSION = '1.8.0';
+53
View File
@@ -0,0 +1,53 @@
import { LIMITS } from './config/limits';
function buildIconServiceTemplate(origin: string): string {
return `${origin}/icons/{}/icon.png`;
}
function buildIconServiceCsp(origin: string): string {
return `img-src 'self' data: ${origin}`;
}
export function buildConfigResponse(origin: string) {
const fillAssistBase = `${origin}/fill-assist/`;
return {
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
cloudRegion: 'self-hosted',
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
icons: origin,
sso: '',
fillAssistRules: fillAssistBase,
},
push: {
pushTechnology: 0,
vapidPublicKey: null,
},
communication: null,
settings: {
disableUserRegistration: false,
suppressOnboardingInterstitials: false,
},
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
'desktop-ui-settings-dialog': true,
'duo-redirect': true,
'email-verification': true,
'fill-assist-targeting-rules': true,
'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true,
'pm-4516-devices-add-last-activity-date': true,
'pm-30529-webauthn-related-origins': true,
'unauth-ui-refresh': true,
'web-push': false,
},
object: 'config',
};
}
+21 -7
View File
@@ -3,12 +3,14 @@
// Access token lifetime in seconds. // Access token lifetime in seconds.
// 访问令牌有效期(秒)。 // 访问令牌有效期(秒)。
accessTokenTtlSeconds: 7200, accessTokenTtlSeconds: 7200,
// Refresh token lifetime in milliseconds. // Refresh sessions use a reusable opaque token with a sliding idle lifetime.
// 刷新令牌有效期(毫秒) // 刷新会话使用可复用的随机令牌,并按客户端采用滑动空闲期限
refreshTokenTtlMs: 365 * 24 * 60 * 60 * 1000, refreshTokenWebSlidingTtlMs: 30 * 24 * 60 * 60 * 1000,
// Grace window for previous refresh token after rotation (ms). refreshTokenDefaultSlidingTtlMs: 30 * 24 * 60 * 60 * 1000,
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。 refreshTokenMobileSlidingTtlMs: 90 * 24 * 60 * 60 * 1000,
refreshTokenOverlapGraceMs: 30 * 60 * 1000, // Hard upper bound for one login session, regardless of sliding refreshes.
// 单次登录会话的绝对最长寿命,不因滑动续期突破该上限。
refreshTokenAbsoluteTtlMs: 365 * 24 * 60 * 60 * 1000,
// Refresh token random byte length. // Refresh token random byte length.
// 刷新令牌随机字节长度。 // 刷新令牌随机字节长度。
refreshTokenRandomBytes: 32, refreshTokenRandomBytes: 32,
@@ -62,6 +64,9 @@
// Refresh-token grant budget per IP per minute. // Refresh-token grant budget per IP per minute.
// refresh_token 授权每 IP 每分钟请求配额。 // refresh_token 授权每 IP 每分钟请求配额。
refreshTokenRequestsPerMinute: 30, refreshTokenRequestsPerMinute: 30,
// Coarser IP budget; the per-session budget above remains the primary guard.
// 更宽松的 IP 总预算;主要保护仍由每个 refresh session 的预算承担。
refreshTokenRequestsPerIpMinute: 300,
// Passwordless/auth-request creation budget per IP/email/device per minute. // Passwordless/auth-request creation budget per IP/email/device per minute.
// 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。 // 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。
authRequestRequestsPerMinute: 5, authRequestRequestsPerMinute: 5,
@@ -151,7 +156,9 @@
compatibility: { compatibility: {
// Single source of truth for /config.version and /api/version. // Single source of truth for /config.version and /api/version.
// /config.version 与 /api/version 的统一版本号来源。 // /config.version 与 /api/version 的统一版本号来源。
bitwardenServerVersion: '2026.4.1', // Vaultwarden 1.37.0 advertises 2026.6.0 after aligning its API response
// with the response contract required by Bitwarden 2026.7.x clients.
bitwardenServerVersion: '2026.6.0',
// Official 2026.4.x clients need this flag to receive and use cipher.key. // Official 2026.4.x clients need this flag to receive and use cipher.key.
// Hiding existing item keys makes item-key encrypted vault data unreadable. // Hiding existing item keys makes item-key encrypted vault data unreadable.
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。 // 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
@@ -159,3 +166,10 @@
cipherKeyEncryptionFeatureEnabled: true, cipherKeyEncryptionFeatureEnabled: true,
}, },
} as const; } as const;
export function getRefreshTokenSlidingTtlMs(clientType?: string | null): number {
const normalized = String(clientType || '').trim().toLowerCase();
if (normalized === 'web') return LIMITS.auth.refreshTokenWebSlidingTtlMs;
if (normalized === 'mobile') return LIMITS.auth.refreshTokenMobileSlidingTtlMs;
return LIMITS.auth.refreshTokenDefaultSlidingTtlMs;
}
+159 -69
View File
@@ -6,20 +6,23 @@ import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../s
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { hashApiKey } from '../utils/api-key'; import { isStoredApiKeyHash } from '../utils/api-key';
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp'; import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { buildAccountKeys } from '../utils/user-decryption'; import { buildAccountKeys } from '../utils/user-decryption';
import { buildProfileResponse } from '../utils/profile-response'; import { buildProfileResponse } from '../utils/profile-response';
import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp'; import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verifyYubicoOtp, yubiKeyPublicIdFromOtp } from '../utils/yubico-otp';
import {
getYubicoCredentials,
initializeYubicoCredentialsOnce,
replaceYubicoCredentials,
} from '../services/yubico-config';
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3; const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7; const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000; const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
// CONTRACT: // CONTRACT:
// users.master_password_hash is server-side login verification only. It does // users.master_password_hash is server-side login verification only. It does
@@ -201,29 +204,8 @@ function readNestedNumber(source: unknown, path: string[]): number | undefined {
return typeof current === 'number' ? current : undefined; return typeof current === 'number' ? current : undefined;
} }
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> { function isRecord(value: unknown): value is Record<string, unknown> {
const fromEnv = yubicoCredentialsFromEnv(env); return !!value && typeof value === 'object' && !Array.isArray(value);
if (fromEnv) return fromEnv;
const clientId = String(await storage.getConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY) || '').trim();
if (!clientId) return null;
const secretKey = String(await storage.getConfigValue(YUBICO_KEY_CONFIG_KEY) || '').trim();
return { clientId, secretKey };
}
async function ensureStoredYubicoCredentials(
storage: StorageService,
env: Env,
email: string,
otp: string
): Promise<YubicoApiCredentials | null> {
const existing = await getStoredYubicoCredentials(storage, env);
if (existing) return existing;
const credentials = await requestYubicoApiCredentials(email, otp);
if (!credentials) return null;
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId);
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey);
return credentials;
} }
async function readRequestBody(request: Request): Promise<Record<string, unknown>> { async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
@@ -361,7 +343,9 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
yubikeyKey4: null, yubikeyKey4: null,
yubikeyKey5: null, yubikeyKey5: null,
yubikeyNfc: false, yubikeyNfc: false,
apiKey: null, // Bitwarden creates a readable personal API key with the account. It is
// returned only after fresh user verification and is excluded from backups.
apiKey: randomStringAlphanum(LIMITS.auth.clientSecretLength),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -692,31 +676,88 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email); const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400); if (!valid) return errorResponse('Invalid password', 400);
const newMasterPasswordHash = const hasAuthenticationData = isRecord(body.authenticationData);
body.newMasterPasswordHash || const hasUnlockData = isRecord(body.unlockData);
readNestedString(body, ['authenticationData', 'masterPasswordAuthenticationHash']); if (hasAuthenticationData !== hasUnlockData) {
if (!newMasterPasswordHash) { return errorResponse('authenticationData and unlockData must be provided together', 400);
return errorResponse('newMasterPasswordHash is required', 400);
} }
const nextKey =
body.newKey || const legacyMasterPasswordHash = typeof body.newMasterPasswordHash === 'string'
body.key || ? body.newMasterPasswordHash.trim()
readNestedString(body, ['unlockData', 'masterKeyWrappedUserKey']); : '';
const legacyKey = typeof body.newKey === 'string' && body.newKey.trim()
? body.newKey.trim()
: typeof body.key === 'string'
? body.key.trim()
: '';
let newMasterPasswordHash: string;
let nextKey: string;
if (hasAuthenticationData && hasUnlockData) {
newMasterPasswordHash = readNestedString(body, ['authenticationData', 'masterPasswordAuthenticationHash']).trim();
nextKey = readNestedString(body, ['unlockData', 'masterKeyWrappedUserKey']).trim();
if (!newMasterPasswordHash || !nextKey) {
return errorResponse('authenticationData and unlockData are incomplete', 400);
}
const authKdf = readNestedNumber(body, ['authenticationData', 'kdf', 'kdfType']);
const authIterations = readNestedNumber(body, ['authenticationData', 'kdf', 'iterations']);
const authMemory = readNestedNumber(body, ['authenticationData', 'kdf', 'memory']);
const authParallelism = readNestedNumber(body, ['authenticationData', 'kdf', 'parallelism']);
const unlockKdf = readNestedNumber(body, ['unlockData', 'kdf', 'kdfType']);
const unlockIterations = readNestedNumber(body, ['unlockData', 'kdf', 'iterations']);
const unlockMemory = readNestedNumber(body, ['unlockData', 'kdf', 'memory']);
const unlockParallelism = readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']);
const authSalt = readNestedString(body, ['authenticationData', 'salt']);
const unlockSalt = readNestedString(body, ['unlockData', 'salt']);
const expectedSalt = user.email.trim().toLowerCase();
if (authKdf === undefined || authIterations === undefined || unlockKdf === undefined || unlockIterations === undefined) {
return errorResponse('authenticationData and unlockData must include KDF settings', 400);
}
if (
authKdf !== unlockKdf ||
authIterations !== unlockIterations ||
authMemory !== unlockMemory ||
authParallelism !== unlockParallelism
) {
return errorResponse('authenticationData and unlockData must use the same KDF settings', 400);
}
if (!authSalt || authSalt !== unlockSalt || authSalt !== expectedSalt) {
return errorResponse('Invalid master password salt', 400);
}
if (
authKdf !== user.kdfType ||
authIterations !== user.kdfIterations ||
(authKdf === 1 && (authMemory !== user.kdfMemory || authParallelism !== user.kdfParallelism))
) {
return errorResponse('KDF settings cannot be changed with the password endpoint', 400);
}
} else {
if (!legacyMasterPasswordHash || !legacyKey) {
return errorResponse('newMasterPasswordHash and key must be provided together', 400);
}
newMasterPasswordHash = legacyMasterPasswordHash;
nextKey = legacyKey;
}
const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey; const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey;
const nextPublicKey = body.newPublicKey || body.publicKey; const nextPublicKey = body.newPublicKey || body.publicKey;
if (nextKey && !looksLikeEncString(nextKey)) { if (!looksLikeEncString(nextKey)) {
return errorResponse('new key is not a valid encrypted string', 400); return errorResponse('new key is not a valid encrypted string', 400);
} }
if (nextPrivateKey && !looksLikeEncString(nextPrivateKey)) { if (nextPrivateKey && !looksLikeEncString(nextPrivateKey)) {
return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400); return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400);
} }
const nextKdf = body.kdf ?? readNestedNumber(body, ['unlockData', 'kdf', 'kdfType']) ?? user.kdfType; if (
const nextKdfIterations = body.kdfIterations ?? readNestedNumber(body, ['unlockData', 'kdf', 'iterations']); (typeof body.kdf === 'number' && body.kdf !== user.kdfType) ||
const nextKdfMemory = body.kdfMemory ?? readNestedNumber(body, ['unlockData', 'kdf', 'memory']); (typeof body.kdfIterations === 'number' && body.kdfIterations !== user.kdfIterations) ||
const nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']); (typeof body.kdfMemory === 'number' && body.kdfMemory !== user.kdfMemory) ||
const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism); (typeof body.kdfParallelism === 'number' && body.kdfParallelism !== user.kdfParallelism)
if (kdfErr) return errorResponse(kdfErr, 400); ) {
return errorResponse('KDF settings cannot be changed with the password endpoint', 400);
}
const shouldUpdateHint = typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null; const shouldUpdateHint = typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null;
const nextMasterPasswordHint = shouldUpdateHint ? normalizeMasterPasswordHint(body.masterPasswordHint) : undefined; const nextMasterPasswordHint = shouldUpdateHint ? normalizeMasterPasswordHint(body.masterPasswordHint) : undefined;
if (nextMasterPasswordHint && nextMasterPasswordHint.length > 120) { if (nextMasterPasswordHint && nextMasterPasswordHint.length > 120) {
@@ -724,13 +765,9 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
} }
user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email); user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email);
if (nextKey) user.key = nextKey; user.key = nextKey;
if (nextPrivateKey) user.privateKey = nextPrivateKey; if (nextPrivateKey) user.privateKey = nextPrivateKey;
if (nextPublicKey) user.publicKey = nextPublicKey; if (nextPublicKey) user.publicKey = nextPublicKey;
if (typeof nextKdf === 'number') user.kdfType = nextKdf;
if (typeof nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations;
if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory;
if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism;
if (shouldUpdateHint) { if (shouldUpdateHint) {
user.masterPasswordHint = nextMasterPasswordHint ?? null; user.masterPasswordHint = nextMasterPasswordHint ?? null;
} }
@@ -815,12 +852,19 @@ function deviceVerificationSettingsResponse(_user: User): Record<string, unknown
} }
async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> { async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> {
const credentials = await getStoredYubicoCredentials(storage, env); void storage;
const credentials = await getYubicoCredentials(env.DB);
const canManageCredentials = user.role === 'admin' && user.status === 'active';
return { return {
...yubiKeyResponse(user), ...yubiKeyResponse(user),
YubicoConfigured: !!credentials?.clientId, YubicoConfigured: !!credentials?.clientId,
YubicoCanManage: canManageCredentials,
...(canManageCredentials
? {
YubicoClientId: credentials?.clientId ?? '', YubicoClientId: credentials?.clientId ?? '',
YubicoSecretKey: credentials?.secretKey ?? '', YubicoSecretKey: credentials?.secretKey ?? '',
}
: {}),
}; };
} }
@@ -1013,7 +1057,7 @@ export async function handlePutTwoFactorYubiKey(request: Request, env: Env, user
readBodyString(body, ['key5', 'Key5']), readBodyString(body, ['key5', 'Key5']),
]; ];
const publicIds: Array<string | null> = []; const publicIds: Array<string | null> = [];
let credentials = await getStoredYubicoCredentials(storage, env); let credentials = await getYubicoCredentials(env.DB);
let apiKeyBootstrapOtpIndex: number | null = null; let apiKeyBootstrapOtpIndex: number | null = null;
for (const key of keys) { for (const key of keys) {
const trimmed = key.trim(); const trimmed = key.trim();
@@ -1028,9 +1072,10 @@ export async function handlePutTwoFactorYubiKey(request: Request, env: Env, user
continue; continue;
} }
if (!credentials) { if (!credentials) {
credentials = await ensureStoredYubicoCredentials(storage, env, user.email, trimmed); const initialized = await initializeYubicoCredentialsOnce(env.DB, user.email, trimmed);
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400); if (!initialized) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
apiKeyBootstrapOtpIndex = publicIds.length; credentials = initialized.credentials;
if (initialized.created) apiKeyBootstrapOtpIndex = publicIds.length;
} }
if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) { if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) {
return errorResponse('Invalid YubiKey OTP.', 400); return errorResponse('Invalid YubiKey OTP.', 400);
@@ -1071,6 +1116,7 @@ export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env
const auth = new AuthService(env); const auth = new AuthService(env);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
if (user.role !== 'admin' || user.status !== 'active') return errorResponse('Forbidden', 403);
let body: Record<string, unknown>; let body: Record<string, unknown>;
try { try {
@@ -1085,10 +1131,18 @@ export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env
const clientId = readBodyString(body, ['yubicoClientId', 'YubicoClientId', 'clientId', 'ClientId']).trim(); const clientId = readBodyString(body, ['yubicoClientId', 'YubicoClientId', 'clientId', 'ClientId']).trim();
const secretKey = readBodyString(body, ['yubicoSecretKey', 'YubicoSecretKey', 'secretKey', 'SecretKey']).trim(); const secretKey = readBodyString(body, ['yubicoSecretKey', 'YubicoSecretKey', 'secretKey', 'SecretKey']).trim();
if (!clientId) return errorResponse('Yubico Client ID is required.', 400); if (!clientId || !secretKey) return errorResponse('Yubico Client ID and Secret Key are required.', 400);
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, clientId); await replaceYubicoCredentials(env.DB, { clientId, secretKey });
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, secretKey); await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'system.yubico.credentials.update',
category: 'security',
level: 'security',
targetType: 'system',
targetId: 'yubico',
metadata: auditRequestMetadata(request),
});
return jsonResponse(await yubiKeySettingsResponse(storage, env, user)); return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
} }
@@ -1113,11 +1167,42 @@ export async function handleBootstrapTwoFactorYubiKeyConfig(request: Request, en
const otp = readBodyString(body, ['otp', 'OTP', 'token', 'Token']).trim(); const otp = readBodyString(body, ['otp', 'OTP', 'token', 'Token']).trim();
if (!yubiKeyPublicIdFromOtp(otp)) return errorResponse('Invalid YubiKey OTP.', 400); if (!yubiKeyPublicIdFromOtp(otp)) return errorResponse('Invalid YubiKey OTP.', 400);
const credentials = await requestYubicoApiCredentials(user.email, otp); const existing = await getYubicoCredentials(env.DB);
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400); if (user.role !== 'admin' && existing) {
return errorResponse('Yubico validation credentials are already configured.', 403);
}
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId); let credentials;
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey); if (user.role === 'admin') {
credentials = await requestYubicoApiCredentials(user.email, otp);
if (!credentials?.clientId || !credentials.secretKey) {
return errorResponse('Unable to initialize Yubico validation credentials.', 400);
}
await replaceYubicoCredentials(env.DB, credentials);
} else {
const initialized = await initializeYubicoCredentialsOnce(env.DB, user.email, otp);
if (!initialized?.created) {
return errorResponse(
initialized?.credentials
? 'Yubico validation credentials are already configured.'
: 'Unable to initialize Yubico validation credentials.',
initialized?.credentials ? 403 : 400
);
}
credentials = initialized.credentials;
}
await writeAuditEvent(storage, {
actorUserId: user.id,
action: user.role === 'admin'
? 'system.yubico.credentials.reconfigure'
: 'system.yubico.credentials.initialize',
category: 'security',
level: 'security',
targetType: 'system',
targetId: 'yubico',
metadata: auditRequestMetadata(request),
});
return jsonResponse(await yubiKeySettingsResponse(storage, env, user)); return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
} }
@@ -1485,19 +1570,24 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email); const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400); if (!valid) return errorResponse('Invalid password', 400);
// Only the fresh secret is returned once; the database stores a hash. if (!rotate && isStoredApiKeyHash(user.apiKey)) {
const plainApiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength); return errorResponse(
user.apiKey = await hashApiKey(plainApiKey); 'This API key was created by an older NodeWarden version and cannot be displayed. Rotate it once to use the Bitwarden-compatible readable format.',
if (rotate) { 409
user.securityStamp = generateUUID(); );
await storage.deleteRefreshTokensByUserId(user.id);
} }
let auditAction = 'account.api_key.view';
if (rotate || !user.apiKey) {
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
AuthService.invalidateUserCache(user.id); AuthService.invalidateUserCache(user.id);
auditAction = rotate ? 'account.api_key.rotate' : 'account.api_key.create';
}
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
actorUserId: user.id, actorUserId: user.id,
action: rotate ? 'account.api_key.rotate' : 'account.api_key.create', action: auditAction,
category: 'security', category: 'security',
level: rotate ? 'security' : 'info', level: rotate ? 'security' : 'info',
targetType: 'user', targetType: 'user',
@@ -1506,7 +1596,7 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
}); });
return jsonResponse({ return jsonResponse({
apiKey: plainApiKey, apiKey: user.apiKey,
revisionDate: user.updatedAt, revisionDate: user.updatedAt,
object: 'apiKey', object: 'apiKey',
}); });
+47 -14
View File
@@ -9,6 +9,34 @@ function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active'; return user.role === 'admin' && user.status === 'active';
} }
async function requireMasterPasswordHash(
env: Env,
actorUser: User,
masterPasswordHash: unknown
): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim();
if (!normalized) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
return null;
}
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
try {
const body = await request.json();
return body && typeof body === 'object' && !Array.isArray(body)
? body as Record<string, unknown>
: {};
} catch {
return {};
}
}
function randomHex(bytes: number): string { function randomHex(bytes: number): string {
const data = crypto.getRandomValues(new Uint8Array(bytes)); const data = crypto.getRandomValues(new Uint8Array(bytes));
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join(''); return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
@@ -204,14 +232,11 @@ export async function handleAdminCreateInvite(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
let body: { expiresInHours?: number } = {}; const body = await readJsonBody(request);
try { const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
body = await request.json(); if (passwordError) return passwordError;
} catch {
body = {};
}
const expiresInHours = Number.isFinite(body.expiresInHours) const expiresInHours = Number.isFinite(Number(body.expiresInHours))
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours)))) ? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
: 24 * 7; : 24 * 7;
const now = new Date(); const now = new Date();
@@ -266,6 +291,10 @@ export async function handleAdminDeleteInvite(
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const deleted = await storage.deleteInvite(code); const deleted = await storage.deleteInvite(code);
if (!deleted) { if (!deleted) {
@@ -288,6 +317,10 @@ export async function handleAdminDeleteAllInvites(
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const url = new URL(request.url); const url = new URL(request.url);
if (url.searchParams.get('scope') === 'invalid') { if (url.searchParams.get('scope') === 'invalid') {
@@ -318,12 +351,9 @@ export async function handleAdminSetUserStatus(
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
let body: { status?: string }; const body = await readJsonBody(request);
try { const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
body = await request.json(); if (passwordError) return passwordError;
} catch {
return errorResponse('Invalid JSON', 400);
}
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null; const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
if (!nextStatus) { if (!nextStatus) {
@@ -366,7 +396,6 @@ export async function handleAdminDeleteUser(
actorUser: User, actorUser: User,
targetUserId: string targetUserId: string
): Promise<Response> { ): Promise<Response> {
void request;
if (!isAdmin(actorUser)) { if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
@@ -374,6 +403,10 @@ export async function handleAdminDeleteUser(
return errorResponse('You cannot delete yourself', 400); return errorResponse('You cannot delete yourself', 400);
} }
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const target = await storage.getUserById(targetUserId); const target = await storage.getUserById(targetUserId);
if (!target) { if (!target) {
+4 -9
View File
@@ -1247,20 +1247,15 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
try { try {
const url = new URL(request.url); // Read the request body only. Accepting these fields from the query string
let input: { blobName?: unknown; masterPasswordHash?: unknown } = {}; // would put the master-password authentication hash in the URL, where it is
if (request.method === 'POST') { // captured by request logs, browser history and Referer headers.
let input: { blobName?: unknown; masterPasswordHash?: unknown };
try { try {
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>(); input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
} catch { } catch {
return errorResponse('Backup attachment download payload is invalid', 400); return errorResponse('Backup attachment download payload is invalid', 400);
} }
} else {
input = {
blobName: url.searchParams.get('blobName') || '',
masterPasswordHash: url.searchParams.get('masterPasswordHash') || '',
};
}
const verificationError = await requireBackupUserVerification( const verificationError = await requireBackupUserVerification(
actorUser, actorUser,
+16 -1
View File
@@ -464,11 +464,26 @@ export async function handleUpdateDeviceName(
// DELETE /api/devices // DELETE /api/devices
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> { export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
let masterPasswordHash = '';
try {
const body = await request.json() as { masterPasswordHash?: string };
masterPasswordHash = String(body?.masterPasswordHash || '').trim();
} catch {
masterPasswordHash = '';
}
if (!masterPasswordHash) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const passwordValid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
if (!passwordValid) {
return errorResponse('Invalid password', 400);
}
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([ const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
storage.deleteTrustedTwoFactorTokensByUserId(userId), storage.deleteTrustedTwoFactorTokensByUserId(userId),
storage.deleteRefreshTokensByUserId(userId), storage.deleteRefreshTokensByUserId(userId),
+161 -113
View File
@@ -3,7 +3,7 @@ import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
import { LIMITS } from '../config/limits'; import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp'; import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
import { createRefreshToken } from '../utils/jwt'; import { createRefreshToken } from '../utils/jwt';
import { readAuthRequestDeviceInfo } from '../utils/device'; import { readAuthRequestDeviceInfo } from '../utils/device';
@@ -25,7 +25,8 @@ import {
import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token'; import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { constantTimeEquals, verifyApiKey } from '../utils/api-key'; import { constantTimeEquals, verifyApiKey } from '../utils/api-key';
import { isYubiKeyEnabled, userYubiKeyPublicIds, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp'; import { isYubiKeyEnabled, userYubiKeyPublicIds, verifyYubicoOtp, yubiKeyPublicIdFromOtp } from '../utils/yubico-otp';
import { getYubicoCredentials, initializeYubicoCredentialsOnce } from '../services/yubico-config';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
@@ -34,14 +35,16 @@ const TWO_FACTOR_PROVIDER_REMEMBER = 5;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7; const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8; const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh'; const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
// Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows // Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows
// the official Identity provider enum (RecoveryCode = 8), while request parsing remains // the official Identity provider enum (RecoveryCode = 8), while request parsing remains
// compatible with older/local provider values. // compatible with older/local provider values.
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1'; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
function identityJsonResponse(data: unknown, status: number = 200): Response {
return jsonResponse(data, status, { 'Cache-Control': 'no-store', Pragma: 'no-cache' });
}
function resolveTotpSecret(userSecret: string | null): string | null { function resolveTotpSecret(userSecret: string | null): string | null {
if (userSecret && isTotpEnabled(userSecret)) { if (userSecret && isTotpEnabled(userSecret)) {
return userSecret; return userSecret;
@@ -60,6 +63,33 @@ async function resolveDeviceSession(
return { identifier: deviceInfo.deviceIdentifier, sessionStamp }; return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
} }
function resolveRefreshClientType(request: Request, body: Record<string, string>): string {
if (shouldUseWebSession(request)) return 'web';
const clientId = String(body.client_id || '').trim().toLowerCase();
if (clientId === 'mobile') return 'mobile';
if (clientId === 'browser' || clientId === 'desktop' || clientId === 'cli') return clientId;
return clientId || 'other';
}
async function persistAndResolveDeviceSession(
storage: StorageService,
userId: string,
deviceInfo: ReturnType<typeof readAuthRequestDeviceInfo>
): Promise<{ identifier: string; sessionStamp: string } | null> {
const candidate = await resolveDeviceSession(storage, userId, deviceInfo);
if (!candidate) return null;
await storage.upsertDevice(
userId,
candidate.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
candidate.sessionStamp
);
const persisted = await storage.getDevice(userId, candidate.identifier);
if (!persisted?.sessionStamp) throw new Error('Failed to persist device session');
return { identifier: persisted.deviceIdentifier, sessionStamp: persisted.sessionStamp };
}
function readDevicePushToken(body: Record<string, string>): string { function readDevicePushToken(body: Record<string, string>): string {
return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim(); return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim();
} }
@@ -132,15 +162,6 @@ async function loginRateLimitKey(clientIdentifier: string, grantType: string, su
return `${clientIdentifier}:login:${grantType}:${subjectHash}`; return `${clientIdentifier}:login:${grantType}:${subjectHash}`;
} }
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
const fromEnv = yubicoCredentialsFromEnv(env);
if (fromEnv) return fromEnv;
const clientId = String(await storage.getConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY) || '').trim();
if (!clientId) return null;
const secretKey = String(await storage.getConfigValue(YUBICO_KEY_CONFIG_KEY) || '').trim();
return { clientId, secretKey };
}
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string { function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
const isHttps = new URL(request.url).protocol === 'https:'; const isHttps = new URL(request.url).protocol === 'https:';
const parts = [ const parts = [
@@ -163,7 +184,7 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken
headers.append( headers.append(
'Set-Cookie', 'Set-Cookie',
refreshToken refreshToken
? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000)) ? buildRefreshCookie(request, refreshToken, Math.floor(getRefreshTokenSlidingTtlMs('web') / 1000))
: buildClearedRefreshCookie(request) : buildClearedRefreshCookie(request)
); );
return new Response(response.body, { return new Response(response.body, {
@@ -173,30 +194,6 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken
}); });
} }
async function revokePresentedAccessTokenSession(request: Request, env: Env, storage: StorageService): Promise<void> {
const authHeader = request.headers.get('Authorization');
if (!authHeader) return;
const auth = new AuthService(env);
const verified = await auth.verifyAccessTokenWithUser(authHeader);
if (!verified) return;
const deviceIdentifier = String(verified.payload.did || '').trim();
if (deviceIdentifier) {
const nextSessionStamp = generateUUID();
await storage.rotateDeviceSessionStamp(verified.user.id, deviceIdentifier, nextSessionStamp);
await storage.deleteRefreshTokensByDevice(verified.user.id, deviceIdentifier);
AuthService.invalidateDeviceCache(verified.user.id, deviceIdentifier);
return;
}
verified.user.securityStamp = generateUUID();
verified.user.updatedAt = new Date().toISOString();
await storage.saveUser(verified.user);
await storage.deleteRefreshTokensByUserId(verified.user.id);
AuthService.invalidateUserCache(verified.user.id);
}
function buildPreloginResponse( function buildPreloginResponse(
email: string, email: string,
kdfType: number, kdfType: number,
@@ -209,6 +206,16 @@ function buildPreloginResponse(
kdfIterations, kdfIterations,
kdfMemory, kdfMemory,
kdfParallelism, kdfParallelism,
// Current official servers expose the consolidated KDF model alongside
// the legacy flat fields. Keep both shapes while clients migrate.
kdfSettings: {
kdfType,
iterations: kdfIterations,
memory: kdfMemory,
parallelism: kdfParallelism,
},
salt: null,
// Preserve the historic NodeWarden aliases for older integrations.
KdfSettings: { KdfSettings: {
KdfType: kdfType, KdfType: kdfType,
Iterations: kdfIterations, Iterations: kdfIterations,
@@ -267,7 +274,7 @@ async function twoFactorRequiredResponse(
}; };
// Bitwarden clients rely on these fields to trigger the 2FA UI flow. // Bitwarden clients rely on these fields to trigger the 2FA UI flow.
return jsonResponse( return identityJsonResponse(
{ {
error: 'invalid_grant', error: 'invalid_grant',
error_description: message, error_description: message,
@@ -341,8 +348,20 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const grantType = body.grant_type; const grantType = body.grant_type;
const clientIdentifier = getClientIdentifier(request); const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) { if (!clientIdentifier && grantType !== 'refresh_token') {
return identityErrorResponse('Client IP is required', 'invalid_request', 403); await safeWriteAuditEvent(env, {
action: 'auth.client_ip.missing',
category: 'auth',
level: 'error',
targetType: 'tokenEndpoint',
metadata: { grantType, reason: 'client_ip_missing', ...auditRequestMetadata(request) },
});
return identityErrorResponse(
'Authentication is temporarily unavailable',
'temporarily_unavailable',
503,
{ 'Retry-After': '5' }
);
} }
if (grantType === 'password') { if (grantType === 'password') {
@@ -359,7 +378,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Bitwarden clients expect OAuth-style error fields. // Bitwarden clients expect OAuth-style error fields.
return identityErrorResponse('Email and password are required', 'invalid_request', 400); return identityErrorResponse('Email and password are required', 'invalid_request', 400);
} }
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, email); const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, email);
// Check login lockout before user lookup to reduce user-enumeration signal // Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -488,8 +507,17 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
if (!publicId || !effectiveYubiKeyPublicIds.includes(publicId)) { if (!publicId || !effectiveYubiKeyPublicIds.includes(publicId)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
const credentials = await getStoredYubicoCredentials(storage, env); let credentials = await getYubicoCredentials(env.DB);
if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) { let initializedWithCurrentOtp = false;
if (!credentials) {
const initialized = await initializeYubicoCredentialsOnce(env.DB, user.email, normalizedTwoFactorToken);
if (!initialized) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
credentials = initialized.credentials;
initializedWithCurrentOtp = initialized.created;
}
if (!initializedWithCurrentOtp && !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_WEBAUTHN)) { } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_WEBAUTHN)) {
@@ -550,15 +578,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// Persist device only after successful password + (optional) 2FA verification. // Persist device only after successful password + (optional) 2FA verification.
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
@@ -569,7 +590,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
@@ -612,14 +633,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
} else if (grantType === 'webauthn') { } else if (grantType === 'webauthn') {
const token = String(body.token || '').trim(); const token = String(body.token || '').trim();
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, token || 'missing-token'); const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, token || 'missing-token');
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) { if (!loginCheck.allowed) {
return identityErrorResponse( return identityErrorResponse(
@@ -673,22 +694,15 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
await rateLimit.clearLoginAttempts(loginIdentifier); await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair'); const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential); const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
@@ -734,7 +748,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
@@ -751,7 +765,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Parameter error', 'invalid_request', 400); return identityErrorResponse('Parameter error', 'invalid_request', 400);
} }
const uid = clientId.slice(5); const uid = clientId.slice(5);
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, uid); const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, uid);
// Check login lockout before user lookup to reduce user-enumeration signal // Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -805,15 +819,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// Persist device only after successful client credential verification. // Persist device only after successful client credential verification.
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
@@ -821,7 +828,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
await rateLimit.clearLoginAttempts(loginIdentifier); await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
@@ -863,7 +870,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
@@ -880,7 +887,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const sendId = String(body.send_id || body.sendId || '').trim(); const sendId = String(body.send_id || body.sendId || '').trim();
if (!sendId) { if (!sendId) {
return jsonResponse( return identityJsonResponse(
{ {
error: 'invalid_request', error: 'invalid_request',
error_description: 'send_id is required', error_description: 'send_id is required',
@@ -905,13 +912,13 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
passwordHashB64, passwordHashB64,
password, password,
rateLimit, rateLimit,
clientIdentifier clientIdentifier || undefined
); );
if ('error' in result) { if ('error' in result) {
return result.error; return result.error;
} }
return jsonResponse({ return identityJsonResponse({
access_token: result.token, access_token: result.token,
expires_in: LIMITS.auth.sendAccessTokenTtlSeconds, expires_in: LIMITS.auth.sendAccessTokenTtlSeconds,
token_type: 'Bearer', token_type: 'Bearer',
@@ -919,19 +926,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
unofficialServer: true, unofficialServer: true,
}); });
} else if (grantType === 'refresh_token') { } else if (grantType === 'refresh_token') {
const refreshLimit = await rateLimit.consumeBudget(
`${clientIdentifier}:identity-refresh`,
LIMITS.rateLimit.refreshTokenRequestsPerMinute
);
if (!refreshLimit.allowed) {
return identityErrorResponse(
`Rate limit exceeded. Try again in ${refreshLimit.retryAfterSeconds} seconds.`,
'TooManyRequests',
429
);
}
// Refresh token
const refreshToken = String(body.refresh_token || '').trim() || ( const refreshToken = String(body.refresh_token || '').trim() || (
shouldUseWebSession(request) shouldUseWebSession(request)
? parseCookieValue(request, WEB_REFRESH_COOKIE) ? parseCookieValue(request, WEB_REFRESH_COOKIE)
@@ -941,7 +935,72 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Refresh token is required', 'invalid_request', 400); return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
} }
const result = await auth.refreshAccessTokenDetailed(refreshToken); const refreshTokenHash = await sha256Hex(refreshToken);
try {
const sessionLimit = await rateLimit.consumeBudget(
`refresh-session:${refreshTokenHash}`,
LIMITS.rateLimit.refreshTokenRequestsPerMinute
);
const ipLimit = clientIdentifier
? await rateLimit.consumeBudget(
`refresh-ip:${clientIdentifier}`,
LIMITS.rateLimit.refreshTokenRequestsPerIpMinute
)
: null;
const rejected = !sessionLimit.allowed ? sessionLimit : (ipLimit && !ipLimit.allowed ? ipLimit : null);
if (rejected) {
const retryAfter = Math.max(1, rejected.retryAfterSeconds || 1);
return identityErrorResponse(
`Rate limit exceeded. Try again in ${retryAfter} seconds.`,
'temporarily_unavailable',
429,
{ 'Retry-After': String(retryAfter) }
);
}
} catch (error) {
await safeWriteAuditEvent(env, {
action: 'auth.refresh.failed.rate_limit_unavailable',
category: 'auth',
level: 'error',
targetType: 'refreshToken',
metadata: { grantType, reason: 'rate_limit_unavailable', error: error instanceof Error ? error.message : String(error), ...auditRequestMetadata(request) },
});
return identityErrorResponse(
'Session refresh is temporarily unavailable',
'temporarily_unavailable',
503,
{ 'Retry-After': '5' }
);
}
if (!clientIdentifier) {
await safeWriteAuditEvent(env, {
action: 'auth.client_ip.missing',
category: 'auth',
level: 'warn',
targetType: 'refreshToken',
metadata: { grantType, reason: 'client_ip_missing', webSession: shouldUseWebSession(request), ...auditRequestMetadata(request) },
});
}
let result: Awaited<ReturnType<AuthService['refreshAccessTokenDetailed']>>;
try {
result = await auth.refreshAccessTokenDetailed(refreshToken);
} catch (error) {
await safeWriteAuditEvent(env, {
action: 'auth.refresh.failed.temporarily_unavailable',
category: 'auth',
level: 'error',
targetType: 'refreshToken',
metadata: { grantType, reason: 'storage_or_worker_error', error: error instanceof Error ? error.message : String(error), webSession: shouldUseWebSession(request), ...auditRequestMetadata(request) },
});
return identityErrorResponse(
'Session refresh is temporarily unavailable',
'temporarily_unavailable',
503,
{ 'Retry-After': '5' }
);
}
if (!result.ok) { if (!result.ok) {
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
actorUserId: result.userId ?? null, actorUserId: result.userId ?? null,
@@ -963,18 +1022,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
: invalidResponse; : invalidResponse;
} }
// Keep a short overlap window for old refresh token to absorb
// concurrent refresh requests from multiple client contexts.
await storage.constrainRefreshTokenExpiry(
refreshToken,
Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs
);
const { accessToken, user, device } = result; const { accessToken, user, device } = result;
if (device?.identifier) { if (device?.identifier) {
await storage.touchDeviceLastSeen(user.id, device.identifier); await storage.touchDeviceLastSeen(user.id, device.identifier);
} }
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
@@ -982,7 +1033,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
access_token: accessToken, access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds, expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer', token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }), ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
Key: user.key, Key: user.key,
PrivateKey: user.privateKey, PrivateKey: user.privateKey,
AccountKeys: accountKeys, AccountKeys: accountKeys,
@@ -1001,9 +1052,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, newRefreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
} }
@@ -1036,7 +1087,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
const kdfMemory = user?.kdfMemory ?? null; const kdfMemory = user?.kdfMemory ?? null;
const kdfParallelism = user?.kdfParallelism ?? null; const kdfParallelism = user?.kdfParallelism ?? null;
return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism)); return identityJsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
} }
// POST /identity/connect/revocation // POST /identity/connect/revocation
@@ -1044,12 +1095,6 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
// RFC 7009 allows returning 200 even if token is unknown. // RFC 7009 allows returning 200 even if token is unknown.
export async function handleRevocation(request: Request, env: Env): Promise<Response> { export async function handleRevocation(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try {
await revokePresentedAccessTokenSession(request, env, storage);
} catch {
// RFC 7009 revocation is best-effort and should not reveal token state.
}
let body: Record<string, string>; let body: Record<string, string>;
const contentType = request.headers.get('content-type') || ''; const contentType = request.headers.get('content-type') || '';
try { try {
@@ -1060,7 +1105,7 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
body = await request.json(); body = await request.json();
} }
} catch { } catch {
return new Response(null, { status: 200 }); return new Response(null, { status: 200, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } });
} }
const token = String(body.token || '').trim() || ( const token = String(body.token || '').trim() || (
@@ -1072,7 +1117,10 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
await storage.deleteRefreshToken(token); await storage.deleteRefreshToken(token);
} }
const baseResponse = new Response(null, { status: 200 }); const baseResponse = new Response(null, {
status: 200,
headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' },
});
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, null) ? withWebRefreshCookie(request, baseResponse, null)
: baseResponse; : baseResponse;
+12 -17
View File
@@ -5,6 +5,11 @@ import { handleRequest } from './router';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import { applyCors, jsonResponse } from './utils/response'; import { applyCors, jsonResponse } from './utils/response';
import { runScheduledBackupIfDue } from './handlers/backup'; import { runScheduledBackupIfDue } from './handlers/backup';
import {
isBackendRequestPath,
isWebVaultHidden,
webVaultNotFoundResponse,
} from './web-vault-visibility';
let dbInitialized = false; let dbInitialized = false;
let dbInitError: string | null = null; let dbInitError: string | null = null;
@@ -19,22 +24,6 @@ function normalizeRequestUrl(request: Request): Request {
return new Request(url.toString(), request); return new Request(url.toString(), request);
} }
function isWorkerHandledPath(path: string): boolean {
return (
path.startsWith('/api/') ||
path.startsWith('/identity/') ||
path.startsWith('/icons/') ||
path.startsWith('/fill-assist/') ||
path.startsWith('/notifications/') ||
path.startsWith('/.well-known/') ||
path === '/v1/assetlinks:check' ||
path === '/web-bootstrap' ||
path === '/config' ||
path === '/api/config' ||
path === '/api/version'
);
}
function addSearchIndexHeaders(request: Request, response: Response): Response { function addSearchIndexHeaders(request: Request, response: Response): Response {
const url = new URL(request.url); const url = new URL(request.url);
const contentType = String(response.headers.get('Content-Type') || '').toLowerCase(); const contentType = String(response.headers.get('Content-Type') || '').toLowerCase();
@@ -58,7 +47,7 @@ async function maybeServeAsset(request: Request, env: Env): Promise<Response | n
if (!env.ASSETS) return null; if (!env.ASSETS) return null;
if (request.method !== 'GET' && request.method !== 'HEAD') return null; if (request.method !== 'GET' && request.method !== 'HEAD') return null;
const url = new URL(request.url); const url = new URL(request.url);
if (isWorkerHandledPath(url.pathname)) return null; if (isBackendRequestPath(url.pathname)) return null;
const response = await env.ASSETS.fetch(request); const response = await env.ASSETS.fetch(request);
return addSearchIndexHeaders(request, response); return addSearchIndexHeaders(request, response);
@@ -90,6 +79,12 @@ export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
void ctx; void ctx;
const normalizedRequest = normalizeRequestUrl(request); const normalizedRequest = normalizeRequestUrl(request);
const requestPath = new URL(normalizedRequest.url).pathname;
if (isWebVaultHidden(env) && !isBackendRequestPath(requestPath)) {
return webVaultNotFoundResponse(normalizedRequest);
}
const assetResponse = await maybeServeAsset(normalizedRequest, env); const assetResponse = await maybeServeAsset(normalizedRequest, env);
if (assetResponse) { if (assetResponse) {
return applyCors(normalizedRequest, assetResponse, env); return applyCors(normalizedRequest, assetResponse, env);
+16 -1
View File
@@ -14,6 +14,7 @@ import {
handleRunAdminConfiguredBackup, handleRunAdminConfiguredBackup,
handleUpdateAdminBackupSettings, handleUpdateAdminBackupSettings,
} from './handlers/backup'; } from './handlers/backup';
import { errorResponse } from './utils/response';
export async function handleAdminBackupRoute( export async function handleAdminBackupRoute(
request: Request, request: Request,
@@ -26,9 +27,23 @@ export async function handleAdminBackupRoute(
return handleAdminExportBackup(request, env, actorUser); return handleAdminExportBackup(request, env, actorUser);
} }
if (path === '/api/admin/backup/blob' && (method === 'GET' || method === 'POST')) { if (path === '/api/admin/backup/blob') {
// POST only: this endpoint requires master-password verification, and a GET
// could only carry that credential in the query string, where it would leak
// into request logs, proxy logs, browser history and Referer headers.
// The credential is the same value clients send to /identity/connect/token,
// so a leaked copy is enough to sign in as this admin.
if (method === 'POST') {
return handleDownloadAdminBackupAttachment(request, env, actorUser); return handleDownloadAdminBackupAttachment(request, env, actorUser);
} }
if (method === 'GET') {
return errorResponse(
'Use POST with a JSON body for this endpoint. Credentials must not be sent in the URL.',
405
);
}
return null;
}
if (path === '/api/admin/backup/settings') { if (path === '/api/admin/backup/settings') {
if (method === 'GET') return handleGetAdminBackupSettings(request, env, actorUser); if (method === 'GET') return handleGetAdminBackupSettings(request, env, actorUser);
+1 -54
View File
@@ -36,6 +36,7 @@ import { jsonResponse, unsupportedResponse } from './utils/response';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import type { Env } from './types'; import type { Env } from './types';
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins'; import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
import { buildConfigResponse } from './config-response';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>; type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
type JwtUnsafeReason = 'missing' | 'too_short' | null; type JwtUnsafeReason = 'missing' | 'too_short' | null;
@@ -95,60 +96,6 @@ function handleMissingWebsiteIcon(): Response {
}); });
} }
function buildIconServiceBase(origin: string): string {
return `${origin}/icons`;
}
function buildIconServiceTemplate(origin: string): string {
return `${buildIconServiceBase(origin)}/{}/icon.png`;
}
function buildIconServiceCsp(origin: string): string {
return `img-src 'self' data: ${origin}`;
}
function buildConfigResponse(origin: string) {
const fillAssistBase = `${origin}/fill-assist/`;
return {
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
cloudRegion: 'self-hosted',
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
icons: origin,
sso: '',
fillAssistRules: fillAssistBase,
},
push: {
pushTechnology: 0,
vapidPublicKey: null,
},
communication: null,
settings: {
disableUserRegistration: false,
},
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
'duo-redirect': true,
'email-verification': true,
'fill-assist-targeting-rules': true,
'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true,
'pm-4516-devices-add-last-activity-date': true,
'pm-30529-webauthn-related-origins': true,
'unauth-ui-refresh': true,
'web-push': false,
},
object: 'config',
};
}
function normalizeIconHost(rawHost: string): string | null { function normalizeIconHost(rawHost: string): string | null {
let decoded: string; let decoded: string;
try { try {
+41 -8
View File
@@ -1,5 +1,6 @@
import { Env, JWTPayload, User } from '../types'; import { Env, JWTPayload, User } from '../types';
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt'; import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
import { StorageService } from './storage'; import { StorageService } from './storage';
// Server-side iterations for second-layer hashing. // Server-side iterations for second-layer hashing.
@@ -28,11 +29,12 @@ export type RefreshAccessTokenFailureReason =
| 'token_not_found_or_expired' | 'token_not_found_or_expired'
| 'user_missing' | 'user_missing'
| 'user_inactive' | 'user_inactive'
| 'security_stamp_mismatch'
| 'device_missing' | 'device_missing'
| 'device_session_mismatch'; | 'device_session_mismatch';
export type RefreshAccessTokenResult = export type RefreshAccessTokenResult =
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null; expiresAt: number }
| { | {
ok: false; ok: false;
reason: RefreshAccessTokenFailureReason; reason: RefreshAccessTokenFailureReason;
@@ -190,9 +192,23 @@ export class AuthService {
} }
// Generate refresh token // Generate refresh token
async generateRefreshToken(userId: string, device?: { identifier: string; sessionStamp: string } | null): Promise<string> { async generateRefreshToken(
user: User,
device?: { identifier: string; sessionStamp: string } | null,
clientType: string = 'other'
): Promise<string> {
const token = createRefreshToken(); const token = createRefreshToken();
await this.storage.saveRefreshToken(token, userId, undefined, device?.identifier ?? null, device?.sessionStamp ?? null); const now = Date.now();
await this.storage.saveRefreshToken(
token,
user.id,
now + getRefreshTokenSlidingTtlMs(clientType),
device?.identifier ?? null,
device?.sessionStamp ?? null,
user.securityStamp,
clientType,
now + LIMITS.auth.refreshTokenAbsoluteTtlMs
);
return token; return token;
} }
@@ -251,25 +267,42 @@ export class AuthService {
return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
} }
let device: { identifier: string; sessionStamp: string } | null = null; if (record.securityStamp && record.securityStamp !== user.securityStamp) {
if (!record.deviceIdentifier || !record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken); await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'security_stamp_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (!record.securityStamp) {
await this.storage.bindRefreshTokenSecurityStamp(refreshToken, user.securityStamp);
} }
let device: { identifier: string; sessionStamp: string } | null = null;
if (record.deviceIdentifier) {
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier); const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
if (!boundDevice) { if (!boundDevice) {
await this.storage.deleteRefreshToken(refreshToken); await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
} }
if (boundDevice.sessionStamp !== record.deviceSessionStamp) { if (record.deviceSessionStamp && boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken); await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
} }
if (!record.deviceSessionStamp) {
await this.storage.bindRefreshTokenDeviceStamp(refreshToken, boundDevice.sessionStamp);
}
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp }; device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
}
const now = Date.now();
const expiresAt = Math.min(
now + getRefreshTokenSlidingTtlMs(record.clientType),
record.absoluteExpiresAt || (now + LIMITS.auth.refreshTokenAbsoluteTtlMs)
);
const extended = await this.storage.extendRefreshTokenExpiry(refreshToken, expiresAt, now);
if (!extended) {
return { ok: false, reason: 'token_not_found_or_expired', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
const accessToken = await this.generateAccessToken(user, device); const accessToken = await this.generateAccessToken(user, device);
return { ok: true, accessToken, user, device }; return { ok: true, accessToken, user, device, expiresAt };
} }
async refreshAccessToken( async refreshAccessToken(
+27 -26
View File
@@ -2,6 +2,7 @@ import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate';
import type { Env } from '../types'; import type { Env } from '../types';
import { APP_VERSION } from '../../shared/app-version'; import { APP_VERSION } from '../../shared/app-version';
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config'; import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config';
import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto'; import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto';
import { import {
getAttachmentObjectKey, getAttachmentObjectKey,
@@ -16,6 +17,8 @@ import {
// - Add persistent tables to BackupPayload, export SQL, manifest tableCounts, // - Add persistent tables to BackupPayload, export SQL, manifest tableCounts,
// and validateBackupPayloadContents(). // and validateBackupPayloadContents().
// - Keep secrets and transient runtime rows sanitized before writing db.json. // - Keep secrets and transient runtime rows sanitized before writing db.json.
// - Runtime authentication state (devices, sessions, auth requests, remembered
// 2FA devices, and one-time tokens) must never enter an instance backup.
// - users.api_key is intentionally not exported. // - users.api_key is intentionally not exported.
// - backup.settings.v1 is exported as portable-only; the current server runtime // - backup.settings.v1 is exported as portable-only; the current server runtime
// envelope must not leave the instance. // envelope must not leave the instance.
@@ -69,7 +72,6 @@ export interface BackupPayload {
ciphers: SqlRow[]; ciphers: SqlRow[];
attachments: SqlRow[]; attachments: SqlRow[];
webauthn_credentials?: SqlRow[]; webauthn_credentials?: SqlRow[];
trusted_two_factor_device_tokens?: SqlRow[];
}; };
} }
@@ -111,7 +113,7 @@ function sanitizeConfigRowsForExport(rows: SqlRow[]): SqlRow[] {
const sanitized: SqlRow[] = []; const sanitized: SqlRow[] = [];
for (const row of rows) { for (const row of rows) {
const key = String(row.key || '').trim(); const key = String(row.key || '').trim();
if (!key || key === BACKUP_RUNNER_LOCK_CONFIG_KEY) continue; if (!key || key === BACKUP_RUNNER_LOCK_CONFIG_KEY || key === YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY) continue;
if (key === BACKUP_SETTINGS_CONFIG_KEY) { if (key === BACKUP_SETTINGS_CONFIG_KEY) {
const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null); const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null);
@@ -260,6 +262,25 @@ function ensureRowArray(value: unknown, table: string): SqlRow[] {
return value as SqlRow[]; return value as SqlRow[];
} }
function normalizeParsedBackupDb(value: unknown): BackupPayload['db'] {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Backup archive database payload is invalid');
}
const source = value as Record<string, unknown>;
// Restore uses an explicit allowlist. Extra tables from old or modified
// archives, especially runtime authentication state, are intentionally ignored.
return {
config: source.config as SqlRow[],
users: source.users as SqlRow[],
domain_settings: source.domain_settings as SqlRow[],
user_revisions: source.user_revisions as SqlRow[],
folders: source.folders as SqlRow[],
ciphers: source.ciphers as SqlRow[],
attachments: source.attachments as SqlRow[],
webauthn_credentials: source.webauthn_credentials as SqlRow[] | undefined,
};
}
function createZipEntries(files: Record<string, Uint8Array>): Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> { function createZipEntries(files: Record<string, Uint8Array>): Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> {
const entries: Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> = {}; const entries: Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> = {};
for (const [path, bytes] of Object.entries(files)) { for (const [path, bytes] of Object.entries(files)) {
@@ -313,10 +334,10 @@ export function parseBackupArchive(
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let manifest: BackupManifest; let manifest: BackupManifest;
let db: BackupPayload['db']; let rawDb: unknown;
try { try {
manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest; manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest;
db = JSON.parse(decoder.decode(dbBytes)) as BackupPayload['db']; rawDb = JSON.parse(decoder.decode(dbBytes));
} catch { } catch {
throw new Error('Backup archive contains invalid JSON metadata'); throw new Error('Backup archive contains invalid JSON metadata');
} }
@@ -324,9 +345,7 @@ export function parseBackupArchive(
if (manifest?.formatVersion !== BACKUP_FORMAT_VERSION) { if (manifest?.formatVersion !== BACKUP_FORMAT_VERSION) {
throw new Error('Unsupported backup format version'); throw new Error('Unsupported backup format version');
} }
if (!db || typeof db !== 'object') { const db = normalizeParsedBackupDb(rawDb);
throw new Error('Backup archive database payload is invalid');
}
const externalAttachmentKeys = new Set<string>( const externalAttachmentKeys = new Set<string>(
options.allowExternalAttachmentBlobs options.allowExternalAttachmentBlobs
@@ -363,7 +382,6 @@ export function validateBackupPayloadContents(
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers'); const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments'); const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials'); const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
const trustedTwoFactorTokenRows = ensureRowArray(payload.db.trusted_two_factor_device_tokens || [], 'trusted_two_factor_device_tokens');
const externalAttachmentKeys = new Set<string>( const externalAttachmentKeys = new Set<string>(
options.allowExternalAttachmentBlobs options.allowExternalAttachmentBlobs
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`) ? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
@@ -454,20 +472,6 @@ export function validateBackupPayloadContents(
accountPasskeyCredentialIds.add(credentialId); accountPasskeyCredentialIds.add(credentialId);
} }
const trustedTwoFactorTokens = new Set<string>();
for (const row of trustedTwoFactorTokenRows) {
const token = String(row.token || '').trim();
const userId = String(row.user_id || '').trim();
const deviceIdentifier = String(row.device_identifier || '').trim();
const expiresAt = Number(row.expires_at || 0);
if (!token || !userIds.has(userId) || !deviceIdentifier || !Number.isFinite(expiresAt) || expiresAt <= 0) {
throw new Error('Backup archive contains an invalid trusted two-factor device token row');
}
if (trustedTwoFactorTokens.has(token)) {
throw new Error(`Backup archive contains duplicate trusted two-factor device token: ${token}`);
}
trustedTwoFactorTokens.add(token);
}
} }
export async function buildBackupArchive( export async function buildBackupArchive(
@@ -486,7 +490,7 @@ export async function buildBackupArchive(
includeAttachments, includeAttachments,
}); });
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([ const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows] = await Promise.all([
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'), queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, created_at, updated_at FROM users ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'), queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
@@ -495,7 +499,6 @@ export async function buildBackupArchive(
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'), queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
queryRows(env.DB, 'SELECT id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
]); ]);
const exportedConfigRows = sanitizeConfigRowsForExport(configRows); const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
const exportedAttachmentRows = includeAttachments ? attachmentRows : []; const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
@@ -524,7 +527,6 @@ export async function buildBackupArchive(
ciphers: cipherRows.length, ciphers: cipherRows.length,
attachments: exportedAttachmentRows.length, attachments: exportedAttachmentRows.length,
webauthn_credentials: accountPasskeyRows.length, webauthn_credentials: accountPasskeyRows.length,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
}, },
includes: { includes: {
attachments: includeAttachments, attachments: includeAttachments,
@@ -548,7 +550,6 @@ export async function buildBackupArchive(
ciphers: cipherRows, ciphers: cipherRows,
attachments: exportedAttachmentRows, attachments: exportedAttachmentRows,
webauthn_credentials: accountPasskeyRows, webauthn_credentials: accountPasskeyRows,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
}, null, BACKUP_JSON_INDENT)), }, null, BACKUP_JSON_INDENT)),
}; };
+54 -5
View File
@@ -99,23 +99,72 @@ function isBlockedIpv4Address(octets: number[]): boolean {
); );
} }
/**
* Expand a hostname-form IPv6 literal to eight 4-digit hextets.
* Needed so compressed forms like "::1" are not misclassified by a naive
* "first non-empty hextet" check (which would read "1" and miss loopback).
*/
function expandIpv6Address(hostname: string): string[] | null {
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, '');
if (!normalized.includes(':')) return null;
if (normalized.includes('.')) {
// IPv4-embedded forms are handled separately by the caller.
return null;
}
if ((normalized.match(/::/g) || []).length > 1) return null;
const sides = normalized.split('::');
const left = sides[0] ? sides[0].split(':').filter((part) => part.length > 0) : [];
const right = sides.length > 1 && sides[1] ? sides[1].split(':').filter((part) => part.length > 0) : [];
if (left.length + right.length > 8) return null;
if (sides.length === 1 && left.length !== 8) return null;
const missing = 8 - left.length - right.length;
if (sides.length > 1 && missing < 0) return null;
const middle = sides.length > 1 ? Array.from({ length: missing }, () => '0') : [];
const parts = [...left, ...middle, ...right];
if (parts.length !== 8) return null;
const hextets: string[] = [];
for (const part of parts) {
if (!/^[0-9a-f]{1,4}$/i.test(part)) return null;
hextets.push(part.padStart(4, '0'));
}
return hextets;
}
function isBlockedIpv6Address(hostname: string): boolean { function isBlockedIpv6Address(hostname: string): boolean {
if (!hostname.includes(':')) return false; if (!hostname.includes(':')) return false;
const normalized = hostname.toLowerCase(); const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
// IPv4-mapped dotted form: ::ffff:127.0.0.1
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
if (mappedIpv4) { if (mappedIpv4) {
const octets = parseIpv4Address(mappedIpv4[1]); const octets = parseIpv4Address(mappedIpv4[1]);
return !octets || isBlockedIpv4Address(octets); return !octets || isBlockedIpv4Address(octets);
} }
const firstHextetText = normalized.split(':').find((part) => part.length > 0) || '0';
const firstHextet = Number.parseInt(firstHextetText, 16); // IPv4-mapped hex form produced by some URL parsers: ::ffff:7f00:1
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
if (mappedHex) {
const hi = Number.parseInt(mappedHex[1], 16);
const lo = Number.parseInt(mappedHex[2], 16);
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return true;
const octets = [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff];
return isBlockedIpv4Address(octets);
}
const hextets = expandIpv6Address(normalized);
if (!hextets) return true;
const firstHextet = Number.parseInt(hextets[0], 16);
if (!Number.isFinite(firstHextet)) return true; if (!Number.isFinite(firstHextet)) return true;
// After expansion, loopback (::1) and unspecified (::) have first hextet 0.
return ( return (
firstHextet === 0 || firstHextet === 0 ||
(firstHextet & 0xfe00) === 0xfc00 || (firstHextet & 0xfe00) === 0xfc00 ||
(firstHextet & 0xffc0) === 0xfe80 || (firstHextet & 0xffc0) === 0xfe80 ||
(firstHextet & 0xff00) === 0xff00 || (firstHextet & 0xff00) === 0xff00 ||
normalized.startsWith('2001:db8:') hextets.join(':').startsWith('2001:0db8:')
); );
} }
+6 -22
View File
@@ -1,6 +1,7 @@
import type { Env, User } from '../types'; import type { Env, User } from '../types';
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store'; import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config'; import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config';
import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config';
import { import {
type BackupManifestAttachmentBlob, type BackupManifestAttachmentBlob,
type BackupPayload, type BackupPayload,
@@ -19,13 +20,14 @@ import {
// shadow-table count validation, insert column lists, and frontend import // shadow-table count validation, insert column lists, and frontend import
// count types together. // count types together.
// - Do not import users.api_key, even if an older backup contains it. // - Do not import users.api_key, even if an older backup contains it.
// - Do not import, clear, or replace runtime authentication state such as
// devices, sessions, auth requests, or remembered 2FA device tokens.
type SqlRow = Record<string, string | number | null>; type SqlRow = Record<string, string | number | null>;
type BackupTableName = type BackupTableName =
| 'config' | 'config'
| 'users' | 'users'
| 'domain_settings' | 'domain_settings'
| 'user_revisions' | 'user_revisions'
| 'trusted_two_factor_device_tokens'
| 'webauthn_credentials' | 'webauthn_credentials'
| 'folders' | 'folders'
| 'ciphers' | 'ciphers'
@@ -36,7 +38,6 @@ const BACKUP_TABLES: BackupTableName[] = [
'users', 'users',
'domain_settings', 'domain_settings',
'user_revisions', 'user_revisions',
'trusted_two_factor_device_tokens',
'webauthn_credentials', 'webauthn_credentials',
'folders', 'folders',
'ciphers', 'ciphers',
@@ -54,7 +55,6 @@ export interface BackupImportResultBody {
users: number; users: number;
domainSettings: number; domainSettings: number;
userRevisions: number; userRevisions: number;
trustedTwoFactorDeviceTokens: number;
webauthnCredentials: number; webauthnCredentials: number;
folders: number; folders: number;
ciphers: number; ciphers: number;
@@ -176,7 +176,6 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
'DELETE FROM ciphers', 'DELETE FROM ciphers',
'DELETE FROM folders', 'DELETE FROM folders',
'DELETE FROM webauthn_credentials', 'DELETE FROM webauthn_credentials',
'DELETE FROM trusted_two_factor_device_tokens',
'DELETE FROM domain_settings', 'DELETE FROM domain_settings',
'DELETE FROM user_revisions', 'DELETE FROM user_revisions',
'DELETE FROM users', 'DELETE FROM users',
@@ -276,7 +275,9 @@ async function prepareImportedConfigRows(
configRows: SqlRow[], configRows: SqlRow[],
userRows: SqlRow[] userRows: SqlRow[]
): Promise<SqlRow[]> { ): Promise<SqlRow[]> {
let nextConfigRows = cloneRows(configRows || []); let nextConfigRows = cloneRows(configRows || []).filter(
(row) => String(row.key || '').trim() !== YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY
);
const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY); const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY);
const normalizedBackupSettings = await normalizeImportedBackupSettingsValue( const normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null, typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null,
@@ -306,7 +307,6 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
})), })),
domain_settings: cloneRows(payload.domain_settings || []), domain_settings: cloneRows(payload.domain_settings || []),
user_revisions: cloneRows(payload.user_revisions || []), user_revisions: cloneRows(payload.user_revisions || []),
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({ webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({
...row, ...row,
purpose: normalizeAccountPasskeyPurpose(row.purpose), purpose: normalizeAccountPasskeyPurpose(row.purpose),
@@ -659,16 +659,6 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
true true
) )
); );
await runInsertBatch(
db,
tableName('trusted_two_factor_device_tokens'),
buildInsertStatements(
db,
tableName('trusted_two_factor_device_tokens'),
['token', 'user_id', 'device_identifier', 'expires_at'],
payload.trusted_two_factor_device_tokens || []
)
);
await runInsertBatch( await runInsertBatch(
db, db,
tableName('webauthn_credentials'), tableName('webauthn_credentials'),
@@ -747,7 +737,6 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -771,7 +760,6 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -813,7 +801,6 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length, domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length, userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length, webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -891,7 +878,6 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -915,7 +901,6 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -963,7 +948,6 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length, domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length, userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length, webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
+4 -2
View File
@@ -45,7 +45,8 @@ export async function upsertDevice(
await db await db
.prepare( .prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, ' +
'session_stamp=CASE WHEN devices.session_stamp IS NULL OR devices.session_stamp = ? THEN excluded.session_stamp ELSE devices.session_stamp END, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' + 'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' + 'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' + 'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
@@ -66,7 +67,8 @@ export async function upsertDevice(
existingDevice?.deviceNote ?? null, existingDevice?.deviceNote ?? null,
now, now,
now, now,
now now,
''
) )
.run(); .run();
} }
+12 -9
View File
@@ -68,20 +68,22 @@ export async function bulkDeleteFolders(
db: D1Database, db: D1Database,
userId: string, userId: string,
ids: string[], ids: string[],
sqlChunkSize: (fixedBindCount: number) => number, sqlChunkSize: (fixedBindCount: number, bindCountPerItem?: number) => number,
updateRevisionDate: (userId: string) => Promise<string> updateRevisionDate: (userId: string) => Promise<string>
): Promise<string | null> { ): Promise<string | null> {
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!uniqueIds.length) return null; if (!uniqueIds.length) return null;
const now = new Date().toISOString(); const now = new Date().toISOString();
const chunkSize = sqlChunkSize(2); // Each folder ID is bound in all three compatibility predicates below.
const chunkSize = sqlChunkSize(2, 3);
const statements: D1PreparedStatement[] = [];
for (let i = 0; i < uniqueIds.length; i += chunkSize) { for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize); const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(','); const placeholders = chunk.map(() => '?').join(',');
await db statements.push(
.prepare( db.prepare(
`UPDATE ciphers `UPDATE ciphers
SET folder_id = NULL, updated_at = ?, SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate') data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
@@ -93,14 +95,15 @@ export async function bulkDeleteFolders(
)` )`
) )
.bind(now, userId, ...chunk, ...chunk, ...chunk) .bind(now, userId, ...chunk, ...chunk, ...chunk)
.run(); );
statements.push(
await db db.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
.bind(userId, ...chunk) .bind(userId, ...chunk)
.run(); );
} }
await db.batch(statements);
return updateRevisionDate(userId); return updateRevisionDate(userId);
} }
+91 -34
View File
@@ -11,16 +11,34 @@ export async function saveRefreshToken(
userId: string, userId: string,
expiresAtMs: number, expiresAtMs: number,
deviceIdentifier?: string | null, deviceIdentifier?: string | null,
deviceSessionStamp?: string | null deviceSessionStamp?: string | null,
securityStamp?: string | null,
clientType?: string | null,
absoluteExpiresAtMs?: number | null
): Promise<void> { ): Promise<void> {
await maybeCleanupExpiredRefreshTokens(Date.now()); await maybeCleanupExpiredRefreshTokens(Date.now());
const tokenKey = await refreshTokenKey(token); const tokenKey = await refreshTokenKey(token);
const now = Date.now();
await db await db
.prepare( .prepare(
'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' + 'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp' 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, ' +
'device_session_stamp=excluded.device_session_stamp, security_stamp=excluded.security_stamp, last_used_at=excluded.last_used_at, ' +
'absolute_expires_at=excluded.absolute_expires_at, client_type=excluded.client_type'
)
.bind(
tokenKey,
userId,
expiresAtMs,
deviceIdentifier ?? null,
deviceSessionStamp ?? null,
securityStamp ?? null,
now,
now,
absoluteExpiresAtMs ?? null,
clientType ?? null
) )
.bind(tokenKey, userId, expiresAtMs, deviceIdentifier ?? null, deviceSessionStamp ?? null)
.run(); .run();
} }
@@ -36,12 +54,25 @@ export async function getRefreshTokenRecord(
const tokenKey = await refreshTokenKey(token); const tokenKey = await refreshTokenKey(token);
const row = await db const row = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?') .prepare(
'SELECT user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type ' +
'FROM refresh_tokens WHERE token = ?'
)
.bind(tokenKey) .bind(tokenKey)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>(); .first<{
user_id: string;
expires_at: number;
device_identifier: string | null;
device_session_stamp: string | null;
security_stamp: string | null;
created_at: number | null;
last_used_at: number | null;
absolute_expires_at: number | null;
client_type: string | null;
}>();
if (!row) return null; if (!row) return null;
if (row.expires_at && row.expires_at < now) { if ((row.expires_at && row.expires_at < now) || (row.absolute_expires_at && row.absolute_expires_at < now)) {
await deleteRefreshTokenRecord(token); await deleteRefreshTokenRecord(token);
return null; return null;
} }
@@ -50,9 +81,62 @@ export async function getRefreshTokenRecord(
expiresAt: row.expires_at, expiresAt: row.expires_at,
deviceIdentifier: row.device_identifier ?? null, deviceIdentifier: row.device_identifier ?? null,
deviceSessionStamp: row.device_session_stamp ?? null, deviceSessionStamp: row.device_session_stamp ?? null,
securityStamp: row.security_stamp ?? null,
createdAt: row.created_at ?? null,
lastUsedAt: row.last_used_at ?? null,
absoluteExpiresAt: row.absolute_expires_at ?? null,
clientType: row.client_type ?? null,
}; };
} }
export async function extendRefreshTokenExpiry(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
requestedExpiresAtMs: number,
nowMs: number
): Promise<boolean> {
const tokenKey = await refreshTokenKey(token);
const result = await db
.prepare(
'UPDATE refresh_tokens SET ' +
'expires_at = CASE ' +
'WHEN absolute_expires_at IS NOT NULL AND absolute_expires_at < ? THEN absolute_expires_at ' +
'ELSE ? END, ' +
'last_used_at = ? ' +
'WHERE token = ? AND expires_at >= ? AND (absolute_expires_at IS NULL OR absolute_expires_at >= ?)'
)
.bind(requestedExpiresAtMs, requestedExpiresAtMs, nowMs, tokenKey, nowMs, nowMs)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function bindRefreshTokenSecurityStamp(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
securityStamp: string
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare('UPDATE refresh_tokens SET security_stamp = ? WHERE token = ? AND (security_stamp IS NULL OR security_stamp = ?)')
.bind(securityStamp, tokenKey, '')
.run();
}
export async function bindRefreshTokenDeviceStamp(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
deviceSessionStamp: string
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare('UPDATE refresh_tokens SET device_session_stamp = ? WHERE token = ? AND (device_session_stamp IS NULL OR device_session_stamp = ?)')
.bind(deviceSessionStamp, tokenKey, '')
.run();
}
export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> { export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> {
const tokenKey = await refreshTokenKey(token); const tokenKey = await refreshTokenKey(token);
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run(); await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
@@ -71,30 +155,3 @@ export async function deleteRefreshTokensByDevice(db: D1Database, userId: string
.run(); .run();
return Number(result.meta.changes ?? 0); return Number(result.meta.changes ?? 0);
} }
export async function constrainRefreshTokenExpiry(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
maxExpiresAtMs: number
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare(
'UPDATE refresh_tokens ' +
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
'WHERE token = ?'
)
.bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey)
.run();
await db
.prepare(
'UPDATE refresh_tokens ' +
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
'WHERE token = ?'
)
.bind(maxExpiresAtMs, maxExpiresAtMs, token)
.run();
}
+12 -1
View File
@@ -74,11 +74,20 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE sends ADD COLUMN emails TEXT', 'ALTER TABLE sends ADD COLUMN emails TEXT',
'CREATE TABLE IF NOT EXISTS refresh_tokens (' + 'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, ' + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, security_stamp TEXT, created_at INTEGER, last_used_at INTEGER, absolute_expires_at INTEGER, client_type TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)', 'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT', 'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT', 'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN security_stamp TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN created_at INTEGER',
'ALTER TABLE refresh_tokens ADD COLUMN last_used_at INTEGER',
'ALTER TABLE refresh_tokens ADD COLUMN absolute_expires_at INTEGER',
'ALTER TABLE refresh_tokens ADD COLUMN client_type TEXT',
"UPDATE refresh_tokens SET security_stamp = (SELECT users.security_stamp FROM users WHERE users.id = refresh_tokens.user_id) WHERE security_stamp IS NULL OR security_stamp = ''",
"UPDATE refresh_tokens SET created_at = CAST(strftime('%s','now') AS INTEGER) * 1000 WHERE created_at IS NULL",
"UPDATE refresh_tokens SET last_used_at = created_at WHERE last_used_at IS NULL",
'UPDATE refresh_tokens SET absolute_expires_at = expires_at WHERE absolute_expires_at IS NULL',
'CREATE TABLE IF NOT EXISTS invites (' + 'CREATE TABLE IF NOT EXISTS invites (' +
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
@@ -118,6 +127,8 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT', 'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)',
"UPDATE refresh_tokens SET device_session_stamp = (SELECT devices.session_stamp FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier) WHERE device_identifier IS NOT NULL AND (device_session_stamp IS NULL OR device_session_stamp = '') AND EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier)",
"UPDATE refresh_tokens SET client_type = CASE WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type IN (0, 1)) THEN 'mobile' WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type = 14) THEN 'web' ELSE 'other' END WHERE client_type IS NULL OR client_type = ''",
'CREATE TABLE IF NOT EXISTS auth_requests (' + 'CREATE TABLE IF NOT EXISTS auth_requests (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
+31 -12
View File
@@ -87,10 +87,12 @@ import {
saveSend as saveStoredSend, saveSend as saveStoredSend,
} from './storage-send-repo'; } from './storage-send-repo';
import { import {
constrainRefreshTokenExpiry as constrainStoredRefreshTokenExpiry, bindRefreshTokenDeviceStamp as bindStoredRefreshTokenDeviceStamp,
bindRefreshTokenSecurityStamp as bindStoredRefreshTokenSecurityStamp,
deleteRefreshToken as deleteStoredRefreshToken, deleteRefreshToken as deleteStoredRefreshToken,
deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice, deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice,
deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId, deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId,
extendRefreshTokenExpiry as extendStoredRefreshTokenExpiry,
getRefreshTokenRecord as findStoredRefreshTokenRecord, getRefreshTokenRecord as findStoredRefreshTokenRecord,
saveRefreshToken as saveStoredRefreshToken, saveRefreshToken as saveStoredRefreshToken,
} from './storage-refresh-token-repo'; } from './storage-refresh-token-repo';
@@ -162,7 +164,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value // changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version. // differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-07-05-passkey-2fa'; const STORAGE_SCHEMA_VERSION = '2026-07-13-refresh-session-reuse';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
// D1-backed storage. // D1-backed storage.
@@ -207,10 +209,15 @@ export class StorageService {
return REQUIRED_SCHEMA_TABLES.every((table) => found.has(table)); return REQUIRED_SCHEMA_TABLES.every((table) => found.has(table));
} }
private sqlChunkSize(fixedBindCount: number): number { private sqlChunkSize(fixedBindCount: number, bindCountPerItem = 1): number {
const safeFixedBindCount = Math.max(0, Math.floor(fixedBindCount));
const safeBindCountPerItem = Math.max(1, Math.floor(bindCountPerItem));
return Math.max( return Math.max(
1, 1,
Math.min(LIMITS.performance.bulkMoveChunkSize, StorageService.MAX_D1_SQL_VARIABLES - fixedBindCount) Math.min(
LIMITS.performance.bulkMoveChunkSize,
Math.floor((StorageService.MAX_D1_SQL_VARIABLES - safeFixedBindCount) / safeBindCountPerItem)
)
); );
} }
@@ -632,9 +639,13 @@ export class StorageService {
userId: string, userId: string,
expiresAtMs?: number, expiresAtMs?: number,
deviceIdentifier?: string | null, deviceIdentifier?: string | null,
deviceSessionStamp?: string | null deviceSessionStamp?: string | null,
securityStamp?: string | null,
clientType?: string | null,
absoluteExpiresAtMs?: number | null
): Promise<void> { ): Promise<void> {
const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs); const now = Date.now();
const expiresAt = expiresAtMs ?? (now + LIMITS.auth.refreshTokenDefaultSlidingTtlMs);
await saveStoredRefreshToken( await saveStoredRefreshToken(
this.db, this.db,
this.refreshTokenKey.bind(this), this.refreshTokenKey.bind(this),
@@ -643,7 +654,10 @@ export class StorageService {
userId, userId,
expiresAt, expiresAt,
deviceIdentifier, deviceIdentifier,
deviceSessionStamp deviceSessionStamp,
securityStamp,
clientType,
absoluteExpiresAtMs ?? (now + LIMITS.auth.refreshTokenAbsoluteTtlMs)
); );
} }
@@ -717,11 +731,16 @@ export class StorageService {
return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier); return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier);
} }
// Keep a short overlap window for rotated refresh token to reduce async extendRefreshTokenExpiry(token: string, requestedExpiresAtMs: number, nowMs: number = Date.now()): Promise<boolean> {
// multi-context refresh races (e.g. browser extension popup/background). return extendStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, requestedExpiresAtMs, nowMs);
// Expiry is only tightened, never extended. }
async constrainRefreshTokenExpiry(token: string, maxExpiresAtMs: number): Promise<void> {
await constrainStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, maxExpiresAtMs); async bindRefreshTokenSecurityStamp(token: string, securityStamp: string): Promise<void> {
await bindStoredRefreshTokenSecurityStamp(this.db, this.refreshTokenKey.bind(this), token, securityStamp);
}
async bindRefreshTokenDeviceStamp(token: string, deviceSessionStamp: string): Promise<void> {
await bindStoredRefreshTokenDeviceStamp(this.db, this.refreshTokenKey.bind(this), token, deviceSessionStamp);
} }
private async trustedTwoFactorTokenKey(token: string): Promise<string> { private async trustedTwoFactorTokenKey(token: string): Promise<string> {
+97
View File
@@ -0,0 +1,97 @@
import {
requestYubicoApiCredentials,
type YubicoApiCredentials,
} from '../utils/yubico-otp';
export const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
export const YUBICO_SECRET_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
export const YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY = 'yubico.bootstrap.claim.v1';
const YUBICO_BOOTSTRAP_CLAIM_TTL_MS = 2 * 60 * 1000;
export interface YubicoCredentialInitializationResult {
credentials: YubicoApiCredentials;
created: boolean;
}
export async function getYubicoCredentials(db: D1Database): Promise<YubicoApiCredentials | null> {
const result = await db
.prepare('SELECT key, value FROM config WHERE key IN (?, ?)')
.bind(YUBICO_CLIENT_ID_CONFIG_KEY, YUBICO_SECRET_KEY_CONFIG_KEY)
.all<{ key: string; value: string }>();
const values = new Map((result.results || []).map((row) => [row.key, String(row.value || '').trim()]));
const clientId = values.get(YUBICO_CLIENT_ID_CONFIG_KEY) || '';
const secretKey = values.get(YUBICO_SECRET_KEY_CONFIG_KEY) || '';
return clientId && secretKey ? { clientId, secretKey } : null;
}
export async function replaceYubicoCredentials(
db: D1Database,
credentials: YubicoApiCredentials
): Promise<void> {
const clientId = String(credentials.clientId || '').trim();
const secretKey = String(credentials.secretKey || '').trim();
if (!clientId || !secretKey) throw new Error('Yubico credentials are incomplete');
await db.batch([
db.prepare(
'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).bind(YUBICO_CLIENT_ID_CONFIG_KEY, clientId),
db.prepare(
'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).bind(YUBICO_SECRET_KEY_CONFIG_KEY, secretKey),
]);
}
async function acquireBootstrapClaim(db: D1Database): Promise<string | null> {
const now = Date.now();
await db
.prepare('DELETE FROM config WHERE key = ? AND CAST(value AS INTEGER) < ?')
.bind(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, now)
.run();
const claim = `${now + YUBICO_BOOTSTRAP_CLAIM_TTL_MS}:${crypto.randomUUID()}`;
const result = await db
.prepare('INSERT OR IGNORE INTO config(key, value) VALUES(?, ?)')
.bind(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, claim)
.run();
return (result.meta.changes ?? 0) > 0 ? claim : null;
}
async function releaseBootstrapClaim(db: D1Database, claim: string): Promise<void> {
await db
.prepare('DELETE FROM config WHERE key = ? AND value = ?')
.bind(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, claim)
.run();
}
export async function initializeYubicoCredentialsOnce(
db: D1Database,
email: string,
otp: string
): Promise<YubicoCredentialInitializationResult | null> {
const existing = await getYubicoCredentials(db);
if (existing) return { credentials: existing, created: false };
const claim = await acquireBootstrapClaim(db);
if (!claim) {
const concurrentlyCreated = await getYubicoCredentials(db);
return concurrentlyCreated ? { credentials: concurrentlyCreated, created: false } : null;
}
try {
const rechecked = await getYubicoCredentials(db);
if (rechecked) return { credentials: rechecked, created: false };
const issued = await requestYubicoApiCredentials(email, otp);
if (!issued?.clientId || !issued.secretKey) return null;
const configuredDuringRequest = await getYubicoCredentials(db);
if (configuredDuringRequest) {
return { credentials: configuredDuringRequest, created: false };
}
await replaceYubicoCredentials(db, issued);
return { credentials: issued, created: true };
} finally {
await releaseBootstrapClaim(db, claim).catch(() => undefined);
}
}
+7 -4
View File
@@ -6,6 +6,8 @@ export interface Env {
ASSETS?: { ASSETS?: {
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>; fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
}; };
// Set to "1" to return 404 for the Web Vault while keeping client APIs available.
HIDE_WEB_VAULT?: string;
// Prefer R2 when available. Optional to support KV-only deployments. // Prefer R2 when available. Optional to support KV-only deployments.
ATTACHMENTS?: R2Bucket; ATTACHMENTS?: R2Bucket;
// Optional fallback for attachment/send file storage (no credit card required). // Optional fallback for attachment/send file storage (no credit card required).
@@ -14,11 +16,7 @@ export interface Env {
WEBAUTHN_RP_ID?: string; WEBAUTHN_RP_ID?: string;
WEBAUTHN_RP_NAME?: string; WEBAUTHN_RP_NAME?: string;
WEBAUTHN_ALLOWED_ORIGINS?: string; WEBAUTHN_ALLOWED_ORIGINS?: string;
YUBICO_CLIENT_ID?: string;
YUBICO_SECRET_KEY?: string;
YUBICO_VALIDATION_URLS?: string; YUBICO_VALIDATION_URLS?: string;
'globalSettings__yubico__clientId'?: string;
'globalSettings__yubico__key'?: string;
'globalSettings__yubico__validationUrls'?: string; 'globalSettings__yubico__validationUrls'?: string;
} }
@@ -402,6 +400,11 @@ export interface RefreshTokenRecord {
expiresAt: number; expiresAt: number;
deviceIdentifier: string | null; deviceIdentifier: string | null;
deviceSessionStamp: string | null; deviceSessionStamp: string | null;
securityStamp: string | null;
createdAt: number | null;
lastUsedAt: number | null;
absoluteExpiresAt: number | null;
clientType: string | null;
} }
export interface TrustedDeviceTokenSummary { export interface TrustedDeviceTokenSummary {
+7 -1
View File
@@ -29,7 +29,13 @@ export async function hashApiKey(apiKey: string): Promise<string> {
export async function verifyApiKey(apiKey: string, storedApiKey: string | null | undefined): Promise<boolean> { export async function verifyApiKey(apiKey: string, storedApiKey: string | null | undefined): Promise<boolean> {
const stored = String(storedApiKey || '').trim(); const stored = String(storedApiKey || '').trim();
if (!isStoredApiKeyHash(stored)) return false; if (!stored) return false;
// Legacy NodeWarden rows stored a one-way hash. Keep them usable until the
// user explicitly rotates once into the Bitwarden-compatible readable form.
if (!isStoredApiKeyHash(stored)) {
return constantTimeEquals(apiKey, stored);
}
const hashed = await hashApiKey(apiKey); const hashed = await hashApiKey(apiKey);
return constantTimeEquals(hashed, stored); return constantTimeEquals(hashed, stored);
+18 -1
View File
@@ -8,6 +8,13 @@ export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn', 'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
] as const; ] as const;
// Bitwarden desktop is migrating from file:// to this privileged Electron
// origin. Official clients keep the legacy file:// path as a compatibility
// fallback while self-hosted servers add CORS support for the new origin.
export const OFFICIAL_BITWARDEN_DESKTOP_ORIGINS = [
'bw-desktop-file://bundle',
] as const;
export function normalizeOrigin(value: unknown): string | null { export function normalizeOrigin(value: unknown): string | null {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (!raw) return null; if (!raw) return null;
@@ -30,10 +37,20 @@ export function isBrowserExtensionOrigin(origin: unknown): boolean {
); );
} }
export function isOfficialBitwardenDesktopOrigin(origin: unknown): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && OFFICIAL_BITWARDEN_DESKTOP_ORIGINS.includes(
normalized as (typeof OFFICIAL_BITWARDEN_DESKTOP_ORIGINS)[number]
);
}
export function getConfiguredWebAuthnAllowedOrigins( export function getConfiguredWebAuthnAllowedOrigins(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'> env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
): string[] { ): string[] {
const seen = new Set<string>(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS); const seen = new Set<string>([
...OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS,
...OFFICIAL_BITWARDEN_DESKTOP_ORIGINS,
]);
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) { for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
const origin = normalizeOrigin(item); const origin = normalizeOrigin(item);
if (origin) seen.add(origin); if (origin) seen.add(origin);
+27 -5
View File
@@ -3,6 +3,7 @@ import type { Env } from '../types';
import { import {
isBrowserExtensionOrigin, isBrowserExtensionOrigin,
isConfiguredWebAuthnAllowedOrigin, isConfiguredWebAuthnAllowedOrigin,
isOfficialBitwardenDesktopOrigin,
normalizeOrigin, normalizeOrigin,
} from './origins'; } from './origins';
@@ -48,7 +49,10 @@ function getCorsPolicy(request: Request, env: Env): { allowOrigin: string | null
if (origin === url.origin) { if (origin === url.origin) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
} }
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) { if (
(isBrowserExtensionOrigin(origin) || isOfficialBitwardenDesktopOrigin(origin))
&& isConfiguredWebAuthnAllowedOrigin(env, origin)
) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
} }
if (isWildcardCorsPath(url.pathname)) { if (isWildcardCorsPath(url.pathname)) {
@@ -100,10 +104,22 @@ export function applyCors(
headers.set(k, v); headers.set(k, v);
} }
// Security headers applied to every response. // Security headers applied to every response.
headers.set('X-Frame-Options', 'DENY');
headers.set('X-Content-Type-Options', 'nosniff'); headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
if (!headers.has('Content-Security-Policy')) { const isWebAuthnFrameConnector = new URL(request.url).pathname === '/webauthn-connector.html';
if (isWebAuthnFrameConnector) {
// Official desktop and browser clients render this exact endpoint inside a
// 40px cross-origin iframe. The connector validates its parent before any
// WebAuthn request or postMessage, so only this protocol page may be framed.
headers.delete('X-Frame-Options');
headers.set(
'Content-Security-Policy',
"default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'"
);
} else {
headers.set('X-Frame-Options', 'DENY');
}
if (!isWebAuthnFrameConnector && !headers.has('Content-Security-Policy')) {
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:"); headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
} }
return new Response(response.body, { return new Response(response.body, {
@@ -144,7 +160,12 @@ export function unsupportedResponse(message: string = 'This feature is not suppo
} }
// Identity endpoint error response (for /identity/connect/token) // Identity endpoint error response (for /identity/connect/token)
export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response { export function identityErrorResponse(
message: string,
error: string = 'invalid_grant',
status: number = 400,
headers: Record<string, string> = {}
): Response {
return jsonResponse( return jsonResponse(
{ {
error: error, error: error,
@@ -154,7 +175,8 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
Object: 'error', Object: 'error',
}, },
}, },
status status,
{ 'Cache-Control': 'no-store', Pragma: 'no-cache', ...headers }
); );
} }
+4
View File
@@ -17,8 +17,12 @@ export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>):
wrappedPrivateKey: user.privateKey, wrappedPrivateKey: user.privateKey,
publicKey, publicKey,
signedPublicKey: null, signedPublicKey: null,
object: 'publicKeyEncryptionKeyPair',
Object: 'publicKeyEncryptionKeyPair', Object: 'publicKeyEncryptionKeyPair',
}, },
securityState: null,
signatureKeyPair: null,
object: 'privateKeys',
Object: 'privateKeys', Object: 'privateKeys',
}; };
} }
+3 -13
View File
@@ -48,12 +48,6 @@ export function isYubiKeyEnabled(user: User): boolean {
return userYubiKeyPublicIds(user).length > 0; return userYubiKeyPublicIds(user).length > 0;
} }
export function yubicoCredentialsFromEnv(env: Env): YubicoApiCredentials | null {
const clientId = String(env['globalSettings__yubico__clientId'] || env.YUBICO_CLIENT_ID || '').trim();
const secretKey = String(env['globalSettings__yubico__key'] || env.YUBICO_SECRET_KEY || '').trim();
return clientId ? { clientId, secretKey } : null;
}
function randomNonce(): string { function randomNonce(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16)); const bytes = crypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join(''); return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join('');
@@ -143,28 +137,26 @@ export async function requestYubicoApiCredentials(email: string, otpInput: strin
export async function verifyYubicoOtp( export async function verifyYubicoOtp(
env: Env, env: Env,
otpInput: string, otpInput: string,
credentials: YubicoApiCredentials | null = yubicoCredentialsFromEnv(env) credentials: YubicoApiCredentials | null
): Promise<boolean> { ): Promise<boolean> {
const otp = normalizeYubiKeyOtp(otpInput); const otp = normalizeYubiKeyOtp(otpInput);
if (!isYubiKeyOtp(otp)) return false; if (!isYubiKeyOtp(otp)) return false;
const clientId = String(credentials?.clientId || '').trim(); const clientId = String(credentials?.clientId || '').trim();
if (!clientId) return false; const secretKey = String(credentials?.secretKey || '').trim();
if (!clientId || !secretKey) return false;
const nonce = randomNonce(); const nonce = randomNonce();
const secretKey = String(credentials?.secretKey || '').trim();
const params = new URLSearchParams({ const params = new URLSearchParams({
id: clientId, id: clientId,
nonce, nonce,
otp, otp,
}); });
if (secretKey) {
try { try {
params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params))); params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params)));
} catch { } catch {
return false; return false;
} }
}
for (const baseUrl of validationUrls(env)) { for (const baseUrl of validationUrls(env)) {
try { try {
@@ -172,14 +164,12 @@ export async function verifyYubicoOtp(
if (!response.ok) continue; if (!response.ok) continue;
const parsed = parseYubicoResponse(await response.text()); const parsed = parseYubicoResponse(await response.text());
if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue; if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue;
if (secretKey) {
if (!parsed.h) continue; if (!parsed.h) continue;
const signedParams = new URLSearchParams(); const signedParams = new URLSearchParams();
for (const [key, value] of Object.entries(parsed)) { for (const [key, value] of Object.entries(parsed)) {
if (key !== 'h') signedParams.set(key, value); if (key !== 'h') signedParams.set(key, value);
} }
if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue; if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue;
}
return true; return true;
} catch { } catch {
continue; continue;
+47
View File
@@ -0,0 +1,47 @@
import type { Env } from './types';
const BACKEND_PATH_PREFIXES = [
'/api',
'/identity',
'/icons',
'/fill-assist',
'/notifications',
'/.well-known',
// Compatibility aliases retained for older Bitwarden clients.
'/devices',
'/auth-requests',
'/webauthn',
] as const;
const BACKEND_EXACT_PATHS = new Set([
'/v1/assetlinks:check',
'/web-bootstrap',
'/config',
'/accounts/kdf',
'/settings/domains',
]);
export function isBackendRequestPath(pathname: string): boolean {
const path = pathname.toLowerCase();
if (BACKEND_EXACT_PATHS.has(path)) return true;
return BACKEND_PATH_PREFIXES.some((prefix) => (
path === prefix || path.startsWith(`${prefix}/`)
));
}
export function isWebVaultHidden(env: Env): boolean {
return String(env.HIDE_WEB_VAULT || '').trim() === '1';
}
export function webVaultNotFoundResponse(request: Request): Response {
const body = request.method === 'HEAD' ? null : 'Not Found';
return new Response(body, {
status: 404,
headers: {
'Cache-Control': 'no-store, max-age=0',
'Content-Type': 'text/plain; charset=utf-8',
'X-Robots-Tag': 'noindex, nofollow, noarchive, nosnippet',
},
});
}
+1 -1
View File
@@ -9,7 +9,7 @@
script-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';
img-src 'self' data:; img-src 'self' data:;
connect-src 'self'; connect-src 'self' https://api.pwnedpasswords.com;
font-src 'self'; font-src 'self';
form-action 'self'; form-action 'self';
base-uri 'self'; base-uri 'self';
+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'"
/>
<title>NodeWarden WebAuthn Connector</title>
<style>
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: transparent;
}
body {
display: flex;
align-items: center;
justify-content: center;
}
button {
width: 100%;
min-height: 40px;
padding: 8px 14px;
border: 1px solid #2563eb;
border-radius: 10px;
background: #2563eb;
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 800;
line-height: 1.2;
transition: background-color 160ms ease, border-color 160ms ease;
}
button:hover {
border-color: #1d4ed8;
background: #1d4ed8;
}
button:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
button[aria-disabled="true"] {
border-color: #d0d5dd;
background: #d0d5dd;
color: #667085;
cursor: not-allowed;
}
</style>
</head>
<body>
<button id="webauthn-button" type="button" aria-live="polite">Read security key</button>
<script type="module" src="/webauthn-connector.js"></script>
</body>
</html>
+268
View File
@@ -0,0 +1,268 @@
const OFFICIAL_DESKTOP_ORIGIN = 'bw-desktop-file://bundle';
function safeDecodeURIComponent(value) {
let decoded = String(value || '');
for (let index = 0; index < 2 && /%[0-9a-f]{2}/i.test(decoded); index += 1) {
try {
const next = decodeURIComponent(decoded);
if (next === decoded) break;
decoded = next;
} catch (_error) {
break;
}
}
return decoded;
}
export function decodeBase64Utf8(value) {
let normalized = String(value || '').replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
let binary;
try {
binary = atob(normalized);
} catch (_error) {
throw new Error('Cannot parse WebAuthn data.');
}
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
if (typeof TextDecoder !== 'undefined') return new TextDecoder().decode(bytes);
return decodeURIComponent(Array.from(bytes, (byte) => `%${byte.toString(16).padStart(2, '0')}`).join(''));
}
export function bytesFromBase64Url(value) {
let normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
try {
return Uint8Array.from(atob(normalized), (character) => character.charCodeAt(0));
} catch (_error) {
throw new Error('Cannot parse WebAuthn data.');
}
}
export function base64UrlFromBuffer(value) {
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
let binary = '';
for (let index = 0; index < bytes.length; index += 1) binary += String.fromCharCode(bytes[index]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
export function parseConnectorRequest(search) {
const params = search instanceof URLSearchParams
? search
: new URLSearchParams(String(search || '').replace(/^\?/, ''));
const parentUrl = safeDecodeURIComponent(params.get('parent'));
const encodedData = params.get('data');
if (!parentUrl) throw new Error('No parent.');
if (!encodedData) throw new Error('No data.');
let parsedParent;
try {
parsedParent = new URL(parentUrl);
} catch (_error) {
throw new Error('Invalid parent.');
}
let webauthnJson;
if (params.get('v') === '1') {
webauthnJson = decodeBase64Utf8(encodedData);
} else {
let payload;
try {
payload = JSON.parse(decodeBase64Utf8(encodedData));
} catch (_error) {
throw new Error('Cannot parse data.');
}
if (!payload || (typeof payload.data !== 'string' && typeof payload.data !== 'object')) {
throw new Error('Cannot parse data.');
}
webauthnJson = typeof payload.data === 'string' ? payload.data : JSON.stringify(payload.data);
}
return {
parentUrl,
parentProtocol: parsedParent.protocol.toLowerCase(),
parentOrigin: parsedParent.origin,
webauthnJson,
buttonText: safeDecodeURIComponent(params.get('btnText')),
awaitingText: safeDecodeURIComponent(params.get('btnAwaitingInteractionText')),
};
}
export function normalizePublicKeyOptions(webauthnJson) {
const source = typeof webauthnJson === 'string' ? JSON.parse(webauthnJson) : webauthnJson;
if (!source || typeof source !== 'object' || !source.challenge) throw new Error('Cannot parse WebAuthn data.');
const publicKey = { ...source, challenge: bytesFromBase64Url(source.challenge) };
if (Array.isArray(source.allowCredentials)) {
publicKey.allowCredentials = source.allowCredentials.map((credential) => ({
...credential,
id: bytesFromBase64Url(credential?.id),
}));
}
return publicKey;
}
export function buildCredentialData(assertedCredential) {
const response = assertedCredential?.response;
if (!assertedCredential || !response?.authenticatorData || !response?.clientDataJSON || !response?.signature) {
throw new Error('The authenticator returned an incomplete response.');
}
return JSON.stringify({
id: assertedCredential.id,
rawId: base64UrlFromBuffer(assertedCredential.rawId),
type: assertedCredential.type,
extensions: typeof assertedCredential.getClientExtensionResults === 'function'
? assertedCredential.getClientExtensionResults()
: {},
response: {
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
clientDataJson: base64UrlFromBuffer(response.clientDataJSON),
signature: base64UrlFromBuffer(response.signature),
},
});
}
function normalizeAllowedOrigin(value) {
try {
const url = new URL(String(value || ''));
return url.protocol && url.host ? `${url.protocol}//${url.host}` : '';
} catch (_error) {
return '';
}
}
function isExtensionOrigin(origin) {
return origin.startsWith('chrome-extension://')
|| origin.startsWith('moz-extension://')
|| origin.startsWith('safari-web-extension://');
}
export function resolveParentChannel(request, connectorOrigin, allowedOrigins = []) {
if (request.parentProtocol === 'file:') {
return { eventOrigin: 'null', targetOrigin: request.parentUrl };
}
const parentOrigin = normalizeAllowedOrigin(request.parentUrl);
if (!parentOrigin) throw new Error('Invalid parent.');
if (parentOrigin === connectorOrigin) {
return { eventOrigin: parentOrigin, targetOrigin: parentOrigin };
}
if (parentOrigin === OFFICIAL_DESKTOP_ORIGIN) {
return { eventOrigin: parentOrigin, targetOrigin: request.parentUrl };
}
const trustedOrigins = allowedOrigins.map(normalizeAllowedOrigin).filter(Boolean);
if (isExtensionOrigin(parentOrigin) && trustedOrigins.includes(parentOrigin)) {
return { eventOrigin: parentOrigin, targetOrigin: parentOrigin };
}
throw new Error('Untrusted parent.');
}
async function loadAllowedParentOrigins() {
try {
const response = await fetch('/api/web-bootstrap', {
headers: { Accept: 'application/json' },
credentials: 'omit',
});
if (!response.ok) return [];
const body = await response.json();
return Array.isArray(body?.webAuthnAllowedOrigins) ? body.webAuthnAllowedOrigins : [];
} catch (_error) {
return [];
}
}
function browserErrorMessage(error) {
return error?.message || String(error || 'WebAuthn failed.');
}
async function initializePage() {
const button = document.getElementById('webauthn-button');
if (!button) return;
let request;
let publicKey;
let channel;
let stopWebAuthn = false;
let sentSuccess = false;
let running = false;
const defaultText = 'Read security key';
const awaitingDefaultText = 'Awaiting security key interaction...';
function setButton(awaiting = false) {
button.textContent = awaiting
? request?.awaitingText || awaitingDefaultText
: request?.buttonText || defaultText;
button.setAttribute('aria-disabled', awaiting ? 'true' : 'false');
button.setAttribute('aria-busy', awaiting ? 'true' : 'false');
button.onclick = awaiting ? null : executeWebAuthn;
}
function post(message) {
window.parent.postMessage(message, channel.targetOrigin);
}
function reportError(error) {
if (channel) post(`error|${browserErrorMessage(error)}`);
setButton(false);
}
async function executeWebAuthn() {
if (running || sentSuccess) return;
if (stopWebAuthn) {
stopWebAuthn = false;
setButton(false);
return;
}
running = true;
setButton(true);
try {
const credential = await navigator.credentials.get({ publicKey });
if (!credential) throw new Error('No security key was selected.');
if (sentSuccess) return;
post(`success|${buildCredentialData(credential)}`);
sentSuccess = true;
} catch (error) {
reportError(error);
} finally {
running = false;
}
}
try {
request = parseConnectorRequest(window.location.search);
publicKey = normalizePublicKeyOptions(request.webauthnJson);
channel = resolveParentChannel(request, window.location.origin, await loadAllowedParentOrigins());
setButton(false);
} catch (error) {
button.textContent = browserErrorMessage(error);
button.setAttribute('aria-disabled', 'true');
return;
}
if (!navigator.credentials || typeof navigator.credentials.get !== 'function' || !window.PublicKeyCredential) {
reportError(new Error('WebAuthn is not supported in this browser.'));
return;
}
window.addEventListener('message', (event) => {
if (event.source !== window.parent || event.origin !== channel.eventOrigin) return;
if (event.data === 'stop') {
stopWebAuthn = true;
setButton(false);
} else if (event.data === 'start' && stopWebAuthn) {
stopWebAuthn = false;
void executeWebAuthn();
}
});
post('info|ready');
const isSafari = navigator.userAgent.includes(' Safari/') && !navigator.userAgent.includes('Chrome');
if (!isSafari) void executeWebAuthn();
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => void initializePage(), { once: true });
} else {
void initializePage();
}
}
@@ -0,0 +1,174 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#f6f8fb" />
<meta name="color-scheme" content="light" />
<meta name="referrer" content="no-referrer" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self'; connect-src 'none'; base-uri 'none'; form-action 'none'"
/>
<title>NodeWarden WebAuthn Connector</title>
<style>
:root {
color-scheme: light;
--primary: #2563eb;
--primary-strong: #1d4ed8;
--text: #101828;
--muted: #667085;
--line: #d8e0ec;
--panel: #ffffff;
--surface: #f6f8fb;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background: var(--surface);
color: var(--text);
}
main {
display: grid;
min-height: 100vh;
min-height: 100svh;
place-items: center;
padding: max(28px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) max(28px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left));
}
.connector-card {
width: min(100%, 430px);
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel);
box-shadow: 0 18px 44px rgba(16, 24, 40, 0.10);
padding: 28px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 28px;
}
.brand img {
width: 44px;
height: 44px;
object-fit: contain;
}
.brand strong {
font-size: 18px;
line-height: 1;
}
h1 {
margin: 0 0 8px;
font-size: 26px;
line-height: 1.2;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.form {
display: grid;
gap: 16px;
margin-top: 24px;
}
button {
min-height: 48px;
width: 100%;
border: 1px solid var(--primary);
border-radius: 10px;
background: var(--primary);
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 800;
transition: background-color 160ms ease, border-color 160ms ease, transform 120ms ease;
}
button:hover:not(:disabled)[aria-disabled="false"] {
background: var(--primary-strong);
border-color: var(--primary-strong);
}
button:active:not(:disabled)[aria-disabled="false"] {
transform: translateY(1px);
}
button:disabled,
button[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.62;
}
button[data-state="return"] {
cursor: pointer;
opacity: 1;
}
.msg {
display: none;
border-radius: 10px;
padding: 11px 12px;
font-size: 14px;
line-height: 1.45;
}
.msg.show {
display: block;
}
.msg.info {
border: 1px solid #bfdbfe;
background: #eff6ff;
color: #1e40af;
}
.msg.error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #991b1b;
}
.msg.success {
border: 1px solid #bbf7d0;
background: #f0fdf4;
color: #166534;
}
</style>
</head>
<body>
<main>
<section class="connector-card" aria-labelledby="webauthn-header">
<div class="brand">
<img src="/nodewarden-logo.svg" alt="NodeWarden" />
<strong>NodeWarden</strong>
</div>
<h1 id="webauthn-header">Verify your identity</h1>
<p id="webauthn-copy">Use your security key to finish two-step verification.</p>
<div class="form">
<div id="webauthn-status" class="msg" role="status" aria-live="polite" hidden></div>
<button id="webauthn-button" type="button" data-state="loading" aria-busy="true" aria-disabled="true">
Preparing passkey…
</button>
</div>
</section>
</main>
<script type="module" src="/webauthn-mobile-connector.js"></script>
</body>
</html>
+272
View File
@@ -0,0 +1,272 @@
const CUSTOM_SCHEME_CALLBACK = 'bitwarden://webauthn-callback';
const APP_LINK_HOSTS = ['bitwarden.com', 'bitwarden.eu', 'bitwarden.pw', 'bitwarden-gov.com'];
function safeDecodeURIComponent(value) {
let decoded = String(value || '');
for (let index = 0; index < 2 && /%[0-9a-f]{2}/i.test(decoded); index += 1) {
try {
decoded = decodeURIComponent(decoded);
} catch (_error) {
break;
}
}
return decoded;
}
export function decodeBase64Utf8(value) {
let normalized = String(value || '').replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
let binary;
try {
binary = atob(normalized);
} catch (_error) {
throw new Error('The WebAuthn challenge is not valid Base64.');
}
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
if (typeof TextDecoder !== 'undefined') return new TextDecoder().decode(bytes);
return decodeURIComponent(Array.from(bytes, (byte) => `%${byte.toString(16).padStart(2, '0')}`).join(''));
}
export function bytesFromBase64Url(value) {
let normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
try {
return Uint8Array.from(atob(normalized), (character) => character.charCodeAt(0));
} catch (_error) {
throw new Error('The WebAuthn challenge contains invalid binary data.');
}
}
export function base64UrlFromBuffer(value) {
if (value == null) return undefined;
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
let binary = '';
for (let index = 0; index < bytes.length; index += 1) binary += String.fromCharCode(bytes[index]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function officialAppLinkHost(hostname) {
const normalized = String(hostname || '').toLowerCase();
return APP_LINK_HOSTS.find((host) => normalized === host || normalized.endsWith(`.${host}`)) || 'bitwarden.com';
}
export function resolveMobileCallbackUri({ deeplinkScheme, payload, hostname, legacyMobile = false }) {
// Match Bitwarden's connector protocol: the scheme parameter governs the
// callback shape. Any non-HTTPS scheme resolves to Bitwarden's fixed custom
// scheme; a client-provided callbackUri is only a mobile-flow signal.
if (deeplinkScheme) {
return String(deeplinkScheme).toLowerCase() === 'https'
? `https://${officialAppLinkHost(hostname)}/webauthn-callback`
: CUSTOM_SCHEME_CALLBACK;
}
return payload?.mobile === true || payload?.callbackUri != null || legacyMobile
? CUSTOM_SCHEME_CALLBACK
: null;
}
export function parseConnectorRequest(search, hostname = '') {
const params = search instanceof URLSearchParams
? search
: new URLSearchParams(String(search || '').replace(/^\?/, ''));
const encodedData = params.get('data');
if (!encodedData) throw new Error('No WebAuthn challenge was provided.');
const version = params.get('v');
let payload = null;
let webauthnJson;
let headerText;
let buttonText;
let returnButtonText;
let awaitingText;
if (version === '1') {
webauthnJson = decodeBase64Utf8(encodedData);
headerText = params.get('headerText');
buttonText = params.get('btnText');
returnButtonText = params.get('btnReturnText');
awaitingText = params.get('btnAwaitingInteractionText');
} else {
try {
payload = JSON.parse(decodeBase64Utf8(encodedData));
} catch (_error) {
throw new Error('The WebAuthn challenge could not be decoded.');
}
if (!payload || (typeof payload.data !== 'string' && typeof payload.data !== 'object')) {
throw new Error('The WebAuthn challenge is incomplete.');
}
webauthnJson = typeof payload.data === 'string' ? payload.data : JSON.stringify(payload.data);
headerText = payload.headerText;
buttonText = payload.btnText;
returnButtonText = payload.btnReturnText;
awaitingText = payload.btnAwaitingInteractionText;
}
const callbackUri = resolveMobileCallbackUri({
deeplinkScheme: params.get('deeplinkScheme'),
payload,
hostname,
legacyMobile: params.get('client') === 'mobile',
});
if (!callbackUri) throw new Error('No supported mobile return target was provided.');
return {
callbackUri,
webauthnJson,
headerText: safeDecodeURIComponent(headerText),
buttonText: safeDecodeURIComponent(buttonText),
returnButtonText: safeDecodeURIComponent(returnButtonText),
awaitingText: safeDecodeURIComponent(awaitingText),
};
}
export function normalizePublicKeyOptions(webauthnJson) {
const source = typeof webauthnJson === 'string' ? JSON.parse(webauthnJson) : webauthnJson;
if (!source || typeof source !== 'object' || !source.challenge) {
throw new Error('The WebAuthn challenge is invalid.');
}
const publicKey = { ...source, challenge: bytesFromBase64Url(source.challenge) };
if (Array.isArray(source.allowCredentials)) {
publicKey.allowCredentials = source.allowCredentials.map((credential) => ({
...credential,
id: bytesFromBase64Url(credential?.id),
}));
}
return publicKey;
}
export function buildCredentialData(assertedCredential) {
const response = assertedCredential?.response;
if (!assertedCredential || !response?.authenticatorData || !response?.clientDataJSON || !response?.signature) {
throw new Error('The authenticator returned an incomplete response.');
}
const extensions = typeof assertedCredential.getClientExtensionResults === 'function'
? assertedCredential.getClientExtensionResults()
: {};
const clientData = base64UrlFromBuffer(response.clientDataJSON);
return JSON.stringify({
id: assertedCredential.id,
rawId: base64UrlFromBuffer(assertedCredential.rawId),
type: assertedCredential.type,
extensions,
response: {
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
clientDataJson: clientData,
signature: base64UrlFromBuffer(response.signature),
},
});
}
export function buildCallbackUrl(callbackUri, key, value) {
const separator = String(callbackUri).includes('?') ? '&' : '?';
return `${callbackUri}${separator}${encodeURIComponent(key)}=${encodeURIComponent(String(value || ''))}`;
}
function translations(locale) {
const normalized = String(locale || 'en').toLowerCase();
if (normalized.startsWith('zh-tw') || normalized.startsWith('zh-hk')) {
return {
title: '兩步驟驗證', copy: '使用通行密鑰或安全金鑰完成登入。', button: '使用通行密鑰驗證',
awaiting: '請依照系統提示完成驗證…', returning: '正在返回 Bitwarden…', returnButton: '返回 Bitwarden',
unsupported: '此瀏覽器不支援通行密鑰。', cancelled: '驗證已取消,請重試。',
};
}
if (normalized.startsWith('zh')) {
return {
title: '两步验证', copy: '使用通行密钥或安全密钥完成登录。', button: '使用通行密钥验证',
awaiting: '请按照系统提示完成验证…', returning: '正在返回 Bitwarden…', returnButton: '返回 Bitwarden',
unsupported: '此浏览器不支持通行密钥。', cancelled: '验证已取消,请重试。',
};
}
return {
title: 'Two-step verification', copy: 'Use your passkey or security key to finish signing in.',
button: 'Authenticate with passkey', awaiting: 'Follow the system prompt to continue…',
returning: 'Returning to Bitwarden…', returnButton: 'Return to Bitwarden',
unsupported: 'This browser does not support passkeys.', cancelled: 'Verification was cancelled. Please try again.',
};
}
function browserErrorMessage(error, text) {
if (error?.name === 'NotAllowedError' || error?.name === 'AbortError') return text.cancelled;
return error?.message || String(error || 'WebAuthn failed.');
}
function initializePage() {
const button = document.getElementById('webauthn-button');
const header = document.getElementById('webauthn-header');
const copy = document.getElementById('webauthn-copy');
const status = document.getElementById('webauthn-status');
if (!button || !header || !copy || !status) return;
const text = translations(navigator.languages?.[0] || navigator.language);
document.documentElement.lang = navigator.languages?.[0] || navigator.language || 'en';
copy.textContent = text.copy;
let request;
let publicKey;
let completed = false;
let returnUri = '';
function setButton(label, state, handler) {
button.textContent = label;
button.dataset.state = state;
button.disabled = state === 'unavailable';
button.setAttribute('aria-disabled', handler ? 'false' : 'true');
button.setAttribute('aria-busy', state === 'waiting' ? 'true' : 'false');
button.onclick = handler;
}
function setStatus(kind, message) {
status.hidden = !message;
status.dataset.kind = kind;
status.textContent = message || '';
status.className = message ? `msg show ${kind}` : 'msg';
}
function navigate(uri) {
returnUri = uri;
window.location.replace(uri);
setButton(request?.returnButtonText || text.returnButton, 'return', () => window.location.replace(returnUri));
}
function handoffError(message) {
setStatus('error', message);
if (request?.callbackUri) navigate(buildCallbackUrl(request.callbackUri, 'error', message));
}
async function executeWebAuthn() {
if (completed || button.dataset.state === 'waiting') return;
setStatus('info', request.awaitingText || text.awaiting);
setButton(request.awaitingText || text.awaiting, 'waiting', null);
try {
const credential = await navigator.credentials.get({ publicKey });
if (!credential) throw new Error('No passkey was selected.');
const data = buildCredentialData(credential);
completed = true;
setStatus('success', text.returning);
navigate(buildCallbackUrl(request.callbackUri, 'data', data));
} catch (error) {
setButton(request.buttonText || text.button, 'ready', executeWebAuthn);
handoffError(browserErrorMessage(error, text));
}
}
try {
request = parseConnectorRequest(window.location.search, window.location.hostname);
publicKey = normalizePublicKeyOptions(request.webauthnJson);
header.textContent = request.headerText || text.title;
setButton(request.buttonText || text.button, 'ready', executeWebAuthn);
} catch (error) {
header.textContent = text.title;
setStatus('error', browserErrorMessage(error, text));
setButton(text.button, 'unavailable', null);
}
if (!navigator.credentials || typeof navigator.credentials.get !== 'function' || !window.PublicKeyCredential) {
handoffError(text.unsupported);
if (!request?.callbackUri) setButton(text.button, 'unavailable', null);
}
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initializePage, { once: true });
else initializePage();
}
+62 -2
View File
@@ -68,6 +68,7 @@ import { t } from '@/lib/i18n';
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify'; import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress'; import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
import { clearOfflineUnlockRecord } from '@/lib/offline-auth'; import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
import { clearPasswordSecurityCache } from '@/lib/password-security-cache';
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt'; import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker'; import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
import { import {
@@ -111,6 +112,8 @@ const APP_ROUTE_PATHS = [
'/', '/',
'/vault', '/vault',
'/vault/totp', '/vault/totp',
'/security/password-health',
'/generator',
'/sends', '/sends',
'/admin', '/admin',
'/logs', '/logs',
@@ -250,6 +253,8 @@ export default function App() {
const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes()); const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes());
const [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction()); const [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction());
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email); const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email);
const [lockedSessionRefreshError, setLockedSessionRefreshError] = useState('');
const [lockedSessionRetryKey, setLockedSessionRetryKey] = useState(0);
const [confirm, setConfirm] = useState<AppConfirmState | null>(null); const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
const [mobileLayout, setMobileLayout] = useState(false); const [mobileLayout, setMobileLayout] = useState(false);
@@ -266,6 +271,7 @@ export default function App() {
const [vaultDecryptError, setVaultDecryptError] = useState(''); const [vaultDecryptError, setVaultDecryptError] = useState('');
const [sendsDecryptDone, setSendsDecryptDone] = useState(false); const [sendsDecryptDone, setSendsDecryptDone] = useState(false);
const sessionRef = useRef<SessionState | null>(initialBootstrap.session); const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
const lockedSessionRetryAttemptRef = useRef(0);
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {}); const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {}); const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
@@ -385,6 +391,10 @@ export default function App() {
} }
}, [phase, profile, session]); }, [phase, profile, session]);
useEffect(() => {
if (phase !== 'app') clearPasswordSecurityCache();
}, [phase]);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes)); window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
@@ -496,13 +506,15 @@ export default function App() {
if (phase !== 'locked' || !session) return; if (phase !== 'locked' || !session) return;
if (IS_DEMO_MODE) return; if (IS_DEMO_MODE) return;
let cancelled = false; let cancelled = false;
let retryTimerId: number | null = null;
void (async () => { void (async () => {
const result = await hydrateLockedSession(session, profile); const result = await hydrateLockedSession(session, profile);
if (cancelled) return; if (cancelled) return;
if (!result.session) { if (result.kind === 'expired') {
setSession(null); setSession(null);
setProfile(null); setProfile(null);
setUnlockPreparing(false); setUnlockPreparing(false);
setLockedSessionRefreshError('');
setPhase('login'); setPhase('login');
if (location !== '/login') navigate('/login'); if (location !== '/login') navigate('/login');
return; return;
@@ -511,11 +523,43 @@ export default function App() {
if (result.profile) { if (result.profile) {
setProfile(stripProfileSecrets(result.profile)); setProfile(stripProfileSecrets(result.profile));
} }
if (result.kind === 'transient') {
setUnlockPreparing(false);
setLockedSessionRefreshError(result.message || t('txt_session_refresh_temporarily_unavailable'));
const retrySchedule = [2_000, 5_000, 15_000, 30_000, 60_000];
const scheduledDelay = retrySchedule[Math.min(lockedSessionRetryAttemptRef.current, retrySchedule.length - 1)];
lockedSessionRetryAttemptRef.current += 1;
const retryAfterMs = Math.min(60_000, Math.max(scheduledDelay, result.retryAfterMs || 0));
retryTimerId = window.setTimeout(() => {
setLockedSessionRetryKey((value) => value + 1);
}, retryAfterMs);
return;
}
lockedSessionRetryAttemptRef.current = 0;
setLockedSessionRefreshError('');
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
if (retryTimerId !== null) window.clearTimeout(retryTimerId);
}; };
}, [phase, session?.email, location, navigate]); }, [phase, session?.email, location, navigate, lockedSessionRetryKey]);
useEffect(() => {
if (!lockedSessionRefreshError || phase !== 'locked') return;
const retryNow = () => {
lockedSessionRetryAttemptRef.current = 0;
setLockedSessionRetryKey((value) => value + 1);
};
const handleVisibility = () => {
if (document.visibilityState === 'visible') retryNow();
};
window.addEventListener('online', retryNow);
document.addEventListener('visibilitychange', handleVisibility);
return () => {
window.removeEventListener('online', retryNow);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [lockedSessionRefreshError, phase]);
async function finalizeLogin(login: CompletedLogin) { async function finalizeLogin(login: CompletedLogin) {
loginScopedBackupRepairAuthRef.current = loginScopedBackupRepairAuthRef.current =
@@ -529,6 +573,7 @@ export default function App() {
setSession(login.session); setSession(login.session);
setProfile(login.profile); setProfile(login.profile);
setUnlockPreparing(false); setUnlockPreparing(false);
setLockedSessionRefreshError('');
setPendingTotp(null); setPendingTotp(null);
setPendingTotpMode(null); setPendingTotpMode(null);
setPendingPasskeyPassword(null); setPendingPasskeyPassword(null);
@@ -871,11 +916,13 @@ export default function App() {
setDecryptedFolders([]); setDecryptedFolders([]);
setDecryptedCiphers([]); setDecryptedCiphers([]);
setDecryptedSends([]); setDecryptedSends([]);
clearPasswordSecurityCache();
setUnlockPassword(''); setUnlockPassword('');
setPendingTotp(null); setPendingTotp(null);
setPendingTotpMode(null); setPendingTotpMode(null);
setTotpCode(''); setTotpCode('');
setUnlockPreparing(false); setUnlockPreparing(false);
setLockedSessionRefreshError('');
setPhase('locked'); setPhase('locked');
navigate('/lock'); navigate('/lock');
} }
@@ -892,6 +939,7 @@ export default function App() {
setSession(null); setSession(null);
clearProfileSnapshot(); clearProfileSnapshot();
clearOfflineUnlockRecord(); clearOfflineUnlockRecord();
clearPasswordSecurityCache();
setProfile(null); setProfile(null);
setUnlockPreparing(false); setUnlockPreparing(false);
setPendingTotp(null); setPendingTotp(null);
@@ -1847,6 +1895,8 @@ export default function App() {
}); });
const adminActions = useAdminActions({ const adminActions = useAdminActions({
authedFetch, authedFetch,
email: String(profile?.email || session?.email || ''),
defaultKdfIterations,
onNotify: pushToast, onNotify: pushToast,
onSetConfirm: setConfirm, onSetConfirm: setConfirm,
refetchUsers: usersQuery.refetch, refetchUsers: usersQuery.refetch,
@@ -1901,13 +1951,17 @@ export default function App() {
const mobilePrimaryRoute = const mobilePrimaryRoute =
location === '/sends' location === '/sends'
? '/sends' ? '/sends'
: location === '/generator'
? '/generator'
: location === '/vault/totp' : location === '/vault/totp'
? '/vault/totp' ? '/vault/totp'
: location === '/vault' : location === '/vault'
? '/vault' ? '/vault'
: '/settings'; : '/settings';
const currentPageTitle = (() => { const currentPageTitle = (() => {
if (location === '/security/password-health') return t('txt_password_security');
if (location === '/vault/totp') return t('txt_verification_code'); if (location === '/vault/totp') return t('txt_verification_code');
if (location === '/generator') return t('txt_password_generator');
if (location === '/sends') return t('nav_sends'); if (location === '/sends') return t('nav_sends');
if (location === '/admin') return t('nav_admin_panel'); if (location === '/admin') return t('nav_admin_panel');
if (location === '/logs') return t('nav_log_center'); if (location === '/logs') return t('nav_log_center');
@@ -2206,6 +2260,7 @@ export default function App() {
unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined} unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined}
unlockReady={!!session?.email} unlockReady={!!session?.email}
unlockPreparing={unlockPreparing} unlockPreparing={unlockPreparing}
sessionRefreshError={lockedSessionRefreshError}
loginValues={loginValues} loginValues={loginValues}
pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null} pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null}
passkeyPassword={passkeyPassword} passkeyPassword={passkeyPassword}
@@ -2246,6 +2301,11 @@ export default function App() {
onLogout={logoutNow} onLogout={logoutNow}
onTogglePasswordHint={() => void handleTogglePasswordHint()} onTogglePasswordHint={() => void handleTogglePasswordHint()}
onShowLockedPasswordHint={handleShowLockedPasswordHint} onShowLockedPasswordHint={handleShowLockedPasswordHint}
onRetrySessionRefresh={() => {
lockedSessionRetryAttemptRef.current = 0;
setLockedSessionRefreshError('');
setLockedSessionRetryKey((value) => value + 1);
}}
/> />
<AppGlobalOverlays <AppGlobalOverlays
toasts={toasts} toasts={toasts}
+61 -153
View File
@@ -1,6 +1,6 @@
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact'; import { ArrowUpDown, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, Sparkles, Users } from 'lucide-preact';
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { Link } from 'wouter'; import { Link } from 'wouter';
import AppMainRoutes from '@/components/AppMainRoutes'; import AppMainRoutes from '@/components/AppMainRoutes';
import NetworkStatusBadge from '@/components/NetworkStatusBadge'; import NetworkStatusBadge from '@/components/NetworkStatusBadge';
@@ -28,19 +28,32 @@ interface AppAuthenticatedShellProps {
mainRoutesProps: AppMainRoutesProps; mainRoutesProps: AppMainRoutesProps;
} }
type NavLayoutMode = 'flat' | 'grouped-expanded' | 'grouped-smart'; const NAV_GROUPS_STORAGE_KEY = 'nodewarden.navGroups';
const NAV_LAYOUT_STORAGE_KEY = 'nodewarden.navLayoutMode'; const DEFAULT_EXPANDED_GROUPS = {
tools: true,
settings: true,
management: true,
};
function readNavLayoutMode(): NavLayoutMode { type NavGroup = keyof typeof DEFAULT_EXPANDED_GROUPS;
if (typeof window === 'undefined') return 'flat'; type ExpandedGroups = Record<NavGroup, boolean>;
function readExpandedGroups(): ExpandedGroups {
if (typeof window === 'undefined') return DEFAULT_EXPANDED_GROUPS;
try { try {
const saved = window.localStorage.getItem(NAV_LAYOUT_STORAGE_KEY); const saved = window.localStorage.getItem(NAV_GROUPS_STORAGE_KEY);
if (saved === 'flat' || saved === 'grouped-expanded' || saved === 'grouped-smart') return saved; if (!saved) return DEFAULT_EXPANDED_GROUPS;
const parsed = JSON.parse(saved) as Partial<ExpandedGroups>;
return {
tools: typeof parsed.tools === 'boolean' ? parsed.tools : DEFAULT_EXPANDED_GROUPS.tools,
settings: typeof parsed.settings === 'boolean' ? parsed.settings : DEFAULT_EXPANDED_GROUPS.settings,
management: typeof parsed.management === 'boolean' ? parsed.management : DEFAULT_EXPANDED_GROUPS.management,
};
} catch { } catch {
// Ignore local preference read failures. // Ignore local preference read failures.
} }
return 'flat'; return DEFAULT_EXPANDED_GROUPS;
} }
function isAdminProfile(profile: Profile | null): boolean { function isAdminProfile(profile: Profile | null): boolean {
@@ -55,58 +68,19 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
const isDomainRulesRoute = props.location === '/settings/domain-rules'; const isDomainRulesRoute = props.location === '/settings/domain-rules';
const isLogRoute = props.location === '/logs'; const isLogRoute = props.location === '/logs';
const isAdmin = isAdminProfile(props.profile); const isAdmin = isAdminProfile(props.profile);
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE; const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive; const [expandedGroups, setExpandedGroups] = useState<ExpandedGroups>(readExpandedGroups);
const flatSettingsActive = settingsActive && !deviceManagementActive;
const dataActive = props.location === '/backup' || props.isImportRoute;
const managementActive = props.location === '/admin' || props.location === '/logs';
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
const [expandedGroups, setExpandedGroups] = useState({
vault: true,
settings: false,
data: false,
management: false,
});
useEffect(() => { function toggleGroup(group: NavGroup): void {
const onPointerDown = (event: Event) => { setExpandedGroups((current) => {
if (!navLayoutPickerOpen) return; const next = { ...current, [group]: !current[group] };
const target = event.target as Node | null;
if (navLayoutPickerRef.current && target && !navLayoutPickerRef.current.contains(target)) {
setNavLayoutPickerOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setNavLayoutPickerOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [navLayoutPickerOpen]);
function setNavMode(mode: NavLayoutMode): void {
setNavLayoutMode(mode);
setNavLayoutPickerOpen(false);
try { try {
window.localStorage.setItem(NAV_LAYOUT_STORAGE_KEY, mode); window.localStorage.setItem(NAV_GROUPS_STORAGE_KEY, JSON.stringify(next));
} catch { } catch {
// Ignore local preference write failures. // Ignore local preference write failures.
} }
} return next;
});
function toggleGroup(group: keyof typeof expandedGroups): void {
setExpandedGroups((current) => ({ ...current, [group]: !current[group] }));
}
function groupOpen(group: keyof typeof expandedGroups, active: boolean): boolean {
if (navLayoutMode === 'grouped-expanded') return true;
return expandedGroups[group] || active;
} }
function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) { function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) {
@@ -127,18 +101,17 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
} }
function renderNavGroup( function renderNavGroup(
group: keyof typeof expandedGroups, group: NavGroup,
title: string, title: string,
icon: ComponentChildren, icon: ComponentChildren,
active: boolean,
children: ComponentChildren children: ComponentChildren
) { ) {
const open = groupOpen(group, active); const open = expandedGroups[group];
return ( return (
<div className={`side-nav-group ${open ? 'open' : ''}`}> <div className={`side-nav-group ${open ? 'open' : ''}`}>
<button <button
type="button" type="button"
className={`side-group-trigger ${active ? 'active' : ''}`} className="side-group-trigger"
aria-expanded={open} aria-expanded={open}
onClick={() => toggleGroup(group)} onClick={() => toggleGroup(group)}
> >
@@ -155,78 +128,40 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
); );
} }
const navLayoutOptions: Array<{ mode: NavLayoutMode; label: string }> = [
{
mode: 'flat',
label: t('txt_nav_layout_flat'),
},
{
mode: 'grouped-expanded',
label: t('txt_nav_layout_grouped_expanded'),
},
{
mode: 'grouped-smart',
label: t('txt_nav_layout_grouped_smart'),
},
];
const navLayoutLabel = navLayoutOptions.find((option) => option.mode === navLayoutMode)?.label || t('txt_nav_layout_flat');
const flatNav = (
<>
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderSideLink('/settings', flatSettingsActive, <SettingsIcon size={16} />, t('txt_settings'))}
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))}
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
</>
);
const groupedNav = ( const groupedNav = (
<> <>
{renderNavGroup( {renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
'vault',
t('nav_my_vault'),
<KeyRound size={16} />,
vaultActive,
<>
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
</>
)}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))} {renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderNavGroup( {renderNavGroup(
'settings', 'tools',
t('txt_settings'), t('nav_group_tools'),
<SettingsIcon size={16} />, <Sparkles size={16} />,
settingsActive,
<> <>
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))} {renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))} {renderSubLink('/generator', props.location === '/generator', t('nav_generator'))}
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))} {renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
</>
)}
{renderNavGroup(
'data',
t('nav_group_data_backup'),
<Cloud size={16} />,
dataActive,
<>
{isAdmin && renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
{renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))} {renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))}
</> </>
)} )}
{renderNavGroup( {renderNavGroup(
'management', 'settings',
t('nav_group_management'), t('txt_settings'),
<ShieldUser size={16} />, <SettingsIcon size={16} />,
managementActive,
<> <>
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))} {renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))} {renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
</>
)}
{isAdmin &&
renderNavGroup(
'management',
t('nav_group_system_management'),
<ShieldUser size={16} />,
<>
{renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
{renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
{renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
</> </>
)} )}
</> </>
@@ -277,38 +212,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<div className="app-main"> <div className="app-main">
<aside className="app-side"> <aside className="app-side">
<div className="side-nav-main"> <div className="side-nav-main">
{navLayoutMode === 'flat' ? flatNav : groupedNav} {groupedNav}
</div>
<div className="nav-layout-control" ref={navLayoutPickerRef}>
{navLayoutPickerOpen && (
<div className="nav-layout-menu" role="menu">
{navLayoutOptions.map((option) => (
<button
key={option.mode}
type="button"
className={`nav-layout-option ${navLayoutMode === option.mode ? 'active' : ''}`}
onClick={() => setNavMode(option.mode)}
role="menuitemradio"
aria-checked={navLayoutMode === option.mode}
>
<span className="nav-layout-option-text">
<strong>{option.label}</strong>
</span>
{navLayoutMode === option.mode && <Check size={15} className="nav-layout-check" />}
</button>
))}
</div>
)}
<button
type="button"
className={`nav-layout-trigger ${navLayoutPickerOpen ? 'active' : ''}`}
aria-haspopup="menu"
aria-expanded={navLayoutPickerOpen}
onClick={() => setNavLayoutPickerOpen((open) => !open)}
title={t('txt_nav_layout')}
>
<SlidersHorizontal size={15} />
</button>
</div> </div>
</aside> </aside>
<main className="content"> <main className="content">
@@ -327,6 +231,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<Clock3 size={18} /> <Clock3 size={18} />
<span>{t('txt_verification_code')}</span> <span>{t('txt_verification_code')}</span>
</Link> </Link>
<Link href="/generator" className={`mobile-tab ${props.mobilePrimaryRoute === '/generator' ? 'active' : ''}`}>
<Sparkles size={18} />
<span>{t('nav_generator')}</span>
</Link>
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}> <Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
<SendIcon size={18} /> <SendIcon size={18} />
<span>{t('nav_sends')}</span> <span>{t('nav_sends')}</span>
+32 -3
View File
@@ -12,7 +12,9 @@ export interface AppConfirmState {
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
hideCancel?: boolean; hideCancel?: boolean;
onConfirm: () => void; /** When true, dialog shows a master-password field and passes it to onConfirm. */
requireMasterPassword?: boolean;
onConfirm: (masterPassword?: string) => void;
onCancel?: () => void; onCancel?: () => void;
} }
@@ -63,6 +65,7 @@ function twoFactorProviderLabel(providerType: number): string {
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const [methodChooserOpen, setMethodChooserOpen] = useState(false); const [methodChooserOpen, setMethodChooserOpen] = useState(false);
const [confirmPassword, setConfirmPassword] = useState('');
const availableProviders = useMemo( const availableProviders = useMemo(
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders), () => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
[props.pendingTotpAvailableProviders] [props.pendingTotpAvailableProviders]
@@ -70,11 +73,16 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType); const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY; const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN; const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
const requireMasterPassword = !!props.confirm?.requireMasterPassword;
useEffect(() => { useEffect(() => {
setMethodChooserOpen(false); setMethodChooserOpen(false);
}, [props.pendingTotpOpen, props.pendingTotpProviderType]); }, [props.pendingTotpOpen, props.pendingTotpProviderType]);
useEffect(() => {
setConfirmPassword('');
}, [props.confirm?.title, props.confirm?.message, requireMasterPassword]);
return ( return (
<> <>
<ConfirmDialog <ConfirmDialog
@@ -86,9 +94,30 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
confirmText={props.confirm?.confirmText} confirmText={props.confirm?.confirmText}
cancelText={props.confirm?.cancelText} cancelText={props.confirm?.cancelText}
hideCancel={props.confirm?.hideCancel} hideCancel={props.confirm?.hideCancel}
onConfirm={() => props.confirm?.onConfirm()} confirmDisabled={requireMasterPassword && !confirmPassword.trim()}
onCancel={props.confirm?.onCancel || props.onCancelConfirm} onConfirm={() => {
if (requireMasterPassword && !confirmPassword.trim()) return;
props.confirm?.onConfirm(requireMasterPassword ? confirmPassword : undefined);
setConfirmPassword('');
}}
onCancel={() => {
setConfirmPassword('');
(props.confirm?.onCancel || props.onCancelConfirm)();
}}
>
{requireMasterPassword && (
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={confirmPassword}
onInput={(e) => setConfirmPassword((e.currentTarget as HTMLInputElement).value)}
/> />
</label>
)}
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={props.pendingTotpOpen} open={props.pendingTotpOpen}
+39 -15
View File
@@ -1,7 +1,7 @@
import { lazy, Suspense } from 'preact/compat'; import { lazy, Suspense } from 'preact/compat';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { Link, Route, Switch } from 'wouter'; import { Link, Route, Switch } from 'wouter';
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldCheck, ShieldUser } from 'lucide-preact';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import LoadingState from '@/components/LoadingState'; import LoadingState from '@/components/LoadingState';
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup'; import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
@@ -13,6 +13,8 @@ import type { ExportRequest } from '@/lib/export-formats';
const VaultPage = lazy(() => import('@/components/VaultPage')); const VaultPage = lazy(() => import('@/components/VaultPage'));
const SendsPage = lazy(() => import('@/components/SendsPage')); const SendsPage = lazy(() => import('@/components/SendsPage'));
const PasswordGeneratorPage = lazy(() => import('@/components/PasswordGeneratorPage'));
const PasswordSecurityPage = lazy(() => import('@/components/PasswordSecurityPage'));
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage')); const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
const SettingsPage = lazy(() => import('@/components/SettingsPage')); const SettingsPage = lazy(() => import('@/components/SettingsPage'));
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage')); const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
@@ -207,6 +209,26 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
return ( return (
<Switch> <Switch>
<Route path="/security/password-health">
<div className="stack">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
<span className="btn-icon" aria-hidden="true">{"<"}</span>
{t('txt_back')}
</button>
</div>
)}
<Suspense fallback={<RouteContentFallback />}>
<PasswordSecurityPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} />
</Suspense>
</div>
</Route>
<Route path="/generator">
<Suspense fallback={<RouteContentFallback />}>
<PasswordGeneratorPage />
</Suspense>
</Route>
<Route path="/sends"> <Route path="/sends">
<Suspense fallback={<RouteContentFallback />}> <Suspense fallback={<RouteContentFallback />}>
<SendsPage <SendsPage
@@ -321,6 +343,19 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<Route path="/settings"> <Route path="/settings">
{props.profile ? ( {props.profile ? (
<section className="card mobile-settings-card settings-home-card"> <section className="card mobile-settings-card settings-home-card">
<div className="settings-home-section">
<h3>{t('nav_group_tools')}</h3>
<div className="mobile-settings-links">
<Link href="/security/password-health" className="mobile-settings-link">
<ShieldCheck size={18} />
<span>{t('nav_password_security')}</span>
</Link>
<Link href={props.importRoute} className="mobile-settings-link">
<ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span>
</Link>
</div>
</div>
<div className="settings-home-section"> <div className="settings-home-section">
<h3>{t('txt_settings')}</h3> <h3>{t('txt_settings')}</h3>
<div className="mobile-settings-links"> <div className="mobile-settings-links">
@@ -338,25 +373,14 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
</Link> </Link>
</div> </div>
</div> </div>
<div className="settings-home-section">
<h3>{t('nav_group_data_backup')}</h3>
<div className="mobile-settings-links">
<Link href={props.importRoute} className="mobile-settings-link">
<ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span>
</Link>
{isAdmin && ( {isAdmin && (
<div className="settings-home-section">
<h3>{t('nav_group_system_management')}</h3>
<div className="mobile-settings-links">
<Link href="/backup" className="mobile-settings-link"> <Link href="/backup" className="mobile-settings-link">
<Cloud size={18} /> <Cloud size={18} />
<span>{t('nav_backup_strategy')}</span> <span>{t('nav_backup_strategy')}</span>
</Link> </Link>
)}
</div>
</div>
{isAdmin && (
<div className="settings-home-section">
<h3>{t('nav_group_management')}</h3>
<div className="mobile-settings-links">
<Link href="/admin" className="mobile-settings-link"> <Link href="/admin" className="mobile-settings-link">
<ShieldUser size={18} /> <ShieldUser size={18} />
<span>{t('nav_admin_panel')}</span> <span>{t('nav_admin_panel')}</span>
+15
View File
@@ -27,6 +27,7 @@ interface AuthViewsProps {
pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null; pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null;
unlockReady: boolean; unlockReady: boolean;
unlockPreparing: boolean; unlockPreparing: boolean;
sessionRefreshError?: string;
loginValues: LoginValues; loginValues: LoginValues;
pendingPasskeyPasswordEmail?: string | null; pendingPasskeyPasswordEmail?: string | null;
passkeyPassword: string; passkeyPassword: string;
@@ -50,6 +51,7 @@ interface AuthViewsProps {
onLogout: () => void; onLogout: () => void;
onTogglePasswordHint: () => void; onTogglePasswordHint: () => void;
onShowLockedPasswordHint: () => void; onShowLockedPasswordHint: () => void;
onRetrySessionRefresh: () => void;
} }
function PasswordField(props: { function PasswordField(props: {
@@ -155,6 +157,19 @@ export default function AuthViews(props: AuthViewsProps) {
{props.unlockPreparing ? ( {props.unlockPreparing ? (
<p className="muted standalone-muted">{t('txt_loading')}</p> <p className="muted standalone-muted">{t('txt_loading')}</p>
) : null} ) : null}
{props.sessionRefreshError ? (
<div className="offline-mode-notice" role="alert" aria-live="polite">
<AlertTriangle size={18} />
<div>
<strong>{props.sessionRefreshError}</strong>
<div>
<button type="button" className="auth-link-btn" onClick={props.onRetrySessionRefresh}>
{t('txt_refresh')}
</button>
</div>
</div>
</div>
) : null}
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}> <button type="submit" className="btn btn-primary full" disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}>
<Unlock size={16} className="btn-icon" /> <Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')} {unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
+20 -1
View File
@@ -13,6 +13,7 @@ import {
} from '@/lib/api/backup'; } from '@/lib/api/backup';
import { import {
REMOTE_BROWSER_ITEMS_PER_PAGE, REMOTE_BROWSER_ITEMS_PER_PAGE,
REMOTE_BROWSER_REFRESH_TTL_MS,
compareRemoteItems, compareRemoteItems,
createDraftBackupSettings, createDraftBackupSettings,
createDraftDestinationRecord, createDraftDestinationRecord,
@@ -217,6 +218,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [remoteBrowserCache, setRemoteBrowserCache] = useState<Record<string, RemoteBackupBrowserResponse>>(persistedRemoteState.cache); const [remoteBrowserCache, setRemoteBrowserCache] = useState<Record<string, RemoteBackupBrowserResponse>>(persistedRemoteState.cache);
const [remoteBrowserPathByDestination, setRemoteBrowserPathByDestination] = useState<Record<string, string>>(persistedRemoteState.pathByDestination); const [remoteBrowserPathByDestination, setRemoteBrowserPathByDestination] = useState<Record<string, string>>(persistedRemoteState.pathByDestination);
const [remoteBrowserPageByKey, setRemoteBrowserPageByKey] = useState<Record<string, number>>(persistedRemoteState.pageByKey); const [remoteBrowserPageByKey, setRemoteBrowserPageByKey] = useState<Record<string, number>>(persistedRemoteState.pageByKey);
const [remoteBrowserRefreshedAt, setRemoteBrowserRefreshedAt] = useState<Record<string, number>>(persistedRemoteState.refreshedAt || {});
const [showAddChooser, setShowAddChooser] = useState(false); const [showAddChooser, setShowAddChooser] = useState(false);
const visibleDestinations = getVisibleDestinations(settings); const visibleDestinations = getVisibleDestinations(settings);
@@ -308,8 +310,22 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
pathByDestination: remoteBrowserPathByDestination, pathByDestination: remoteBrowserPathByDestination,
pageByKey: remoteBrowserPageByKey, pageByKey: remoteBrowserPageByKey,
selectedDestinationId, selectedDestinationId,
refreshedAt: remoteBrowserRefreshedAt,
}); });
}, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]); }, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, remoteBrowserRefreshedAt, selectedDestinationId]);
useEffect(() => {
if (!savedSelectedDestination) return;
const destinationId = savedSelectedDestination.id;
const path = remoteBrowserPathByDestination[destinationId] || '';
const cacheKey = getRemoteBrowserCacheKey(destinationId, path);
const lastRefreshed = remoteBrowserRefreshedAt[cacheKey] || 0;
const isStale = Date.now() - lastRefreshed > REMOTE_BROWSER_REFRESH_TTL_MS;
if (isStale) {
void loadRemoteBrowser(destinationId, path, { force: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [savedSelectedDestination?.id]);
useEffect(() => { useEffect(() => {
if (!restoreProgress) { if (!restoreProgress) {
@@ -398,6 +414,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}; };
setRemoteBrowserCache((current) => ({ ...current, [cacheKey]: nextBrowser })); setRemoteBrowserCache((current) => ({ ...current, [cacheKey]: nextBrowser }));
setRemoteBrowserPageByKey((current) => ({ ...current, [cacheKey]: 1 })); setRemoteBrowserPageByKey((current) => ({ ...current, [cacheKey]: 1 }));
setRemoteBrowserRefreshedAt((current) => ({ ...current, [cacheKey]: Date.now() }));
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_load_failed'); const message = error instanceof Error ? error.message : t('txt_backup_remote_load_failed');
setLocalError(message); setLocalError(message);
@@ -543,6 +560,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
).cache); ).cache);
setRemoteBrowserPathByDestination((current) => Object.fromEntries(Object.entries(current).filter(([key]) => key !== destinationIdToDelete))); setRemoteBrowserPathByDestination((current) => Object.fromEntries(Object.entries(current).filter(([key]) => key !== destinationIdToDelete)));
setRemoteBrowserPageByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToDelete}:`)))); setRemoteBrowserPageByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToDelete}:`))));
setRemoteBrowserRefreshedAt((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToDelete}:`))));
setSelectedDestinationId(nextSelected); setSelectedDestinationId(nextSelected);
setConfirmDeleteDestinationOpen(false); setConfirmDeleteDestinationOpen(false);
props.onNotify('success', t('txt_backup_destination_deleted')); props.onNotify('success', t('txt_backup_destination_deleted'));
@@ -670,6 +688,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setRemoteBrowserCache((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToInvalidate}:`)))); setRemoteBrowserCache((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToInvalidate}:`))));
setRemoteBrowserPathByDestination((current) => Object.fromEntries(Object.entries(current).filter(([key]) => key !== destinationIdToInvalidate))); setRemoteBrowserPathByDestination((current) => Object.fromEntries(Object.entries(current).filter(([key]) => key !== destinationIdToInvalidate)));
setRemoteBrowserPageByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToInvalidate}:`)))); setRemoteBrowserPageByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToInvalidate}:`))));
setRemoteBrowserRefreshedAt((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToInvalidate}:`))));
} }
setSelectedDestinationId(nextSelected); setSelectedDestinationId(nextSelected);
props.onNotify('success', t('txt_backup_settings_saved')); props.onNotify('success', t('txt_backup_settings_saved'));
@@ -0,0 +1,318 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Check, Copy, Download, LoaderCircle, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import { t } from '@/lib/i18n';
import {
clampInteger,
defaultGeneratorSettings,
estimateStrength,
generateValue,
normalizeGeneratorSettings,
type EmailMode,
type EmailOptions,
type GeneratorMode,
type GeneratorSettings,
type PassphraseOptions,
type PasswordOptions,
type PinOptions,
type SshKeyOptions,
type UsernameOptions,
} from '@/lib/password-generator';
import { generateSshKey, type GeneratedSshKey } from '@/lib/ssh-key-generator';
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v2';
function readSettings(): GeneratorSettings {
try {
const current = localStorage.getItem(SETTINGS_KEY);
if (current) return normalizeGeneratorSettings(JSON.parse(current));
// Preserve compatible options for users upgrading from the original generator.
const legacy = JSON.parse(localStorage.getItem('nodewarden.passwordGenerator.settings.v1') || '{}');
return normalizeGeneratorSettings(legacy);
} catch {
return defaultGeneratorSettings;
}
}
export default function PasswordGeneratorPage() {
const initial = useMemo(readSettings, []);
const [settings, setSettings] = useState<GeneratorSettings>(initial);
const [seed, setSeed] = useState(0);
const [copied, setCopied] = useState(false);
const [sshKey, setSshKey] = useState<GeneratedSshKey | null>(null);
const [sshKeyError, setSshKeyError] = useState('');
const [sshKeyLoading, setSshKeyLoading] = useState(false);
const generated = useMemo(() => settings.mode === 'sshKey' ? sshKey?.fingerprint || '' : generateValue(settings), [settings, seed, sshKey]);
const strength = useMemo(
() => estimateStrength(settings.mode, generated, settings.mode === 'passphrase' ? settings.passphrase.words : undefined),
[generated, settings.mode, settings.passphrase.words],
);
const strengthLabel = strength
? t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][strength - 1])
: '';
useEffect(() => {
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch {
// The generator remains fully usable when browser storage is unavailable.
}
}, [settings]);
useEffect(() => {
if (settings.mode !== 'sshKey') return;
let cancelled = false;
setSshKeyLoading(true);
setSshKeyError('');
void generateSshKey({ ...settings.sshKey, comment: '' })
.then((value) => { if (!cancelled) setSshKey(value); })
.catch(() => { if (!cancelled) { setSshKey(null); setSshKeyError(t('txt_generator_ssh_error')); } })
.finally(() => { if (!cancelled) setSshKeyLoading(false); });
return () => { cancelled = true; };
}, [settings.mode, settings.sshKey.type, settings.sshKey.rsaLength, seed]);
const regenerate = () => {
setCopied(false);
setSeed((value) => value + 1);
};
const copy = async () => {
const value = settings.mode === 'sshKey' && sshKey ? publicKeyWithComment(sshKey.publicKey, settings.sshKey.comment) : generated;
await copyTextToClipboard(value, { onSuccess: () => setCopied(true), onError: () => setCopied(false) });
window.setTimeout(() => setCopied(false), 1600);
};
const changeMode = (mode: GeneratorMode) => {
setSettings((current) => ({ ...current, mode }));
setCopied(false);
};
const changePasswordOption = <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => {
setSettings((current) => ({ ...current, password: { ...current.password, [key]: value } }));
setCopied(false);
};
const changeCharacterType = (key: 'uppercase' | 'lowercase' | 'numbers' | 'special', checked: boolean) => {
const enabled = ['uppercase', 'lowercase', 'numbers', 'special'].filter((item) => settings.password[item as 'uppercase']);
if (!checked && enabled.length === 1 && enabled[0] === key) return;
changePasswordOption(key, checked);
};
const changePassphraseOption = <K extends keyof PassphraseOptions>(key: K, value: PassphraseOptions[K]) => {
setSettings((current) => ({ ...current, passphrase: { ...current.passphrase, [key]: value } }));
setCopied(false);
};
const changePinOption = <K extends keyof PinOptions>(key: K, value: PinOptions[K]) => {
setSettings((current) => ({ ...current, pin: { ...current.pin, [key]: value } }));
setCopied(false);
};
const changeUsernameOption = <K extends keyof UsernameOptions>(key: K, value: UsernameOptions[K]) => {
setSettings((current) => ({ ...current, username: { ...current.username, [key]: value } }));
setCopied(false);
};
const changeEmailOption = <K extends keyof EmailOptions>(key: K, value: EmailOptions[K]) => {
setSettings((current) => ({ ...current, email: { ...current.email, [key]: value } }));
setCopied(false);
};
const changeSshKeyOption = <K extends keyof SshKeyOptions>(key: K, value: SshKeyOptions[K]) => {
setSettings((current) => ({ ...current, sshKey: { ...current.sshKey, [key]: value } }));
setCopied(false);
};
return (
<section className="generator-page" aria-label={t('txt_password_generator')}>
<div className="generator-layout">
<section className="generator-output-card" aria-live="polite">
<div className="settings-category-tabs generator-mode-tabs" role="tablist" aria-label={t('txt_generator_type')}>
{([
['password', 'txt_password'],
['passphrase', 'txt_passphrase'],
['pin', 'txt_generator_pin'],
['username', 'txt_generator_username'],
['email', 'txt_generator_email_alias'],
['sshKey', 'txt_generator_ssh_key'],
] as const).map(([mode, label]) => (
<button key={mode} type="button" role="tab" aria-selected={settings.mode === mode} className={`settings-category-tab ${settings.mode === mode ? 'active' : ''}`} onClick={() => changeMode(mode)}>{t(label)}</button>
))}
</div>
{settings.mode === 'sshKey' ? (
<SshKeyOutput value={sshKey} loading={sshKeyLoading} error={sshKeyError} comment={settings.sshKey.comment} />
) : <output className={`generator-value ${generated ? '' : 'empty'}`} aria-label={t('txt_generated_value')}>{generated || t('txt_generator_email_required_hint')}</output>}
{settings.mode !== 'sshKey' && <div className="generator-meta-row">
{strength > 0 ? (
<>
<div className="generator-strength" aria-label={`${t('txt_password_strength')}: ${strengthLabel}`}>
{[1, 2, 3, 4].map((level) => <span key={level} className={level <= strength ? `active level-${strength}` : ''} />)}
</div>
<span><ShieldCheck size={15} /> {strengthLabel}</span>
</>
) : <span />}
<span>{t('txt_generator_character_count', { count: generated.length })}</span>
</div>}
<div className="actions generator-actions">
<button type="button" className="btn btn-primary" disabled={sshKeyLoading} onClick={regenerate}>{sshKeyLoading ? <LoaderCircle size={16} className="btn-icon generator-spinner" /> : <RefreshCw size={16} className="btn-icon" />}{t('txt_regenerate')}</button>
<button type="button" className="btn btn-secondary" disabled={(settings.mode === 'sshKey' && !sshKey) || !generated} onClick={() => void copy()}><Copy size={16} className="btn-icon" />{copied ? t('txt_copied') : settings.mode === 'sshKey' ? t('txt_generator_copy_public_key') : t('txt_copy')}</button>
</div>
<p className="generator-security-note"><Check size={15} />{t(settings.mode === 'sshKey' ? 'txt_generator_ssh_security_note' : 'txt_generator_security_note')}</p>
</section>
<section className="generator-options-card" aria-labelledby="generator-options-title">
<h2 id="generator-options-title">{t('txt_options')}</h2>
{settings.mode === 'password' && (
<PasswordOptionFields options={settings.password} onChange={changePasswordOption} onCharacterTypeChange={changeCharacterType} />
)}
{settings.mode === 'passphrase' && (
<>
<GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={settings.passphrase.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
<label className="generator-select-field" htmlFor="generator-word-list"><span>{t('txt_generator_word_list')}</span><select id="generator-word-list" className="input" value={settings.passphrase.wordList} onChange={(event) => changePassphraseOption('wordList', event.currentTarget.value as 'eff' | 'custom')}><option value="eff">{t('txt_generator_eff_word_list')}</option><option value="custom">{t('txt_generator_custom_word_list')}</option></select></label>
{settings.passphrase.wordList === 'custom' && <label className="generator-text-field" htmlFor="generator-custom-words"><span>{t('txt_generator_custom_words')}</span><textarea id="generator-custom-words" className="input generator-word-list-input" rows={6} spellcheck={false} placeholder={t('txt_generator_custom_words_placeholder')} value={settings.passphrase.customWords} onInput={(event) => changePassphraseOption('customWords', event.currentTarget.value)} /></label>}
<label className="generator-number-field" htmlFor="generator-separator"><span>{t('txt_generator_separator')}</span><input id="generator-separator" className="input" type="text" maxLength={1} value={settings.passphrase.separator} onInput={(event) => changePassphraseOption('separator', event.currentTarget.value.slice(0, 1))} /></label>
<div className="generator-option-group">
<GeneratorToggle checked={settings.passphrase.capitalize} onChange={(checked) => changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} />
<GeneratorToggle checked={settings.passphrase.includeNumber} onChange={(checked) => changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
</div>
</>
)}
{settings.mode === 'pin' && (
<>
<GeneratorNumberStepper id="pin-length" label={t('txt_generator_length')} value={settings.pin.length} minimum={3} maximum={64} fallback={6} onChange={(value) => changePinOption('length', value)} />
<p className="generator-options-note">{t('txt_generator_pin_description')}</p>
</>
)}
{settings.mode === 'username' && (
<UsernameOptionFields options={settings.username} onChange={changeUsernameOption} />
)}
{settings.mode === 'email' && (
<EmailOptionFields options={settings.email} onChange={changeEmailOption} />
)}
{settings.mode === 'sshKey' && (
<SshKeyOptionFields options={settings.sshKey} onChange={changeSshKeyOption} />
)}
</section>
</div>
</section>
);
}
function PasswordOptionFields(props: { options: PasswordOptions; onChange: <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => void; onCharacterTypeChange: (key: 'uppercase' | 'lowercase' | 'numbers' | 'special', checked: boolean) => void }) {
const { options } = props;
return (
<>
<GeneratorNumberStepper id="length" label={t('txt_generator_length')} value={options.length} minimum={5} maximum={128} fallback={16} onChange={(value) => props.onChange('length', value)} />
<fieldset className="generator-option-group"><legend>{t('txt_generator_character_types')}</legend>
<GeneratorToggle checked={options.uppercase} onChange={(checked) => props.onCharacterTypeChange('uppercase', checked)} label={t('txt_generator_uppercase')} />
{options.uppercase && <GeneratorNumberStepper id="min-uppercase" compact label={t('txt_generator_minimum')} value={options.minUppercase} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minUppercase', value)} />}
<GeneratorToggle checked={options.lowercase} onChange={(checked) => props.onCharacterTypeChange('lowercase', checked)} label={t('txt_generator_lowercase')} />
{options.lowercase && <GeneratorNumberStepper id="min-lowercase" compact label={t('txt_generator_minimum')} value={options.minLowercase} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minLowercase', value)} />}
<GeneratorToggle checked={options.numbers} onChange={(checked) => props.onCharacterTypeChange('numbers', checked)} label={t('txt_generator_numbers')} />
{options.numbers && <GeneratorNumberStepper id="min-numbers" compact label={t('txt_generator_minimum')} value={options.minNumbers} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minNumbers', value)} />}
<GeneratorToggle checked={options.special} onChange={(checked) => props.onCharacterTypeChange('special', checked)} label={t('txt_generator_special')} />
{options.special && <GeneratorNumberStepper id="min-special" compact label={t('txt_generator_minimum')} value={options.minSpecial} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minSpecial', value)} />}
</fieldset>
<GeneratorToggle checked={options.avoidAmbiguous} onChange={(checked) => props.onChange('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} />
</>
);
}
function UsernameOptionFields(props: { options: UsernameOptions; onChange: <K extends keyof UsernameOptions>(key: K, value: UsernameOptions[K]) => void }) {
return (
<>
<GeneratorNumberStepper id="username-words" label={t('txt_generator_words')} value={props.options.words} minimum={1} maximum={10} fallback={2} onChange={(value) => props.onChange('words', value)} />
<div className="generator-option-group">
<GeneratorToggle checked={props.options.capitalize} onChange={(checked) => props.onChange('capitalize', checked)} label={t('txt_generator_capitalize')} />
<GeneratorToggle checked={props.options.includeNumber} onChange={(checked) => props.onChange('includeNumber', checked)} label={t('txt_generator_include_number')} />
</div>
<label className="generator-select-field" htmlFor="generator-username-word-list"><span>{t('txt_generator_word_list')}</span><select id="generator-username-word-list" className="input" value={props.options.wordList} onChange={(event) => props.onChange('wordList', event.currentTarget.value as 'eff' | 'custom')}><option value="eff">{t('txt_generator_eff_word_list')}</option><option value="custom">{t('txt_generator_custom_word_list')}</option></select></label>
{props.options.wordList === 'custom' && <label className="generator-text-field" htmlFor="generator-username-custom-words"><span>{t('txt_generator_custom_words')}</span><textarea id="generator-username-custom-words" className="input generator-word-list-input" rows={6} spellcheck={false} placeholder={t('txt_generator_custom_words_placeholder')} value={props.options.customWords} onInput={(event) => props.onChange('customWords', event.currentTarget.value)} /></label>}
<label className="generator-text-field" htmlFor="generator-username-custom-word"><span>{t('txt_generator_custom_word')}</span><input id="generator-username-custom-word" className="input" type="text" autocomplete="off" maxLength={128} value={props.options.customWord} onInput={(event) => props.onChange('customWord', event.currentTarget.value)} /></label>
<label className="generator-text-field" htmlFor="generator-username-delimiter"><span>{t('txt_generator_separator')}</span><input id="generator-username-delimiter" className="input" type="text" autocomplete="off" maxLength={8} value={props.options.delimiter} onInput={(event) => props.onChange('delimiter', event.currentTarget.value.slice(0, 8))} /></label>
<p className="generator-options-note">{t('txt_generator_long_word_username_description')}</p>
</>
);
}
function EmailOptionFields(props: { options: EmailOptions; onChange: <K extends keyof EmailOptions>(key: K, value: EmailOptions[K]) => void }) {
const types: Array<[EmailMode, string]> = [
['plusAddressed', 'txt_generator_plus_addressed_email'],
['catchAll', 'txt_generator_catch_all_email'],
['subdomain', 'txt_generator_subdomain_email'],
];
return (
<>
<label className="generator-select-field" htmlFor="generator-email-type"><span>{t('txt_generator_email_type')}</span><select id="generator-email-type" className="input" value={props.options.type} onChange={(event) => props.onChange('type', event.currentTarget.value as EmailMode)}>{types.map(([value, label]) => <option key={value} value={value}>{t(label)}</option>)}</select></label>
{props.options.type === 'catchAll'
? <label className="generator-text-field" htmlFor="generator-domain"><span>{t('txt_generator_domain')}</span><input id="generator-domain" className="input" type="text" autocomplete="off" value={props.options.domain} onInput={(event) => props.onChange('domain', event.currentTarget.value)} /></label>
: <label className="generator-text-field" htmlFor="generator-email"><span>{t('txt_generator_email')}</span><input id="generator-email" className="input" type="email" autocomplete="off" value={props.options.email} onInput={(event) => props.onChange('email', event.currentTarget.value)} /></label>}
<p className="generator-options-note">{t('txt_generator_email_description')}</p>
</>
);
}
function publicKeyWithComment(publicKey: string, comment: string): string {
const base = publicKey.trim().split(/\s+/).slice(0, 2).join(' ');
const safeComment = comment.replace(/[\r\n]+/g, ' ').trim();
return safeComment ? `${base} ${safeComment}` : base;
}
function downloadText(filename: string, value: string): void {
const url = URL.createObjectURL(new Blob([value], { type: 'text/plain;charset=utf-8' }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.style.display = 'none';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function SshKeyOutput(props: { value: GeneratedSshKey | null; loading: boolean; error: string; comment: string }) {
if (props.loading) return <div className="generator-key-status"><LoaderCircle size={24} className="generator-spinner" /><span>{t('txt_generator_ssh_generating')}</span></div>;
if (props.error) return <div className="generator-key-status error">{props.error}</div>;
if (!props.value) return null;
const publicKey = publicKeyWithComment(props.value.publicKey, props.comment);
const copyField = (value: string) => void copyTextToClipboard(value);
return (
<div className="generator-key-output">
<div className="generator-key-summary"><strong>{props.value.type}{props.value.type === 'RSA' ? ` ${props.value.bits}` : ''}</strong><code>{props.value.fingerprint}</code></div>
<div className="generator-key-field"><span>{t('txt_generator_public_key')}</span><code>{publicKey}</code><div className="generator-key-field-actions"><button type="button" className="btn btn-secondary small" onClick={() => copyField(publicKey)}><Copy size={14} />{t('txt_copy')}</button><button type="button" className="btn btn-secondary small" onClick={() => downloadText('id_nodewarden.pub', `${publicKey}\n`)}><Download size={14} />{t('txt_download')}</button></div></div>
<details className="generator-private-key"><summary>{t('txt_generator_private_key')}</summary><code>{props.value.privateKey}</code><div className="generator-key-field-actions"><button type="button" className="btn btn-secondary small" onClick={() => copyField(props.value!.privateKey)}><Copy size={14} />{t('txt_copy')}</button><button type="button" className="btn btn-secondary small" onClick={() => downloadText('id_nodewarden', props.value!.privateKey)}><Download size={14} />{t('txt_download')}</button></div></details>
</div>
);
}
function SshKeyOptionFields(props: { options: SshKeyOptions; onChange: <K extends keyof SshKeyOptions>(key: K, value: SshKeyOptions[K]) => void }) {
return (
<>
<label className="generator-select-field" htmlFor="generator-ssh-type"><span>{t('txt_generator_ssh_algorithm')}</span><select id="generator-ssh-type" className="input" value={props.options.type} onChange={(event) => props.onChange('type', event.currentTarget.value as SshKeyOptions['type'])}><option value="ed25519">Ed25519</option><option value="rsa">RSA</option></select></label>
{props.options.type === 'rsa' && <label className="generator-select-field" htmlFor="generator-rsa-length"><span>{t('txt_generator_key_length')}</span><select id="generator-rsa-length" className="input" value={props.options.rsaLength} onChange={(event) => props.onChange('rsaLength', Number(event.currentTarget.value) as SshKeyOptions['rsaLength'])}><option value={2048}>2048</option><option value={3072}>3072</option><option value={4096}>4096</option></select></label>}
<label className="generator-text-field" htmlFor="generator-ssh-comment"><span>{t('txt_generator_ssh_comment')}</span><input id="generator-ssh-comment" className="input" type="text" autocomplete="off" maxLength={256} placeholder="user@example.com" value={props.options.comment} onInput={(event) => props.onChange('comment', event.currentTarget.value)} /></label>
<p className="generator-options-note">{t(props.options.type === 'rsa' ? 'txt_generator_ssh_rsa_description' : 'txt_generator_ssh_ed25519_description')}</p>
</>
);
}
function GeneratorToggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) {
return <label className="generator-toggle"><input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.currentTarget.checked)} /><span aria-hidden="true" /><strong>{props.label}</strong></label>;
}
function GeneratorNumberStepper(props: { id: string; label: string; value: number; minimum: number; maximum: number; fallback: number; compact?: boolean; onChange: (value: number) => void }) {
const id = `generator-stepper-${props.id}`;
const setValue = (value: number) => props.onChange(clampInteger(value, props.minimum, props.maximum, props.fallback));
return (
<div className={`generator-number-field ${props.compact ? 'compact' : ''}`}>
<label htmlFor={id}>{props.label}</label>
<div className="generator-stepper">
<button type="button" aria-label={`${props.label} -`} disabled={props.value <= props.minimum} onClick={() => setValue(props.value - 1)}><Minus size={15} /></button>
<input id={id} className="input" type="text" inputMode="numeric" pattern="[0-9]*" value={props.value} onInput={(event) => setValue(Number(event.currentTarget.value))} />
<button type="button" aria-label={`${props.label} +`} disabled={props.value >= props.maximum} onClick={() => setValue(props.value + 1)}><Plus size={15} /></button>
</div>
</div>
);
}
@@ -0,0 +1,169 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { AlertTriangle, CheckCircle2, ExternalLink, Eye, EyeOff, RefreshCw, ScanSearch, ShieldAlert, ShieldCheck, Unplug } from 'lucide-preact';
import { Link } from 'wouter';
import { maskSecret } from '@/components/vault/vault-page-helpers';
import { getPasswordSecurityState, readPasswordSecurityState, startPasswordSecurityScan, subscribePasswordSecurityState } from '@/lib/password-security-cache';
import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types';
interface PasswordSecurityPageProps {
ciphers: Cipher[];
loading: boolean;
}
type PasswordSecurityFilter = 'exposed' | 'reused' | 'weak' | 'all';
function vaultFingerprint(ciphers: Cipher[]): string {
return JSON.stringify(ciphers.map((cipher) => ({
id: cipher.id,
type: cipher.type,
revisionDate: cipher.revisionDate || '',
deletedDate: cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt || '',
})));
}
function formatCheckedAt(value: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(value);
}
export default function PasswordSecurityPage(props: PasswordSecurityPageProps) {
const fingerprint = vaultFingerprint(props.ciphers);
const [securityState, setSecurityState] = useState(() => getPasswordSecurityState(fingerprint));
const [filter, setFilter] = useState<PasswordSecurityFilter>('all');
const [revealedPasswordIds, setRevealedPasswordIds] = useState<Set<string>>(() => new Set());
useEffect(() => {
setSecurityState(getPasswordSecurityState(fingerprint));
setFilter('all');
setRevealedPasswordIds(new Set());
return subscribePasswordSecurityState(() => {
const next = readPasswordSecurityState(fingerprint);
if (next) setSecurityState(next);
});
}, [fingerprint]);
const { report, scannedAt, scanning, progress, scanError } = securityState;
const eligibleCount = useMemo(
() => props.ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length,
[props.ciphers],
);
const ciphersById = useMemo(() => new Map(props.ciphers.map((cipher) => [cipher.id, cipher])), [props.ciphers]);
const filteredItems = useMemo(() => {
if (!report || filter === 'all') return report?.items || [];
if (filter === 'exposed') return report.items.filter((item) => (item.exposedCount || 0) > 0);
if (filter === 'reused') return report.items.filter((item) => item.reusedCount > 1);
return report.items.filter((item) => item.weak);
}, [filter, report]);
const allPasswordsVisible = !!report?.items.length && report.items.every((item) => revealedPasswordIds.has(item.cipherId));
const togglePasswordVisibility = (cipherId: string) => {
setRevealedPasswordIds((current) => {
const next = new Set(current);
if (next.has(cipherId)) next.delete(cipherId);
else next.add(cipherId);
return next;
});
};
const toggleAllPasswordVisibility = () => {
if (!report) return;
setRevealedPasswordIds(allPasswordsVisible ? new Set() : new Set(report.items.map((item) => item.cipherId)));
};
const scan = () => {
setRevealedPasswordIds(new Set());
setFilter('all');
startPasswordSecurityScan(fingerprint, props.ciphers);
};
return (
<section className="password-security-page" aria-label={t('txt_password_security')}>
<div className="password-security-intro card">
<div className="password-security-intro-icon"><ShieldCheck size={22} /></div>
<div>
<h2>{t('txt_password_security')}</h2>
<p>{t('txt_password_security_privacy')}</p>
{scannedAt && <p className="password-security-checked-at">{t('txt_password_security_last_checked', { value: formatCheckedAt(scannedAt) })}</p>}
</div>
<div className="password-security-intro-actions">
{report && <button type="button" className="btn btn-secondary password-security-toggle-all" onClick={toggleAllPasswordVisibility}>
{allPasswordsVisible ? <EyeOff size={16} className="btn-icon" /> : <Eye size={16} className="btn-icon" />}
{allPasswordsVisible ? t('txt_password_security_hide_all') : t('txt_password_security_show_all')}
</button>}
<button type="button" className="btn btn-primary password-security-scan" disabled={props.loading || scanning || eligibleCount === 0} onClick={scan}>
{scanning ? <RefreshCw size={16} className="btn-icon spin" /> : <ScanSearch size={16} className="btn-icon" />}
{scanning ? t('txt_checking_password_security') : report ? t('txt_recheck_password_security') : t('txt_check_password_security')}
</button>
</div>
</div>
{!report && !scanning && !props.loading && (
<div className="password-security-empty card">
<ShieldCheck size={26} aria-hidden="true" />
<strong>{eligibleCount ? t('txt_password_security_ready') : t('txt_password_security_no_login')}</strong>
<span>{eligibleCount ? t('txt_password_security_manual') : t('txt_password_security_no_login_help')}</span>
</div>
)}
{(scanning || report) && (
<div className="password-security-summary" aria-live="polite">
<SecurityMetric icon={<ShieldAlert size={18} />} tone="danger" label={t('txt_exposed_passwords')} value={report?.exposedCount ?? 0} active={filter === 'exposed'} disabled={!report} onClick={() => setFilter('exposed')} />
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_reused_passwords')} value={report?.reusedCount ?? 0} active={filter === 'reused'} disabled={!report} onClick={() => setFilter('reused')} />
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_weak_passwords')} value={report?.weakCount ?? 0} active={filter === 'weak'} disabled={!report} onClick={() => setFilter('weak')} />
<SecurityMetric icon={<CheckCircle2 size={18} />} tone="primary" label={t('txt_passwords_checked')} value={`${scanning ? progress.checked : report?.checkedCount || 0} / ${scanning ? progress.total : report?.eligibleCount || 0}`} active={filter === 'all'} disabled={!report} onClick={() => setFilter('all')} />
</div>
)}
{scanError && <div className="password-security-notice warning card" role="alert"><Unplug size={16} />{t('txt_password_security_check_failed')}</div>}
{report && (
<section className="password-security-results card">
{report.unavailableCount > 0 && (
<div className="password-security-notice warning"><Unplug size={16} />{t('txt_password_security_unavailable', { count: report.unavailableCount })}</div>
)}
{!report.items.length ? (
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks')}</strong></div>
) : !filteredItems.length ? (
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks_in_filter')}</strong></div>
) : (
<div className="password-security-list">
{filteredItems.map((item) => {
const cipher = ciphersById.get(item.cipherId);
const name = String(cipher?.decName || cipher?.name || '');
const password = String(cipher?.login?.decPassword || '');
const passwordVisible = revealedPasswordIds.has(item.cipherId);
return <article className="password-security-item" key={item.cipherId}>
<div className="password-security-item-main">
<div className="password-security-item-header">
<strong>{name || t('txt_no_name')}</strong>
<div className="password-security-badges">
{item.exposedCount === null && <span className="risk-badge muted">{t('txt_password_security_not_checked')}</span>}
{(item.exposedCount || 0) > 0 && <span className="risk-badge danger">{t('txt_password_security_exposed_short', { count: item.exposedCount || 0 })}</span>}
{item.weak && <span className="risk-badge weak">{t('txt_password_security_weak_short')}</span>}
{item.reusedCount > 1 && <span className="risk-badge reused">{t('txt_password_security_reused_short')}</span>}
</div>
</div>
<span className="password-security-password">{passwordVisible ? password : maskSecret(password)}</span>
</div>
<div className="password-security-item-actions">
<button type="button" className="btn btn-secondary small" onClick={() => togglePasswordVisibility(item.cipherId)}>
{passwordVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
{passwordVisible ? t('txt_hide') : t('txt_reveal')}
</button>
<Link href={`/vault?cipher=${encodeURIComponent(item.cipherId)}`} className="btn btn-secondary small password-security-open">
<ExternalLink size={14} className="btn-icon" />{t('txt_password_security_jump')}
</Link>
</div>
</article>;
})}
</div>
)}
</section>
)}
</section>
);
}
function SecurityMetric(props: { icon: preact.ComponentChildren; tone: 'danger' | 'warning' | 'primary'; label: string; value: string | number; active: boolean; disabled: boolean; onClick: () => void }) {
return <button type="button" className={`password-security-metric ${props.tone}`} aria-pressed={props.active} disabled={props.disabled} onClick={props.onClick}><span>{props.icon}</span><div><strong>{props.value}</strong><small>{props.label}</small></div></button>;
}
+7 -2
View File
@@ -144,6 +144,7 @@ export default function SettingsPage(props: SettingsPageProps) {
const [yubiKeyStoredKeys, setYubiKeyStoredKeys] = useState<[string, string, string, string, string]>(EMPTY_YUBIKEY_KEYS); const [yubiKeyStoredKeys, setYubiKeyStoredKeys] = useState<[string, string, string, string, string]>(EMPTY_YUBIKEY_KEYS);
const [yubiKeyNfc, setYubiKeyNfc] = useState(false); const [yubiKeyNfc, setYubiKeyNfc] = useState(false);
const [yubiKeyYubicoConfigured, setYubiKeyYubicoConfigured] = useState(false); const [yubiKeyYubicoConfigured, setYubiKeyYubicoConfigured] = useState(false);
const [yubiKeyYubicoCanManage, setYubiKeyYubicoCanManage] = useState(false);
const [yubiKeyYubicoClientId, setYubiKeyYubicoClientId] = useState(''); const [yubiKeyYubicoClientId, setYubiKeyYubicoClientId] = useState('');
const [yubiKeyYubicoSecretKey, setYubiKeyYubicoSecretKey] = useState(''); const [yubiKeyYubicoSecretKey, setYubiKeyYubicoSecretKey] = useState('');
const [yubiKeyBootstrapOtp, setYubiKeyBootstrapOtp] = useState(''); const [yubiKeyBootstrapOtp, setYubiKeyBootstrapOtp] = useState('');
@@ -340,6 +341,7 @@ export default function SettingsPage(props: SettingsPageProps) {
setYubiKeyStoredKeys(settings.keys); setYubiKeyStoredKeys(settings.keys);
setYubiKeyNfc(settings.nfc); setYubiKeyNfc(settings.nfc);
setYubiKeyYubicoConfigured(settings.yubicoConfigured); setYubiKeyYubicoConfigured(settings.yubicoConfigured);
setYubiKeyYubicoCanManage(settings.yubicoCanManage);
setYubiKeyYubicoClientId(settings.yubicoClientId); setYubiKeyYubicoClientId(settings.yubicoClientId);
setYubiKeyYubicoSecretKey(settings.yubicoSecretKey); setYubiKeyYubicoSecretKey(settings.yubicoSecretKey);
} }
@@ -352,6 +354,7 @@ export default function SettingsPage(props: SettingsPageProps) {
setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS); setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS);
setYubiKeyNfc(false); setYubiKeyNfc(false);
setYubiKeyYubicoConfigured(false); setYubiKeyYubicoConfigured(false);
setYubiKeyYubicoCanManage(false);
setYubiKeyYubicoClientId(''); setYubiKeyYubicoClientId('');
setYubiKeyYubicoSecretKey(''); setYubiKeyYubicoSecretKey('');
setYubiKeyBootstrapOtp(''); setYubiKeyBootstrapOtp('');
@@ -1010,8 +1013,7 @@ export default function SettingsPage(props: SettingsPageProps) {
</section> </section>
)} )}
{yubiKeyYubicoConfigured && ( {yubiKeyYubicoConfigured && yubiKeyYubicoCanManage && (
<>
<section className="settings-submodule yubikey-config-panel"> <section className="settings-submodule yubikey-config-panel">
<div className="settings-module-head"> <div className="settings-module-head">
<h3>{t('txt_yubikey_validation_credentials')}</h3> <h3>{t('txt_yubikey_validation_credentials')}</h3>
@@ -1055,7 +1057,10 @@ export default function SettingsPage(props: SettingsPageProps) {
</div> </div>
)} )}
</section> </section>
)}
{yubiKeyYubicoConfigured && (
<>
<ol className="settings-plain-steps"> <ol className="settings-plain-steps">
<li>{t('txt_yubikey_plug_in')}</li> <li>{t('txt_yubikey_plug_in')}</li>
<li>{t('txt_yubikey_select_empty_field')}</li> <li>{t('txt_yubikey_select_empty_field')}</li>
+53 -1
View File
@@ -87,6 +87,7 @@ export default function VaultPage(props: VaultPageProps) {
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' }); const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState(''); const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({}); const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
const pendingFocusCipherIdRef = useRef<string | null>(null);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [createMenuOpen, setCreateMenuOpen] = useState(false); const [createMenuOpen, setCreateMenuOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -497,8 +498,59 @@ export default function VaultPage(props: VaultPageProps) {
if (sidebarFilter.kind === 'duplicates') setSelectedMap({}); if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
}, [sidebarFilter.kind, duplicateMode]); }, [sidebarFilter.kind, duplicateMode]);
useEffect(() => {
if (typeof window === 'undefined') return;
const focusId = String(new URLSearchParams(window.location.search || '').get('cipher') || '').trim();
if (!focusId) return;
pendingFocusCipherIdRef.current = focusId;
}, []);
useEffect(() => {
const focusId = pendingFocusCipherIdRef.current;
if (!focusId) return;
const cipher = cipherById.get(focusId);
if (!cipher) {
if (!props.loading && props.ciphers.length > 0) pendingFocusCipherIdRef.current = null;
return;
}
const nextFilter: SidebarFilter = isCipherVisibleInTrash(cipher)
? { kind: 'trash' }
: isCipherVisibleInArchive(cipher)
? { kind: 'archive' }
: { kind: 'all' };
setSidebarFilter((prev) => (prev.kind === nextFilter.kind ? prev : nextFilter));
setSearchInput('');
setSearchQuery('');
setIsEditing(false);
setIsCreating(false);
setDraft(null);
}, [cipherById, props.ciphers.length, props.loading]);
useEffect(() => { useEffect(() => {
if (isCreating) return; if (isCreating) return;
const focusId = pendingFocusCipherIdRef.current;
if (focusId) {
if (!filteredCipherIds.has(focusId)) return;
setSelectedCipherId(focusId);
setRepromptApprovedCipherId(null);
setShowPassword(false);
setHiddenFieldVisibleMap({});
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
pendingFocusCipherIdRef.current = null;
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
const url = new URL(window.location.href);
if (url.searchParams.has('cipher')) {
url.searchParams.delete('cipher');
const next = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState(null, '', next || '/vault');
}
}
return;
}
if (!filteredCiphers.length) { if (!filteredCiphers.length) {
if (selectedCipherId) setSelectedCipherId(''); if (selectedCipherId) setSelectedCipherId('');
return; return;
@@ -506,7 +558,7 @@ export default function VaultPage(props: VaultPageProps) {
if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) { if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
setSelectedCipherId(filteredCiphers[0].id); setSelectedCipherId(filteredCiphers[0].id);
} }
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]); }, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating, isMobileLayout]);
const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]); const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
const virtualRange = useMemo(() => { const virtualRange = useMemo(() => {
@@ -1,8 +1,9 @@
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { useEffect, useMemo, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact'; import { AlertTriangle, Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RefreshCw, RotateCcw, ShieldCheck, ShieldAlert, Trash2, X } from 'lucide-preact';
import { useDialogLifecycle } from '@/components/ConfirmDialog'; import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { TotpCodeResult } from '@/lib/crypto'; import type { TotpCodeResult } from '@/lib/crypto';
import { checkPasswordLeaked, type PasswordBreachResult } from '@/lib/password-security';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
@@ -21,6 +22,10 @@ import {
toBooleanFieldValue, toBooleanFieldValue,
} from '@/components/vault/vault-page-helpers'; } from '@/components/vault/vault-page-helpers';
function isAbortError(error: unknown): boolean {
return !!error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError';
}
interface VaultDetailViewProps { interface VaultDetailViewProps {
selectedCipher: Cipher; selectedCipher: Cipher;
repromptApprovedCipherId: string | null; repromptApprovedCipherId: string | null;
@@ -90,6 +95,9 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : []; const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false); const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false); const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
const [breachResult, setBreachResult] = useState<PasswordBreachResult | null>(null);
const [checkingBreach, setCheckingBreach] = useState(false);
const breachControllerRef = useRef<AbortController | null>(null);
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt); const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
const isDeleted = isCipherDeleted(props.selectedCipher); const isDeleted = isCipherDeleted(props.selectedCipher);
const passwordHistoryEntries = useMemo( const passwordHistoryEntries = useMemo(
@@ -103,9 +111,39 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
[props.selectedCipher.passwordHistory] [props.selectedCipher.passwordHistory]
); );
useEffect(() => { useEffect(() => {
breachControllerRef.current?.abort();
breachControllerRef.current = null;
setShowSshPrivateKey(false); setShowSshPrivateKey(false);
setPasswordHistoryOpen(false); setPasswordHistoryOpen(false);
}, [props.selectedCipher.id]); setBreachResult(null);
setCheckingBreach(false);
return () => {
breachControllerRef.current?.abort();
breachControllerRef.current = null;
};
}, [props.selectedCipher.id, props.selectedCipher.login?.decPassword]);
const checkBreach = async () => {
const password = String(props.selectedCipher.login?.decPassword || '');
if (!password) return;
breachControllerRef.current?.abort();
const controller = new AbortController();
breachControllerRef.current = controller;
setCheckingBreach(true);
setBreachResult(null);
try {
const result = await checkPasswordLeaked(password, fetch, controller.signal);
if (controller.signal.aborted) return;
setBreachResult(result);
} catch (error) {
if (controller.signal.aborted || isAbortError(error)) return;
setBreachResult({ count: null, available: false });
} finally {
if (breachControllerRef.current === controller) {
breachControllerRef.current = null;
setCheckingBreach(false);
}
}
};
const formatDownloadLabel = (attachmentId: string) => { const formatDownloadLabel = (attachmentId: string) => {
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`; const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download'); if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
@@ -172,8 +210,18 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}> <button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')} <Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button> </button>
<button type="button" className="btn btn-secondary small" disabled={checkingBreach || !props.selectedCipher.login?.decPassword} onClick={() => void checkBreach()}>
{checkingBreach ? <RefreshCw size={14} className="btn-icon spin" /> : <ShieldCheck size={14} className="btn-icon" />}
{checkingBreach ? t('txt_checking_password_security') : t('txt_check_password_breach')}
</button>
</div> </div>
</div> </div>
{breachResult && (
<div className={`password-breach-inline ${breachResult.available ? (breachResult.count ? 'danger' : 'safe') : 'warning'}`} role="status">
{breachResult.available ? (breachResult.count ? <ShieldAlert size={15} /> : <ShieldCheck size={15} />) : <AlertTriangle size={15} />}
<span>{breachResult.available ? (breachResult.count ? t('txt_password_exposed_count', { count: breachResult.count }) : t('txt_password_not_found_in_breaches')) : t('txt_password_security_check_failed')}</span>
</div>
)}
{!!props.selectedCipher.login.decTotp && ( {!!props.selectedCipher.login.decTotp && (
<div className="kv-row"> <div className="kv-row">
<span className="kv-label">{t('txt_totp')}</span> <span className="kv-label">{t('txt_totp')}</span>
@@ -17,11 +17,13 @@ import {
LayoutGrid, LayoutGrid,
Pencil, Pencil,
ShieldUser, ShieldUser,
ShieldCheck,
Star, Star,
StickyNote, StickyNote,
Trash2, Trash2,
X, X,
} from 'lucide-preact'; } from 'lucide-preact';
import { Link } from 'wouter';
import type { Folder } from '@/lib/types'; import type { Folder } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers'; import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers';
@@ -95,6 +97,9 @@ export default function VaultSidebar(props: VaultSidebarProps) {
</div> </div>
)} )}
<div className="sidebar-block"> <div className="sidebar-block">
<Link href="/security/password-health" className="tree-btn">
<ShieldCheck size={14} className="tree-icon" /> <span className="tree-label">{t('nav_password_security')}</span>
</Link>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'all' })}> <button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'all' })}>
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span> <LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
</button> </button>
@@ -561,13 +561,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
openRemoveAllDevices() { openRemoveAllDevices() {
onSetConfirm({ onSetConfirm({
title: t('txt_remove_all_devices'), title: t('txt_remove_all_devices'),
message: t('txt_remove_all_devices_and_sign_out_all_sessions'), message: `${t('txt_remove_all_devices_and_sign_out_all_sessions')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteAllAuthorizedDevices(authedFetch); if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
await deleteAllAuthorizedDevices(authedFetch, derived.hash);
onNotify('success', t('txt_all_devices_removed')); onNotify('success', t('txt_all_devices_removed'));
onLogoutNow(); onLogoutNow();
} catch (error) { } catch (error) {
+68 -16
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin'; import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin';
import { deriveLoginHash } from '@/lib/api/auth';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AppConfirmState } from '@/components/AppGlobalOverlays'; import type { AppConfirmState } from '@/components/AppGlobalOverlays';
import type { AuthedFetch } from '@/lib/api/shared'; import type { AuthedFetch } from '@/lib/api/shared';
@@ -8,6 +9,8 @@ type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
interface UseAdminActionsOptions { interface UseAdminActionsOptions {
authedFetch: AuthedFetch; authedFetch: AuthedFetch;
email: string;
defaultKdfIterations: number;
onNotify: Notify; onNotify: Notify;
onSetConfirm: (next: AppConfirmState | null) => void; onSetConfirm: (next: AppConfirmState | null) => void;
refetchUsers: () => Promise<unknown>; refetchUsers: () => Promise<unknown>;
@@ -15,7 +18,24 @@ interface UseAdminActionsOptions {
} }
export default function useAdminActions(options: UseAdminActionsOptions) { export default function useAdminActions(options: UseAdminActionsOptions) {
const { authedFetch, onNotify, onSetConfirm, refetchUsers, refetchInvites } = options; const {
authedFetch,
email,
defaultKdfIterations,
onNotify,
onSetConfirm,
refetchUsers,
refetchInvites,
} = options;
async function withMasterPasswordHash(masterPassword: string | undefined): Promise<string> {
const normalizedEmail = String(email || '').trim().toLowerCase();
const normalizedPassword = String(masterPassword || '');
if (!normalizedEmail) throw new Error(t('txt_profile_unavailable'));
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(normalizedEmail, normalizedPassword, defaultKdfIterations);
return derived.hash;
}
return useMemo( return useMemo(
() => ({ () => ({
@@ -26,35 +46,61 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
}, },
async createInvite(hours: number) { async createInvite(hours: number) {
onSetConfirm({
title: t('txt_create_timed_invite'),
message: t('txt_enter_master_password_to_continue'),
requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try { try {
await createInvite(authedFetch, hours); const hash = await withMasterPasswordHash(masterPassword);
await createInvite(authedFetch, hours, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invite_created')); onNotify('success', t('txt_invite_created'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
} }
})();
},
});
}, },
async toggleUserStatus(userId: string, status: 'active' | 'banned') { async toggleUserStatus(userId: string, status: 'active' | 'banned') {
const nextStatus = status === 'active' ? 'banned' : 'active';
onSetConfirm({
title: nextStatus === 'banned' ? t('txt_ban') : t('txt_unban'),
message: t('txt_enter_master_password_to_continue'),
danger: nextStatus === 'banned',
requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try { try {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active'); const hash = await withMasterPasswordHash(masterPassword);
await setUserStatus(authedFetch, userId, nextStatus, hash);
await refetchUsers(); await refetchUsers();
onNotify('success', t('txt_user_status_updated')); onNotify('success', t('txt_user_status_updated'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
} }
})();
},
});
}, },
async deleteInvite(code: string) { async deleteInvite(code: string) {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_invite'), title: t('txt_delete_invite'),
message: t('txt_delete_invite_confirm_message'), message: `${t('txt_delete_invite_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteInvite(authedFetch, code); const hash = await withMasterPasswordHash(masterPassword);
await deleteInvite(authedFetch, code, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invite_deleted')); onNotify('success', t('txt_invite_deleted'));
} catch (error) { } catch (error) {
@@ -68,13 +114,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteInvalidInvites() { async deleteInvalidInvites() {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_invalid_invites'), title: t('txt_delete_invalid_invites'),
message: t('txt_delete_invalid_invites_confirm_message'), message: `${t('txt_delete_invalid_invites_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteInvalidInvites(authedFetch); const hash = await withMasterPasswordHash(masterPassword);
await deleteInvalidInvites(authedFetch, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invalid_invites_deleted')); onNotify('success', t('txt_invalid_invites_deleted'));
} catch (error) { } catch (error) {
@@ -88,13 +136,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteAllInvites() { async deleteAllInvites() {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_all_invites'), title: t('txt_delete_all_invites'),
message: t('txt_delete_all_invite_codes_active_inactive'), message: `${t('txt_delete_all_invite_codes_active_inactive')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteAllInvites(authedFetch); const hash = await withMasterPasswordHash(masterPassword);
await deleteAllInvites(authedFetch, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_all_invites_deleted')); onNotify('success', t('txt_all_invites_deleted'));
} catch (error) { } catch (error) {
@@ -108,13 +158,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteUser(userId: string) { async deleteUser(userId: string) {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_user'), title: t('txt_delete_user'),
message: t('txt_delete_this_user_and_all_user_data'), message: `${t('txt_delete_this_user_and_all_user_data')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteUser(authedFetch, userId); const hash = await withMasterPasswordHash(masterPassword);
await deleteUser(authedFetch, userId, hash);
await refetchUsers(); await refetchUsers();
onNotify('success', t('txt_user_deleted')); onNotify('success', t('txt_user_deleted'));
} catch (error) { } catch (error) {
@@ -125,6 +177,6 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
}); });
}, },
}), }),
[authedFetch, onNotify, onSetConfirm, refetchInvites, refetchUsers] [authedFetch, defaultKdfIterations, email, onNotify, onSetConfirm, refetchInvites, refetchUsers]
); );
} }
+29 -12
View File
@@ -15,45 +15,62 @@ export async function listAdminInvites(authedFetch: AuthedFetch): Promise<AdminI
return body?.data || []; return body?.data || [];
} }
export async function createInvite(authedFetch: AuthedFetch, hours: number): Promise<void> { export async function createInvite(authedFetch: AuthedFetch, hours: number, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites', { const resp = await authedFetch('/api/admin/invites', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expiresInHours: hours }), body: JSON.stringify({ expiresInHours: hours, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error('Create invite failed'); if (!resp.ok) throw new Error('Create invite failed');
} }
export async function deleteInvite(authedFetch: AuthedFetch, code: string): Promise<void> { export async function deleteInvite(authedFetch: AuthedFetch, code: string, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' }); const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete invite failed'); if (!resp.ok) throw new Error('Delete invite failed');
} }
export async function deleteInvalidInvites(authedFetch: AuthedFetch): Promise<void> { export async function deleteInvalidInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites?scope=invalid', { method: 'DELETE' }); const resp = await authedFetch('/api/admin/invites?scope=invalid', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete invalid invites failed'); if (!resp.ok) throw new Error('Delete invalid invites failed');
} }
export async function deleteAllInvites(authedFetch: AuthedFetch): Promise<void> { export async function deleteAllInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites', { method: 'DELETE' }); const resp = await authedFetch('/api/admin/invites', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete all invites failed'); if (!resp.ok) throw new Error('Delete all invites failed');
} }
export async function setUserStatus( export async function setUserStatus(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
userId: string, userId: string,
status: 'active' | 'banned' status: 'active' | 'banned',
masterPasswordHash: string
): Promise<void> { ): Promise<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, { const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }), body: JSON.stringify({ status, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error('Update user status failed'); if (!resp.ok) throw new Error('Update user status failed');
} }
export async function deleteUser(authedFetch: AuthedFetch, userId: string): Promise<void> { export async function deleteUser(authedFetch: AuthedFetch, userId: string, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }); const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete user failed'); if (!resp.ok) throw new Error('Delete user failed');
} }
+38 -16
View File
@@ -1,4 +1,12 @@
import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../crypto'; import {
bytesToBase64,
decryptBw,
encryptBw,
hkdfExpand,
pbkdf2,
requireWebCrypto,
WebCryptoUnavailableError,
} from '../crypto';
import { t, translateServerError } from '../i18n'; import { t, translateServerError } from '../i18n';
import type { AuthorizedDevice } from '../types'; import type { AuthorizedDevice } from '../types';
import type { import type {
@@ -42,6 +50,7 @@ interface RefreshFailure {
ok: false; ok: false;
transient: boolean; transient: boolean;
error: string; error: string;
retryAfterMs?: number;
} }
interface RefreshSuccess { interface RefreshSuccess {
@@ -333,8 +342,8 @@ export async function loginWithAccountPasskeyAssertion(assertion: AccountPasskey
return json; return json;
} }
function isTransientRefreshStatus(status: number): boolean { function isPermanentRefreshFailure(status: number, errorCode: string | undefined): boolean {
return status === 0 || status === 429 || status >= 500; return status === 400 && (errorCode === 'invalid_grant' || errorCode === 'invalid_request');
} }
export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> { export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> {
@@ -346,6 +355,8 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
try { try {
const resp = await fetch('/identity/connect/token', { const resp = await fetch('/identity/connect/token', {
method: 'POST', method: 'POST',
cache: 'no-store',
credentials: 'same-origin',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}), ...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
@@ -354,15 +365,19 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
}); });
if (!resp.ok) { if (!resp.ok) {
const json = await parseJson<TokenError>(resp); const json = await parseJson<TokenError>(resp);
const retryAfterSeconds = Number(resp.headers.get('Retry-After') || 0);
return { return {
ok: false, ok: false,
transient: isTransientRefreshStatus(resp.status), transient: !isPermanentRefreshFailure(resp.status, json?.error),
error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_failed')), error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_temporarily_unavailable')),
...(Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
? { retryAfterMs: retryAfterSeconds * 1000 }
: {}),
}; };
} }
const json = await parseJson<TokenSuccess>(resp); const json = await parseJson<TokenSuccess>(resp);
if (!json?.access_token) { if (!json?.access_token) {
return { ok: false, transient: false, error: t('txt_session_refresh_failed') }; return { ok: false, transient: true, error: t('txt_session_refresh_temporarily_unavailable') };
} }
return { ok: true, token: json }; return { ok: true, token: json };
} catch (error) { } catch (error) {
@@ -400,6 +415,8 @@ export async function revokeCurrentSession(session: SessionState | null): Promis
} }
await fetch('/identity/connect/revocation', { await fetch('/identity/connect/revocation', {
method: 'POST', method: 'POST',
cache: 'no-store',
credentials: 'same-origin',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}), ...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}),
@@ -419,14 +436,15 @@ export async function registerAccount(args: {
}): Promise<{ ok: true } | { ok: false; message: string }> { }): Promise<{ ok: true } | { ok: false; message: string }> {
try { try {
const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args; const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args;
const webCrypto = requireWebCrypto();
const masterKey = await pbkdf2(password, email, fallbackIterations, 32); const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
const masterHash = await pbkdf2(masterKey, password, 1, 32); const masterHash = await pbkdf2(masterKey, password, 1, 32);
const encKey = await hkdfExpand(masterKey, 'enc', 32); const encKey = await hkdfExpand(masterKey, 'enc', 32);
const macKey = await hkdfExpand(masterKey, 'mac', 32); const macKey = await hkdfExpand(masterKey, 'mac', 32);
const sym = crypto.getRandomValues(new Uint8Array(64)); const sym = webCrypto.getRandomValues(new Uint8Array(64));
const encryptedVaultKey = await encryptBw(sym, encKey, macKey); const encryptedVaultKey = await encryptBw(sym, encKey, macKey);
const keyPair = await crypto.subtle.generateKey( const keyPair = await webCrypto.subtle.generateKey(
{ {
name: 'RSA-OAEP', name: 'RSA-OAEP',
modulusLength: 2048, modulusLength: 2048,
@@ -436,8 +454,8 @@ export async function registerAccount(args: {
true, true,
['encrypt', 'decrypt'] ['encrypt', 'decrypt']
); );
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey)); const publicKey = new Uint8Array(await webCrypto.subtle.exportKey('spki', keyPair.publicKey));
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)); const privateKey = new Uint8Array(await webCrypto.subtle.exportKey('pkcs8', keyPair.privateKey));
const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64)); const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64));
const resp = await fetch('/api/accounts/register', { const resp = await fetch('/api/accounts/register', {
@@ -465,6 +483,9 @@ export async function registerAccount(args: {
} }
return { ok: true }; return { ok: true };
} catch (error) { } catch (error) {
if (error instanceof WebCryptoUnavailableError) {
return { ok: false, message: t('txt_web_crypto_unavailable') };
}
return { ok: false, message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_register_failed') }; return { ok: false, message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_register_failed') };
} }
} }
@@ -622,8 +643,6 @@ export async function changeMasterPassword(
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
masterPasswordHash: current.hash, masterPasswordHash: current.hash,
newMasterPasswordHash,
key: newKey,
authenticationData: { authenticationData: {
kdf: { kdf: {
kdfType: 0, kdfType: 0,
@@ -644,8 +663,6 @@ export async function changeMasterPassword(
masterKeyWrappedUserKey: newKey, masterKeyWrappedUserKey: newKey,
salt: args.email.trim().toLowerCase(), salt: args.email.trim().toLowerCase(),
}, },
kdf: 0,
kdfIterations: current.kdfIterations,
}), }),
}); });
if (!resp.ok) throw new Error('Change master password failed'); if (!resp.ok) throw new Error('Change master password failed');
@@ -678,6 +695,7 @@ function normalizeYubiKeySettings(raw: any): YubiKeyOtpSettings {
], ],
nfc: !!(raw?.nfc ?? raw?.Nfc), nfc: !!(raw?.nfc ?? raw?.Nfc),
yubicoConfigured: !!(raw?.yubicoConfigured ?? raw?.YubicoConfigured), yubicoConfigured: !!(raw?.yubicoConfigured ?? raw?.YubicoConfigured),
yubicoCanManage: !!(raw?.yubicoCanManage ?? raw?.YubicoCanManage),
yubicoClientId: String(raw?.yubicoClientId ?? raw?.YubicoClientId ?? ''), yubicoClientId: String(raw?.yubicoClientId ?? raw?.YubicoClientId ?? ''),
yubicoSecretKey: String(raw?.yubicoSecretKey ?? raw?.YubicoSecretKey ?? ''), yubicoSecretKey: String(raw?.yubicoSecretKey ?? raw?.YubicoSecretKey ?? ''),
}; };
@@ -1140,8 +1158,12 @@ export async function updateAuthorizedDeviceName(
if (!resp.ok) throw new Error(t('txt_update_device_note_failed')); if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
} }
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> { export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/devices', { method: 'DELETE' }); const resp = await authedFetch('/api/devices', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
} }
-1
View File
@@ -103,7 +103,6 @@ export interface AdminBackupImportCounts {
users: number; users: number;
domainSettings?: number; domainSettings?: number;
userRevisions: number; userRevisions: number;
trustedTwoFactorDeviceTokens?: number;
webauthnCredentials?: number; webauthnCredentials?: number;
folders: number; folders: number;
ciphers: number; ciphers: number;
+44 -9
View File
@@ -199,26 +199,43 @@ function decodeJwtExp(accessToken: string | undefined): number | null {
} }
} }
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> { type SessionRefreshOutcome =
if (!session.refreshToken && session.authMode !== 'web-cookie') return session.accessToken ? session : null; | { kind: 'success'; session: SessionState }
| { kind: 'transient'; session: SessionState; message: string; retryAfterMs?: number }
| { kind: 'expired' };
async function maybeRefreshSession(session: SessionState): Promise<SessionRefreshOutcome> {
if (!session.refreshToken && session.authMode !== 'web-cookie') {
return session.accessToken ? { kind: 'success', session } : { kind: 'expired' };
}
const exp = decodeJwtExp(session.accessToken); const exp = decodeJwtExp(session.accessToken);
const nowSeconds = Math.floor(Date.now() / 1000); const nowSeconds = Math.floor(Date.now() / 1000);
if (session.accessToken && exp !== null && exp - nowSeconds > 60) { if (session.accessToken && exp !== null && exp - nowSeconds > 60) {
return session; return { kind: 'success', session };
} }
const refreshed = await refreshAccessToken(session); const refreshed = await refreshAccessToken(session);
if (!refreshed.ok) { if (!refreshed.ok) {
if (refreshed.transient) return session; if (refreshed.transient) {
return session.accessToken && exp !== null && exp > nowSeconds ? session : null; return {
kind: 'transient',
session,
message: refreshed.error || t('txt_session_refresh_temporarily_unavailable'),
retryAfterMs: refreshed.retryAfterMs,
};
}
return { kind: 'expired' };
} }
return { return {
kind: 'success',
session: {
...session, ...session,
accessToken: refreshed.token.access_token, accessToken: refreshed.token.access_token,
refreshToken: refreshed.token.refresh_token || session.refreshToken, refreshToken: refreshed.token.refresh_token || session.refreshToken,
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'), authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
},
}; };
} }
@@ -388,25 +405,41 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
export async function hydrateLockedSession( export async function hydrateLockedSession(
session: SessionState, session: SessionState,
fallbackProfile: Profile | null = null fallbackProfile: Profile | null = null
): Promise<{ session: SessionState | null; profile: Profile | null }> { ): Promise<
| { kind: 'ready'; session: SessionState; profile: Profile | null }
| { kind: 'transient'; session: SessionState; profile: Profile | null; message: string; retryAfterMs?: number }
| { kind: 'expired'; session: null; profile: null }
> {
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email); const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
if (hasOfflineUnlock && browserReportsOffline()) { if (hasOfflineUnlock && browserReportsOffline()) {
return { return {
kind: 'ready',
session, session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email), profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
}; };
} }
const refreshedSession = await maybeRefreshSession(session); const refreshOutcome = await maybeRefreshSession(session);
if (!refreshedSession?.accessToken) { if (refreshOutcome.kind === 'expired') {
return { kind: 'expired', session: null, profile: null };
}
if (refreshOutcome.kind === 'transient') {
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) { if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
return { return {
kind: 'ready',
session, session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email), profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
}; };
} }
return { session: null, profile: null }; return {
kind: 'transient',
session,
profile: fallbackProfile,
message: refreshOutcome.message,
retryAfterMs: refreshOutcome.retryAfterMs,
};
} }
const refreshedSession = refreshOutcome.session;
try { try {
const profile = await getProfile( const profile = await getProfile(
createAuthedFetch( createAuthedFetch(
@@ -415,11 +448,13 @@ export async function hydrateLockedSession(
) )
); );
return { return {
kind: 'ready',
session: refreshedSession, session: refreshedSession,
profile, profile,
}; };
} catch { } catch {
return { return {
kind: 'ready',
session: refreshedSession, session: refreshedSession,
profile: fallbackProfile, profile: fallbackProfile,
}; };
+78 -1
View File
@@ -81,6 +81,9 @@ export function summarizeImportResult(
if (type === 3) return t('txt_card'); if (type === 3) return t('txt_card');
if (type === 4) return t('txt_identity'); if (type === 4) return t('txt_identity');
if (type === 5) return t('txt_ssh_key'); if (type === 5) return t('txt_ssh_key');
if (type === 6) return t('txt_bank_account');
if (type === 7) return t('txt_drivers_license');
if (type === 8) return t('txt_passport');
return t('txt_other'); return t('txt_other');
}; };
const counter = new Map<number, number>(); const counter = new Map<number, number>();
@@ -88,7 +91,7 @@ export function summarizeImportResult(
const cipherType = Number(raw?.type || 1) || 1; const cipherType = Number(raw?.type || 1) || 1;
counter.set(cipherType, (counter.get(cipherType) || 0) + 1); counter.set(cipherType, (counter.get(cipherType) || 0) + 1);
} }
const order = [1, 2, 3, 4, 5]; const order = [1, 2, 3, 4, 5, 6, 7, 8];
const seen = new Set<number>(order); const seen = new Set<number>(order);
const typeCounts = order const typeCounts = order
.filter((type) => (counter.get(type) || 0) > 0) .filter((type) => (counter.get(type) || 0) > 0)
@@ -146,6 +149,40 @@ function buildEmptyImportDraft(type: number): VaultDraft {
sshPrivateKey: '', sshPrivateKey: '',
sshPublicKey: '', sshPublicKey: '',
sshFingerprint: '', sshFingerprint: '',
bankName: '',
bankNameOnAccount: '',
bankAccountType: '',
bankAccountNumber: '',
bankRoutingNumber: '',
bankBranchNumber: '',
bankPin: '',
bankSwiftCode: '',
bankIban: '',
bankContactPhone: '',
licenseFirstName: '',
licenseMiddleName: '',
licenseLastName: '',
licenseDateOfBirth: '',
licenseNumber: '',
licenseIssuingCountry: '',
licenseIssuingState: '',
licenseIssueDate: '',
licenseExpirationDate: '',
licenseIssuingAuthority: '',
licenseClass: '',
passportSurname: '',
passportGivenName: '',
passportDateOfBirth: '',
passportSex: '',
passportBirthPlace: '',
passportNationality: '',
passportIssuingCountry: '',
passportNumber: '',
passportType: '',
passportNationalIdentificationNumber: '',
passportIssuingAuthority: '',
passportIssueDate: '',
passportExpirationDate: '',
customFields: [], customFields: [],
}; };
} }
@@ -247,6 +284,46 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
draft.sshPrivateKey = asText(sshKey.privateKey); draft.sshPrivateKey = asText(sshKey.privateKey);
draft.sshPublicKey = asText(sshKey.publicKey); draft.sshPublicKey = asText(sshKey.publicKey);
draft.sshFingerprint = asText(sshKey.keyFingerprint ?? sshKey.fingerprint); draft.sshFingerprint = asText(sshKey.keyFingerprint ?? sshKey.fingerprint);
} else if (type === 6) {
const bankAccount = (cipher.bankAccount || {}) as Record<string, unknown>;
draft.bankName = asText(bankAccount.bankName);
draft.bankNameOnAccount = asText(bankAccount.nameOnAccount);
draft.bankAccountType = asText(bankAccount.accountType);
draft.bankAccountNumber = asText(bankAccount.accountNumber);
draft.bankRoutingNumber = asText(bankAccount.routingNumber);
draft.bankBranchNumber = asText(bankAccount.branchNumber);
draft.bankPin = asText(bankAccount.pin);
draft.bankSwiftCode = asText(bankAccount.swiftCode);
draft.bankIban = asText(bankAccount.iban);
draft.bankContactPhone = asText(bankAccount.bankContactPhone);
} else if (type === 7) {
const driversLicense = (cipher.driversLicense || {}) as Record<string, unknown>;
draft.licenseFirstName = asText(driversLicense.firstName);
draft.licenseMiddleName = asText(driversLicense.middleName);
draft.licenseLastName = asText(driversLicense.lastName);
draft.licenseDateOfBirth = asText(driversLicense.dateOfBirth);
draft.licenseNumber = asText(driversLicense.licenseNumber);
draft.licenseIssuingCountry = asText(driversLicense.issuingCountry);
draft.licenseIssuingState = asText(driversLicense.issuingState);
draft.licenseIssueDate = asText(driversLicense.issueDate);
draft.licenseExpirationDate = asText(driversLicense.expirationDate);
draft.licenseIssuingAuthority = asText(driversLicense.issuingAuthority);
draft.licenseClass = asText(driversLicense.licenseClass);
} else if (type === 8) {
const passport = (cipher.passport || {}) as Record<string, unknown>;
draft.passportSurname = asText(passport.surname);
draft.passportGivenName = asText(passport.givenName);
draft.passportDateOfBirth = asText(passport.dateOfBirth);
draft.passportSex = asText(passport.sex);
draft.passportBirthPlace = asText(passport.birthPlace);
draft.passportNationality = asText(passport.nationality);
draft.passportIssuingCountry = asText(passport.issuingCountry);
draft.passportNumber = asText(passport.passportNumber);
draft.passportType = asText(passport.passportType);
draft.passportNationalIdentificationNumber = asText(passport.nationalIdentificationNumber);
draft.passportIssuingAuthority = asText(passport.issuingAuthority);
draft.passportIssueDate = asText(passport.issueDate);
draft.passportExpirationDate = asText(passport.expirationDate);
} }
return draft; return draft;
+5
View File
@@ -14,10 +14,12 @@ export interface PersistedRemoteBrowserState {
pathByDestination: Record<string, string>; pathByDestination: Record<string, string>;
pageByKey: Record<string, number>; pageByKey: Record<string, number>;
selectedDestinationId: string | null; selectedDestinationId: string | null;
refreshedAt: Record<string, number>;
} }
export const REMOTE_BROWSER_STORAGE_KEY = 'nodewarden.backup.remote-browser.v1'; export const REMOTE_BROWSER_STORAGE_KEY = 'nodewarden.backup.remote-browser.v1';
export const REMOTE_BROWSER_ITEMS_PER_PAGE = 10; export const REMOTE_BROWSER_ITEMS_PER_PAGE = 10;
export const REMOTE_BROWSER_REFRESH_TTL_MS = 5 * 60 * 1000; // 5 minutes
export const COMMON_TIME_ZONES = [ export const COMMON_TIME_ZONES = [
'UTC', 'UTC',
@@ -148,6 +150,7 @@ export function loadPersistedRemoteBrowserState(userId?: string | null): Persist
pathByDestination: {}, pathByDestination: {},
pageByKey: {}, pageByKey: {},
selectedDestinationId: null, selectedDestinationId: null,
refreshedAt: {},
}; };
} }
const parsed = JSON.parse(raw) as Partial<PersistedRemoteBrowserState>; const parsed = JSON.parse(raw) as Partial<PersistedRemoteBrowserState>;
@@ -156,6 +159,7 @@ export function loadPersistedRemoteBrowserState(userId?: string | null): Persist
pathByDestination: parsed.pathByDestination && typeof parsed.pathByDestination === 'object' ? parsed.pathByDestination : {}, pathByDestination: parsed.pathByDestination && typeof parsed.pathByDestination === 'object' ? parsed.pathByDestination : {},
pageByKey: parsed.pageByKey && typeof parsed.pageByKey === 'object' ? parsed.pageByKey : {}, pageByKey: parsed.pageByKey && typeof parsed.pageByKey === 'object' ? parsed.pageByKey : {},
selectedDestinationId: typeof parsed.selectedDestinationId === 'string' ? parsed.selectedDestinationId : null, selectedDestinationId: typeof parsed.selectedDestinationId === 'string' ? parsed.selectedDestinationId : null,
refreshedAt: parsed.refreshedAt && typeof parsed.refreshedAt === 'object' ? parsed.refreshedAt as Record<string, number> : {},
}; };
} catch { } catch {
return { return {
@@ -163,6 +167,7 @@ export function loadPersistedRemoteBrowserState(userId?: string | null): Persist
pathByDestination: {}, pathByDestination: {},
pageByKey: {}, pageByKey: {},
selectedDestinationId: null, selectedDestinationId: null,
refreshedAt: {},
}; };
} }
} }
+52 -17
View File
@@ -1,3 +1,34 @@
export const WEB_CRYPTO_UNAVAILABLE_MESSAGE =
'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.';
export class WebCryptoUnavailableError extends Error {
constructor() {
super(WEB_CRYPTO_UNAVAILABLE_MESSAGE);
this.name = 'WebCryptoUnavailableError';
}
}
interface WebCryptoEnvironment {
crypto?: Crypto;
isSecureContext?: boolean;
}
export function requireWebCrypto(
environment: WebCryptoEnvironment = globalThis as unknown as WebCryptoEnvironment
): Crypto {
const cryptoApi = environment.crypto;
if (
environment.isSecureContext === false ||
!cryptoApi ||
typeof cryptoApi.getRandomValues !== 'function' ||
!cryptoApi.subtle ||
typeof cryptoApi.subtle.importKey !== 'function'
) {
throw new WebCryptoUnavailableError();
}
return cryptoApi;
}
export function bytesToBase64(bytes: Uint8Array): string { export function bytesToBase64(bytes: Uint8Array): string {
let s = ''; let s = '';
for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]); for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]);
@@ -24,7 +55,7 @@ export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
export async function sha256Base64(value: string): Promise<string> { export async function sha256Base64(value: string): Promise<string> {
const bytes = new TextEncoder().encode(value); const bytes = new TextEncoder().encode(value);
const hash = await crypto.subtle.digest('SHA-256', toBufferSource(bytes)); const hash = await requireWebCrypto().subtle.digest('SHA-256', toBufferSource(bytes));
return bytesToBase64(new Uint8Array(hash)); return bytesToBase64(new Uint8Array(hash));
} }
@@ -51,7 +82,7 @@ function getHmacSha256Key(keyBytes: Uint8Array): Promise<CryptoKey> {
return getCachedCryptoKey( return getCachedCryptoKey(
hmacSha256KeyCache, hmacSha256KeyCache,
keyBytes, keyBytes,
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) () => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
); );
} }
@@ -59,7 +90,7 @@ function getAesCbcEncryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
return getCachedCryptoKey( return getCachedCryptoKey(
aesCbcEncryptKeyCache, aesCbcEncryptKeyCache,
keyBytes, keyBytes,
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt']) () => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt'])
); );
} }
@@ -67,7 +98,7 @@ function getAesCbcDecryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
return getCachedCryptoKey( return getCachedCryptoKey(
aesCbcDecryptKeyCache, aesCbcDecryptKeyCache,
keyBytes, keyBytes,
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt']) () => requireWebCrypto().subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt'])
); );
} }
@@ -88,8 +119,9 @@ export async function pbkdf2(
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes; const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes;
const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes; const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes;
const key = await crypto.subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']); const subtle = requireWebCrypto().subtle;
const bits = await crypto.subtle.deriveBits( const key = await subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']);
const bits = await subtle.deriveBits(
{ name: 'PBKDF2', hash: 'SHA-256', salt: toBufferSource(saltBytes), iterations }, { name: 'PBKDF2', hash: 'SHA-256', salt: toBufferSource(saltBytes), iterations },
key, key,
keyLen * 8 keyLen * 8
@@ -99,7 +131,8 @@ export async function pbkdf2(
export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise<Uint8Array> { export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise<Uint8Array> {
const infoBytes = new TextEncoder().encode(info || ''); const infoBytes = new TextEncoder().encode(info || '');
const key = await crypto.subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const subtle = requireWebCrypto().subtle;
const key = await subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const result = new Uint8Array(length); const result = new Uint8Array(length);
let previous = new Uint8Array(0); let previous = new Uint8Array(0);
let offset = 0; let offset = 0;
@@ -110,7 +143,7 @@ export async function hkdfExpand(prk: Uint8Array, info: string, length: number):
input.set(previous, 0); input.set(previous, 0);
input.set(infoBytes, previous.length); input.set(infoBytes, previous.length);
input[input.length - 1] = counter & 0xff; input[input.length - 1] = counter & 0xff;
previous = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(input))); previous = new Uint8Array(await subtle.sign('HMAC', key, toBufferSource(input)));
const copyLen = Math.min(previous.length, length - offset); const copyLen = Math.min(previous.length, length - offset);
result.set(previous.slice(0, copyLen), offset); result.set(previous.slice(0, copyLen), offset);
offset += copyLen; offset += copyLen;
@@ -134,28 +167,29 @@ export async function hkdf(
info: toBufferSource(infoBytes), info: toBufferSource(infoBytes),
hash: 'SHA-256', hash: 'SHA-256',
}; };
const key = await crypto.subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']); const subtle = requireWebCrypto().subtle;
const bits = await crypto.subtle.deriveBits(params, key, outputByteSize * 8); const key = await subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']);
const bits = await subtle.deriveBits(params, key, outputByteSize * 8);
return new Uint8Array(bits); return new Uint8Array(bits);
} }
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> { async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
const key = await getHmacSha256Key(keyBytes); const key = await getHmacSha256Key(keyBytes);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes))); return new Uint8Array(await requireWebCrypto().subtle.sign('HMAC', key, toBufferSource(dataBytes)));
} }
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> { async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await getAesCbcEncryptKey(key); const cryptoKey = await getAesCbcEncryptKey(key);
return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data))); return new Uint8Array(await requireWebCrypto().subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
} }
async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> { async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await getAesCbcDecryptKey(key); const cryptoKey = await getAesCbcDecryptKey(key);
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data))); return new Uint8Array(await requireWebCrypto().subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
} }
export async function encryptBwFileData(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<Uint8Array> { export async function encryptBwFileData(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<Uint8Array> {
const iv = crypto.getRandomValues(new Uint8Array(16)); const iv = requireWebCrypto().getRandomValues(new Uint8Array(16));
const cipher = await encryptAesCbc(data, encKey, iv); const cipher = await encryptAesCbc(data, encKey, iv);
const mac = await hmacSha256(macKey, concatBytes(iv, cipher)); const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
const out = new Uint8Array(1 + iv.length + mac.length + cipher.length); const out = new Uint8Array(1 + iv.length + mac.length + cipher.length);
@@ -179,7 +213,7 @@ export async function decryptBwFileData(encrypted: Uint8Array, encKey: Uint8Arra
} }
export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<string> { export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(16)); const iv = requireWebCrypto().getRandomValues(new Uint8Array(16));
const cipher = await encryptAesCbc(data, encKey, iv); const cipher = await encryptAesCbc(data, encKey, iv);
const mac = await hmacSha256(macKey, concatBytes(iv, cipher)); const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
return `2.${bytesToBase64(iv)}|${bytesToBase64(cipher)}|${bytesToBase64(mac)}`; return `2.${bytesToBase64(iv)}|${bytesToBase64(cipher)}|${bytesToBase64(mac)}`;
@@ -556,8 +590,9 @@ export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now())
message[i] = c & 0xff; message[i] = c & 0xff;
c = Math.floor(c / 256); c = Math.floor(c / 256);
} }
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']); const subtle = requireWebCrypto().subtle;
const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message))); const key = await subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']);
const hs = new Uint8Array(await subtle.sign('HMAC', key, toBufferSource(message)));
const offset = hs[hs.length - 1] & 0x0f; const offset = hs[hs.length - 1] & 0x0f;
const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff); const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
let code = (bin % (10 ** digits)).toString().padStart(digits, '0'); let code = (bin % (10 ** digits)).toString().padStart(digits, '0');
File diff suppressed because it is too large Load Diff
+1
View File
@@ -238,6 +238,7 @@ export function translateServerError(message: string | null | undefined, fallbac
'WebDAV server URL is required': 'txt_backup_error_webdav_url_required', 'WebDAV server URL is required': 'txt_backup_error_webdav_url_required',
'WebDAV server URL must start with http:// or https://': 'txt_backup_error_webdav_url_protocol', 'WebDAV server URL must start with http:// or https://': 'txt_backup_error_webdav_url_protocol',
'WebDAV username is required': 'txt_backup_error_webdav_username_required', 'WebDAV username is required': 'txt_backup_error_webdav_username_required',
'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.': 'txt_web_crypto_unavailable',
'masterPasswordHash is required': 'txt_server_error_master_password_hash_required', 'masterPasswordHash is required': 'txt_server_error_master_password_hash_required',
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required', 'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
}[normalized]; }[normalized];
+17
View File
@@ -7,10 +7,17 @@ const de: Record<string, string> = {
"nav_my_vault": "Mein Tresor", "nav_my_vault": "Mein Tresor",
"nav_vault_items": "Tresor", "nav_vault_items": "Tresor",
"nav_sends": "Sendungen", "nav_sends": "Sendungen",
"nav_generator": "Passwortwerkzeug", "txt_password_generator": "Passwortgenerator", "txt_password_generator_description": "Erstellen Sie lokal auf diesem Gerät ein starkes, einzigartiges Passwort.", "txt_generator_type": "Generatortyp", "txt_passphrase": "Kennwortsatz", "txt_generated_password": "Generiertes Passwort", "txt_password_strength": "Stärke", "txt_password_strength_weak": "Schwach", "txt_password_strength_fair": "Mittel", "txt_password_strength_good": "Gut", "txt_password_strength_strong": "Stark", "txt_generator_security_note": "Die Erzeugung erfolgt lokal. Ihr Passwort wird nie an den Server gesendet.", "txt_generator_length": "Länge", "txt_generator_character_types": "Zeichentypen", "txt_generator_uppercase": "Großbuchstaben (A-Z)", "txt_generator_lowercase": "Kleinbuchstaben (a-z)", "txt_generator_numbers": "Zahlen (0-9)", "txt_generator_special": "Sonderzeichen (!@#$%^&*)", "txt_generator_minimum": "Mindestanzahl", "txt_generator_avoid_ambiguous": "Verwechselbare Zeichen vermeiden", "txt_generator_words": "Anzahl der Wörter", "txt_generator_separator": "Worttrenner", "txt_generator_capitalize": "Großschreibung", "txt_generator_include_number": "Eine Zahl einfügen",
"txt_generated_value": "Erzeugter Wert", "txt_generator_character_count": "{count} Zeichen", "txt_generator_pin": "PIN-Code", "txt_generator_pin_description": "Erzeugt lokal eine numerische PIN mit kryptografisch sicheren Zufallswerten.", "txt_generator_username": "Benutzername", "txt_generator_plus_addressed_email": "E-Mail mit Plus-Adressierung", "txt_generator_catch_all_email": "Catch-all-E-Mail", "txt_generator_email": "E-Mail-Adresse", "txt_generator_domain": "Domäne",
"txt_generator_word_list": "Wortliste", "txt_generator_eff_word_list": "Lange EFF-Wortliste", "txt_generator_custom_word_list": "Eigene Wortliste", "txt_generator_custom_words": "Eigene Wörter", "txt_generator_custom_words_placeholder": "Mindestens zwei Wörter durch Leerzeichen, Kommas oder Zeilen trennen",
"txt_generator_long_word_username": "Langwort-Benutzername", "txt_generator_custom_word": "Eigenes Merkwort", "txt_generator_subdomain_email": "Subdomain-E-Mail", "txt_generator_ssh_key": "SSH-Schlüssel", "txt_generator_ssh_generating": "SSH-Schlüssel wird erzeugt…", "txt_generator_ssh_error": "Dieser Browser unterstützt keine SSH-Schlüsselerzeugung.", "txt_generator_copy_public_key": "Öffentlichen Schlüssel kopieren", "txt_generator_ssh_security_note": "Das Schlüsselpaar wird lokal erzeugt und nicht gesendet. Speichern Sie den privaten Schlüssel jetzt; NodeWarden behält ihn nicht.", "txt_generator_public_key": "Öffentlicher Schlüssel", "txt_generator_private_key": "Privater Schlüssel — geheim halten", "txt_generator_ssh_algorithm": "Algorithmus", "txt_generator_key_length": "Schlüssellänge", "txt_generator_ssh_comment": "Kommentar des öffentlichen Schlüssels", "txt_generator_ssh_rsa_description": "RSA ist breit kompatibel. Mindestens 2048 Bit verwenden; 4096 Bit werden empfohlen.", "txt_generator_ssh_ed25519_description": "Ed25519 ist schnell, kompakt und für moderne SSH-Clients empfohlen.",
"txt_generator_email_alias": "E-Mail-Alias", "txt_generator_email_type": "Alias-Typ", "txt_generator_email_required_hint": "E-Mail-Adresse oder Domain eingeben, um einen Alias zu erzeugen.", "txt_generator_email_description": "Ihre Eingabe bleibt auf diesem Gerät und wird nur zum Zusammensetzen des Alias verwendet.", "txt_generator_long_word_username_description": "Ein oder mehrere lange Wörter zu einem einprägsamen Benutzernamen kombinieren.",
"nav_backup_strategy": "Cloud-Backup", "nav_backup_strategy": "Cloud-Backup",
"nav_import_export": "Import und Export", "nav_import_export": "Import und Export",
"nav_group_data_backup": "Daten & Backup", "nav_group_data_backup": "Daten & Backup",
"nav_group_management": "Verwaltung", "nav_group_management": "Verwaltung",
"nav_group_tools": "Werkzeuge",
"nav_group_system_management": "Systemverwaltung",
"txt_settings_appearance": "Erscheinungsbild", "txt_settings_appearance": "Erscheinungsbild",
"txt_theme": "Design", "txt_theme": "Design",
"txt_use_system_theme": "Systemdesign verwenden", "txt_use_system_theme": "Systemdesign verwenden",
@@ -508,6 +515,7 @@ const de: Record<string, string> = {
"txt_create_account": "Konto erstellen", "txt_create_account": "Konto erstellen",
"txt_registering": "Konto wird erstellt...", "txt_registering": "Konto wird erstellt...",
"txt_register_failed": "Registrierung fehlgeschlagen", "txt_register_failed": "Registrierung fehlgeschlagen",
"txt_web_crypto_unavailable": "Sichere Browser-Kryptografie ist nicht verfügbar. Öffnen Sie NodeWarden über HTTPS in einem unterstützten Browser.",
"txt_create_folder": "Ordner erstellen", "txt_create_folder": "Ordner erstellen",
"txt_create_folder_failed": "Fehler beim Erstellen des Ordners", "txt_create_folder_failed": "Fehler beim Erstellen des Ordners",
"txt_create_item_failed": "Fehler beim Erstellen des Eintrags", "txt_create_item_failed": "Fehler beim Erstellen des Eintrags",
@@ -978,6 +986,7 @@ const de: Record<string, string> = {
"txt_save_profile_failed": "Fehler beim Speichern des Profils", "txt_save_profile_failed": "Fehler beim Speichern des Profils",
"txt_search_sends": "Sendungen suchen...", "txt_search_sends": "Sendungen suchen...",
"txt_session_refresh_failed": "Sitzungsaktualisierung fehlgeschlagen. Bitte melden Sie sich erneut an.", "txt_session_refresh_failed": "Sitzungsaktualisierung fehlgeschlagen. Bitte melden Sie sich erneut an.",
"txt_session_refresh_temporarily_unavailable": "Die Sitzung kann vorübergehend nicht geprüft werden. Die Anmeldung bleibt erhalten und wird erneut versucht.",
"txt_search_your_secure_vault": "Ihren sicheren Tresor durchsuchen...", "txt_search_your_secure_vault": "Ihren sicheren Tresor durchsuchen...",
"txt_search_items_count": "In {count} Einträgen suchen...", "txt_search_items_count": "In {count} Einträgen suchen...",
"txt_clear_search": "Suche löschen", "txt_clear_search": "Suche löschen",
@@ -1447,4 +1456,12 @@ const de: Record<string, string> = {
"txt_ip_address": "IP-Adresse" "txt_ip_address": "IP-Adresse"
}; };
Object.assign(de, {
"nav_password_security": "Passwortsicherheit", "txt_password_security": "Passwort-Sicherheitsprüfung", "txt_password_security_privacy": "Passwörter werden lokal geprüft. Erst nach dem Start wird nur ein anonymer Hash-Präfix an die Leckdatenbank gesendet.", "txt_check_password_security": "Prüfung starten", "txt_checking_password_security": "Prüfung läuft", "txt_recheck_password_security": "Erneut prüfen", "txt_password_security_ready": "Ihr Tresor ist für eine Sicherheitsprüfung bereit.", "txt_password_security_no_login": "Es gibt keine Login-Passwörter zu prüfen.", "txt_password_security_manual": "Die Prüfung startet nur auf Ihre Anfrage. Ergebnisse bleiben nur auf dieser Seite.", "txt_password_security_no_login_help": "Fügen Sie einen Login-Eintrag mit Passwort hinzu und prüfen Sie ihn anschließend hier.", "txt_exposed_passwords": "Geleakt", "txt_reused_passwords": "Wiederverwendet", "txt_weak_passwords": "Schwach", "txt_passwords_checked": "Geprüft", "txt_password_security_unavailable": "{count} Passwortprüfungen konnten die Leckdatenbank nicht erreichen. Sie werden nicht als sicher markiert.", "txt_password_security_not_checked": "Nicht geprüft", "txt_password_exposed_count": "In {count} Lecks gefunden", "txt_password_reused_count": "{count}-mal verwendet", "txt_weak_password": "Schwaches Passwort", "txt_no_password_risks": "Keine Passwortrisiken gefunden", "txt_open_vault": "Tresor öffnen", "txt_check_password_breach": "Leck prüfen", "txt_password_not_found_in_breaches": "Nicht in der Leckdatenbank gefunden", "txt_password_security_check_failed": "Die Leckprüfung konnte nicht abgeschlossen werden."
});
Object.assign(de, { "txt_password_security_last_checked": "Zuletzt überprüft: {value}" });
Object.assign(de, { "txt_no_password_risks_in_filter": "Keine Passwortrisiken in dieser Kategorie" });
Object.assign(de, { "txt_password_security_show_all": "Alle anzeigen", "txt_password_security_hide_all": "Alle ausblenden", "txt_password_security_jump": "Öffnen", "txt_password_security_exposed_short": "{count}-mal geleakt", "txt_password_security_weak_short": "Schwaches Passwort", "txt_password_security_reused_short": "Wiederverwendet" });
export default de; export default de;
+97
View File
@@ -7,10 +7,70 @@ const en: Record<string, string> = {
"nav_my_vault": "My Vault", "nav_my_vault": "My Vault",
"nav_vault_items": "Vault", "nav_vault_items": "Vault",
"nav_sends": "Sends", "nav_sends": "Sends",
"nav_generator": "Generator",
"txt_password_generator": "Password Generator",
"txt_password_generator_description": "Create a strong, unique password locally on this device.",
"txt_generator_type": "Generator type",
"txt_passphrase": "Passphrase",
"txt_generated_password": "Generated password",
"txt_password_strength": "Strength",
"txt_password_strength_weak": "Weak",
"txt_password_strength_fair": "Fair",
"txt_password_strength_good": "Good",
"txt_password_strength_strong": "Strong",
"txt_generator_security_note": "Generation happens locally. Your generated password is never sent to the server.",
"txt_generator_length": "Length",
"txt_generator_character_types": "Character types",
"txt_generator_uppercase": "Uppercase (A-Z)",
"txt_generator_lowercase": "Lowercase (a-z)",
"txt_generator_numbers": "Numbers (0-9)",
"txt_generator_special": "Special characters (!@#$%^&*)",
"txt_generator_minimum": "Minimum",
"txt_generator_avoid_ambiguous": "Avoid ambiguous characters",
"txt_generator_words": "Number of words",
"txt_generator_separator": "Word separator",
"txt_generator_capitalize": "Capitalize",
"txt_generator_include_number": "Include a number",
"txt_generated_value": "Generated value",
"txt_generator_character_count": "{count} characters",
"txt_generator_pin": "PIN",
"txt_generator_pin_description": "Generate a numeric PIN locally using cryptographically secure randomness.",
"txt_generator_username": "Username",
"txt_generator_plus_addressed_email": "Plus-addressed email",
"txt_generator_catch_all_email": "Catch-all email",
"txt_generator_email": "Email address",
"txt_generator_domain": "Domain",
"txt_generator_word_list": "Word list",
"txt_generator_eff_word_list": "EFF long word list",
"txt_generator_custom_word_list": "Custom word list",
"txt_generator_custom_words": "Custom words",
"txt_generator_custom_words_placeholder": "Enter at least two words separated by spaces, commas, or new lines",
"txt_generator_long_word_username": "Long-word username",
"txt_generator_custom_word": "Custom helper word",
"txt_generator_subdomain_email": "Subdomain email",
"txt_generator_ssh_key": "SSH key",
"txt_generator_ssh_generating": "Generating SSH key…",
"txt_generator_ssh_error": "SSH key generation is not supported by this browser.",
"txt_generator_copy_public_key": "Copy public key",
"txt_generator_ssh_security_note": "The key pair is generated locally and is never sent to the server. Save the private key now; NodeWarden does not retain it.",
"txt_generator_public_key": "Public key",
"txt_generator_private_key": "Private key — keep secret",
"txt_generator_ssh_algorithm": "Algorithm",
"txt_generator_key_length": "Key length",
"txt_generator_ssh_comment": "Public key comment",
"txt_generator_ssh_rsa_description": "RSA is widely compatible. Use at least 2048 bits; 4096 bits is recommended.",
"txt_generator_ssh_ed25519_description": "Ed25519 is fast, compact, and recommended for modern SSH clients.",
"txt_generator_email_alias": "Email alias",
"txt_generator_email_type": "Email alias type",
"txt_generator_email_required_hint": "Enter your email address or domain to generate an alias.",
"txt_generator_email_description": "Your address stays on this device and is only used to compose the alias.",
"txt_generator_long_word_username_description": "Combine one or more long words into a memorable username.",
"nav_backup_strategy": "Cloud Backup", "nav_backup_strategy": "Cloud Backup",
"nav_import_export": "Import & Export", "nav_import_export": "Import & Export",
"nav_group_data_backup": "Data & Backup", "nav_group_data_backup": "Data & Backup",
"nav_group_management": "Management", "nav_group_management": "Management",
"nav_group_tools": "Tools",
"nav_group_system_management": "System Management",
"txt_settings_appearance": "Appearance", "txt_settings_appearance": "Appearance",
"txt_theme": "Theme", "txt_theme": "Theme",
"txt_use_system_theme": "Use system theme", "txt_use_system_theme": "Use system theme",
@@ -508,6 +568,7 @@ const en: Record<string, string> = {
"txt_create_account": "Create Account", "txt_create_account": "Create Account",
"txt_registering": "Creating account...", "txt_registering": "Creating account...",
"txt_register_failed": "Register failed", "txt_register_failed": "Register failed",
"txt_web_crypto_unavailable": "Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.",
"txt_create_folder": "Create Folder", "txt_create_folder": "Create Folder",
"txt_create_folder_failed": "Create folder failed", "txt_create_folder_failed": "Create folder failed",
"txt_create_item_failed": "Create item failed", "txt_create_item_failed": "Create item failed",
@@ -978,6 +1039,7 @@ const en: Record<string, string> = {
"txt_save_profile_failed": "Save profile failed", "txt_save_profile_failed": "Save profile failed",
"txt_search_sends": "Search sends...", "txt_search_sends": "Search sends...",
"txt_session_refresh_failed": "Session refresh failed. Please sign in again.", "txt_session_refresh_failed": "Session refresh failed. Please sign in again.",
"txt_session_refresh_temporarily_unavailable": "Session verification is temporarily unavailable. Your login is preserved and will retry.",
"txt_search_your_secure_vault": "Search your secure vault...", "txt_search_your_secure_vault": "Search your secure vault...",
"txt_search_items_count": "Search within {count} items...", "txt_search_items_count": "Search within {count} items...",
"txt_clear_search": "Clear search", "txt_clear_search": "Clear search",
@@ -1447,4 +1509,39 @@ const en: Record<string, string> = {
"txt_ip_address": "IP address" "txt_ip_address": "IP address"
}; };
Object.assign(en, {
"nav_password_security": "Password Security",
"txt_password_security": "Password Security Check",
"txt_password_security_privacy": "Passwords are checked locally in your browser. Only an anonymous hash prefix is sent to the breach database.",
"txt_check_password_security": "Start check",
"txt_checking_password_security": "Checking",
"txt_recheck_password_security": "Check again",
"txt_password_security_ready": "Your vault is ready for a security check.",
"txt_password_security_no_login": "There are no login passwords to check.",
"txt_password_security_manual": "The check only starts when you choose it. Results are kept until you refresh or your vault changes.",
"txt_password_security_no_login_help": "Add a login item with a password, then return here to check it.",
"txt_exposed_passwords": "Exposed",
"txt_reused_passwords": "Reused",
"txt_weak_passwords": "Weak",
"txt_passwords_checked": "Checked",
"txt_password_security_last_checked": "Last checked: {value}",
"txt_password_security_show_all": "Show all",
"txt_password_security_hide_all": "Hide all",
"txt_password_security_jump": "Go to item",
"txt_password_security_exposed_short": "Exposed {count} times",
"txt_password_security_weak_short": "Weak password",
"txt_password_security_reused_short": "Reused",
"txt_password_security_unavailable": "{count} password checks could not reach the breach database. They are not marked safe.",
"txt_password_security_not_checked": "Not checked",
"txt_password_exposed_count": "Found in {count} breaches",
"txt_password_reused_count": "Used {count} times",
"txt_weak_password": "Weak password",
"txt_no_password_risks": "No password risks found",
"txt_no_password_risks_in_filter": "No password risks in this category",
"txt_open_vault": "Open vault",
"txt_check_password_breach": "Check breach",
"txt_password_not_found_in_breaches": "Not found in the breach database",
"txt_password_security_check_failed": "The breach check could not be completed."
});
export default en; export default en;
+17
View File
@@ -7,10 +7,17 @@ const es: Record<string, string> = {
"nav_my_vault": "Mi bóveda", "nav_my_vault": "Mi bóveda",
"nav_vault_items": "Bóveda", "nav_vault_items": "Bóveda",
"nav_sends": "Envíos", "nav_sends": "Envíos",
"nav_generator": "Generador", "txt_password_generator": "Generador de contraseñas", "txt_password_generator_description": "Crea una contraseña única y segura localmente en este dispositivo.", "txt_generator_type": "Tipo de generador", "txt_passphrase": "Frase de contraseña", "txt_generated_password": "Contraseña generada", "txt_password_strength": "Seguridad", "txt_password_strength_weak": "Débil", "txt_password_strength_fair": "Regular", "txt_password_strength_good": "Buena", "txt_password_strength_strong": "Fuerte", "txt_generator_security_note": "La generación se realiza localmente. Tu contraseña nunca se envía al servidor.", "txt_generator_length": "Longitud", "txt_generator_character_types": "Tipos de caracteres", "txt_generator_uppercase": "Mayúsculas (A-Z)", "txt_generator_lowercase": "Minúsculas (a-z)", "txt_generator_numbers": "Números (0-9)", "txt_generator_special": "Caracteres especiales (!@#$%^&*)", "txt_generator_minimum": "Mínimo", "txt_generator_avoid_ambiguous": "Evitar caracteres ambiguos", "txt_generator_words": "Número de palabras", "txt_generator_separator": "Separador de palabras", "txt_generator_capitalize": "Usar mayúsculas", "txt_generator_include_number": "Incluir un número",
"txt_generated_value": "Valor generado", "txt_generator_character_count": "{count} caracteres", "txt_generator_pin": "PIN", "txt_generator_pin_description": "Genera localmente un PIN numérico con aleatoriedad criptográficamente segura.", "txt_generator_username": "Nombre de usuario", "txt_generator_plus_addressed_email": "Correo con direccionamiento plus", "txt_generator_catch_all_email": "Correo catch-all", "txt_generator_email": "Correo electrónico", "txt_generator_domain": "Dominio",
"txt_generator_word_list": "Lista de palabras", "txt_generator_eff_word_list": "Lista larga de EFF", "txt_generator_custom_word_list": "Lista personalizada", "txt_generator_custom_words": "Palabras personalizadas", "txt_generator_custom_words_placeholder": "Introduce al menos dos palabras separadas por espacios, comas o líneas",
"txt_generator_long_word_username": "Usuario de palabras largas", "txt_generator_custom_word": "Palabra auxiliar personalizada", "txt_generator_subdomain_email": "Correo de subdominio", "txt_generator_ssh_key": "Clave SSH", "txt_generator_ssh_generating": "Generando clave SSH…", "txt_generator_ssh_error": "Este navegador no admite la generación de claves SSH.", "txt_generator_copy_public_key": "Copiar clave pública", "txt_generator_ssh_security_note": "El par se genera localmente y no se envía. Guarda ahora la clave privada; NodeWarden no la conserva.", "txt_generator_public_key": "Clave pública", "txt_generator_private_key": "Clave privada — mantenla en secreto", "txt_generator_ssh_algorithm": "Algoritmo", "txt_generator_key_length": "Longitud de clave", "txt_generator_ssh_comment": "Comentario de clave pública", "txt_generator_ssh_rsa_description": "RSA es ampliamente compatible. Usa al menos 2048 bits; se recomiendan 4096.", "txt_generator_ssh_ed25519_description": "Ed25519 es rápido, compacto y recomendado para clientes SSH modernos.",
"txt_generator_email_alias": "Alias de correo", "txt_generator_email_type": "Tipo de alias", "txt_generator_email_required_hint": "Introduce tu correo o dominio para generar un alias.", "txt_generator_email_description": "Los datos permanecen en este dispositivo y solo se usan para formar el alias.", "txt_generator_long_word_username_description": "Combina una o más palabras largas en un nombre de usuario fácil de recordar.",
"nav_backup_strategy": "Copia de seguridad en la nube", "nav_backup_strategy": "Copia de seguridad en la nube",
"nav_import_export": "Importar y exportar", "nav_import_export": "Importar y exportar",
"nav_group_data_backup": "Datos y copias", "nav_group_data_backup": "Datos y copias",
"nav_group_management": "Gestión", "nav_group_management": "Gestión",
"nav_group_tools": "Herramientas",
"nav_group_system_management": "Administración del sistema",
"txt_settings_appearance": "Apariencia", "txt_settings_appearance": "Apariencia",
"txt_theme": "Tema", "txt_theme": "Tema",
"txt_use_system_theme": "Usar tema del sistema", "txt_use_system_theme": "Usar tema del sistema",
@@ -508,6 +515,7 @@ const es: Record<string, string> = {
"txt_create_account": "Crear cuenta", "txt_create_account": "Crear cuenta",
"txt_registering": "Creando cuenta...", "txt_registering": "Creando cuenta...",
"txt_register_failed": "Error al registrarse", "txt_register_failed": "Error al registrarse",
"txt_web_crypto_unavailable": "La criptografía segura del navegador no está disponible. Abra NodeWarden mediante HTTPS en un navegador compatible.",
"txt_create_folder": "Crear carpeta", "txt_create_folder": "Crear carpeta",
"txt_create_folder_failed": "Error al crear carpeta", "txt_create_folder_failed": "Error al crear carpeta",
"txt_create_item_failed": "Error al crear elemento", "txt_create_item_failed": "Error al crear elemento",
@@ -978,6 +986,7 @@ const es: Record<string, string> = {
"txt_save_profile_failed": "Error al guardar perfil", "txt_save_profile_failed": "Error al guardar perfil",
"txt_search_sends": "Buscar envíos...", "txt_search_sends": "Buscar envíos...",
"txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.", "txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.",
"txt_session_refresh_temporarily_unavailable": "La sesión no se puede verificar temporalmente. Tu inicio de sesión se conserva y se volverá a intentar.",
"txt_search_your_secure_vault": "Buscar en su bóveda segura...", "txt_search_your_secure_vault": "Buscar en su bóveda segura...",
"txt_search_items_count": "Buscar entre {count} elementos...", "txt_search_items_count": "Buscar entre {count} elementos...",
"txt_clear_search": "Limpiar búsqueda", "txt_clear_search": "Limpiar búsqueda",
@@ -1447,4 +1456,12 @@ const es: Record<string, string> = {
"txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública" "txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública"
}; };
Object.assign(es, {
"nav_password_security": "Seguridad de contraseñas", "txt_password_security": "Comprobación de seguridad", "txt_password_security_privacy": "Las contraseñas se comprueban localmente. Solo se envía un prefijo de hash anónimo a la base de filtraciones al iniciar la comprobación.", "txt_check_password_security": "Iniciar comprobación", "txt_checking_password_security": "Comprobando", "txt_recheck_password_security": "Comprobar de nuevo", "txt_password_security_ready": "Tu bóveda está lista para una comprobación de seguridad.", "txt_password_security_no_login": "No hay contraseñas de inicio de sesión para comprobar.", "txt_password_security_manual": "La comprobación solo empieza cuando la eliges. Los resultados se conservan solo en esta página.", "txt_password_security_no_login_help": "Añade un inicio de sesión con contraseña y vuelve aquí para comprobarlo.", "txt_exposed_passwords": "Filtradas", "txt_reused_passwords": "Reutilizadas", "txt_weak_passwords": "Débiles", "txt_passwords_checked": "Comprobadas", "txt_password_security_unavailable": "{count} comprobaciones no pudieron acceder a la base de filtraciones. No se marcan como seguras.", "txt_password_security_not_checked": "Sin comprobar", "txt_password_exposed_count": "Encontrada en {count} filtraciones", "txt_password_reused_count": "Usada {count} veces", "txt_weak_password": "Contraseña débil", "txt_no_password_risks": "No se encontraron riesgos de contraseña", "txt_open_vault": "Abrir bóveda", "txt_check_password_breach": "Comprobar filtración", "txt_password_not_found_in_breaches": "No encontrada en la base de filtraciones", "txt_password_security_check_failed": "No se pudo completar la comprobación de filtraciones."
});
Object.assign(es, { "txt_password_security_last_checked": "Última comprobación: {value}" });
Object.assign(es, { "txt_no_password_risks_in_filter": "No hay riesgos de contraseña en esta categoría" });
Object.assign(es, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default es; export default es;
+17
View File
@@ -7,10 +7,17 @@ const fi: Record<string, string> = {
"nav_my_vault": "Oma holvi", "nav_my_vault": "Oma holvi",
"nav_vault_items": "Holvi", "nav_vault_items": "Holvi",
"nav_sends": "Lähetykset", "nav_sends": "Lähetykset",
"nav_generator": "Luoja", "txt_password_generator": "Salasanageneraattori", "txt_password_generator_description": "Luo vahva ja yksilöllinen salasana paikallisesti tällä laitteella.", "txt_generator_type": "Generaattorin tyyppi", "txt_passphrase": "Salalause", "txt_generated_password": "Luotu salasana", "txt_password_strength": "Vahvuus", "txt_password_strength_weak": "Heikko", "txt_password_strength_fair": "Kohtalainen", "txt_password_strength_good": "Hyvä", "txt_password_strength_strong": "Vahva", "txt_generator_security_note": "Generointi tapahtuu paikallisesti. Salasanaa ei koskaan lähetetä palvelimelle.", "txt_generator_length": "Pituus", "txt_generator_character_types": "Merkkityypit", "txt_generator_uppercase": "Isot kirjaimet (A-Z)", "txt_generator_lowercase": "Pienet kirjaimet (a-z)", "txt_generator_numbers": "Numerot (0-9)", "txt_generator_special": "Erikoismerkit (!@#$%^&*)", "txt_generator_minimum": "Vähintään", "txt_generator_avoid_ambiguous": "Vältä epäselviä merkkejä", "txt_generator_words": "Sanojen määrä", "txt_generator_separator": "Sanaerotin", "txt_generator_capitalize": "Iso alkukirjain", "txt_generator_include_number": "Sisällytä numero",
"txt_generated_value": "Luotu arvo", "txt_generator_character_count": "{count} merkkiä", "txt_generator_pin": "PIN", "txt_generator_pin_description": "Luo numeerisen PIN-koodin paikallisesti kryptografisesti turvallisella satunnaisuudella.", "txt_generator_username": "Käyttäjänimi", "txt_generator_plus_addressed_email": "Plus-osoitteinen sähköposti", "txt_generator_catch_all_email": "Catch-all-sähköposti", "txt_generator_email": "Sähköpostiosoite", "txt_generator_domain": "Verkkotunnus",
"txt_generator_word_list": "Sanalista", "txt_generator_eff_word_list": "Pitkä EFF-sanalista", "txt_generator_custom_word_list": "Oma sanalista", "txt_generator_custom_words": "Omat sanat", "txt_generator_custom_words_placeholder": "Anna vähintään kaksi sanaa välilyönnein, pilkuin tai riveittäin",
"txt_generator_long_word_username": "Pitkäsanainen käyttäjänimi", "txt_generator_custom_word": "Oma muistisanaa", "txt_generator_subdomain_email": "Aliverkkotunnuksen sähköposti", "txt_generator_ssh_key": "SSH-avain", "txt_generator_ssh_generating": "Luodaan SSH-avainta…", "txt_generator_ssh_error": "Selain ei tue SSH-avaimen luontia.", "txt_generator_copy_public_key": "Kopioi julkinen avain", "txt_generator_ssh_security_note": "Avainpari luodaan paikallisesti eikä sitä lähetetä. Tallenna yksityinen avain nyt; NodeWarden ei säilytä sitä.", "txt_generator_public_key": "Julkinen avain", "txt_generator_private_key": "Yksityinen avain — pidä salassa", "txt_generator_ssh_algorithm": "Algoritmi", "txt_generator_key_length": "Avaimen pituus", "txt_generator_ssh_comment": "Julkisen avaimen kommentti", "txt_generator_ssh_rsa_description": "RSA on laajasti yhteensopiva. Käytä vähintään 2048 bittiä; 4096 on suositus.", "txt_generator_ssh_ed25519_description": "Ed25519 on nopea ja kompakti sekä suositeltu moderneille SSH-asiakkaille.",
"txt_generator_email_alias": "Sähköpostialias", "txt_generator_email_type": "Aliaksen tyyppi", "txt_generator_email_required_hint": "Anna sähköpostiosoite tai verkkotunnus aliaksen luomiseksi.", "txt_generator_email_description": "Syöte pysyy tällä laitteella ja sitä käytetään vain aliaksen muodostamiseen.", "txt_generator_long_word_username_description": "Yhdistä yksi tai useampi pitkä sana helposti muistettavaksi käyttäjänimeksi.",
"nav_backup_strategy": "Pilvivarmuuskopiointi", "nav_backup_strategy": "Pilvivarmuuskopiointi",
"nav_import_export": "Tuonti ja Vienti", "nav_import_export": "Tuonti ja Vienti",
"nav_group_data_backup": "Data & Varmuuskopiointi", "nav_group_data_backup": "Data & Varmuuskopiointi",
"nav_group_management": "Hallinta", "nav_group_management": "Hallinta",
"nav_group_tools": "Työkalut",
"nav_group_system_management": "Järjestelmän hallinta",
"txt_settings_appearance": "Ulkoasu", "txt_settings_appearance": "Ulkoasu",
"txt_theme": "Teema", "txt_theme": "Teema",
"txt_use_system_theme": "Käytä järjestelmän teemaa", "txt_use_system_theme": "Käytä järjestelmän teemaa",
@@ -508,6 +515,7 @@ const fi: Record<string, string> = {
"txt_create_account": "Luo tili", "txt_create_account": "Luo tili",
"txt_registering": "Luodaan tiliä...", "txt_registering": "Luodaan tiliä...",
"txt_register_failed": "Rekisteröinti epäonnistui", "txt_register_failed": "Rekisteröinti epäonnistui",
"txt_web_crypto_unavailable": "Selaimen suojattu salaus ei ole käytettävissä. Avaa NodeWarden HTTPS-yhteydellä tuetussa selaimessa.",
"txt_create_folder": "Luo kansio", "txt_create_folder": "Luo kansio",
"txt_create_folder_failed": "Kansion luonti epäonnistui", "txt_create_folder_failed": "Kansion luonti epäonnistui",
"txt_create_item_failed": "Nimikkeen luonti epäonnistui", "txt_create_item_failed": "Nimikkeen luonti epäonnistui",
@@ -978,6 +986,7 @@ const fi: Record<string, string> = {
"txt_save_profile_failed": "Profiilin tallennus epäonnistui", "txt_save_profile_failed": "Profiilin tallennus epäonnistui",
"txt_search_sends": "Hae lähetyksiä...", "txt_search_sends": "Hae lähetyksiä...",
"txt_session_refresh_failed": "Istunnon päivitys epäonnistui. Kirjaudu sisään uudelleen.", "txt_session_refresh_failed": "Istunnon päivitys epäonnistui. Kirjaudu sisään uudelleen.",
"txt_session_refresh_temporarily_unavailable": "Istuntoa ei voida tarkistaa juuri nyt. Kirjautuminen säilytetään ja tarkistusta yritetään uudelleen.",
"txt_search_your_secure_vault": "Hae turvallisesta holvistasi...", "txt_search_your_secure_vault": "Hae turvallisesta holvistasi...",
"txt_search_items_count": "Hae {count} nimikkeen joukosta...", "txt_search_items_count": "Hae {count} nimikkeen joukosta...",
"txt_clear_search": "Tyhjennä haku", "txt_clear_search": "Tyhjennä haku",
@@ -1447,4 +1456,12 @@ const fi: Record<string, string> = {
"txt_ip_address": "IP-osoite" "txt_ip_address": "IP-osoite"
}; };
Object.assign(fi, {
"nav_password_security": "Salasanasuojaus", "txt_password_security": "Salasanojen turvatarkistus", "txt_password_security_privacy": "Salasanat tarkistetaan paikallisesti. Vain anonyymi hajautteen alku lähetetään vuototietokantaan tarkistuksen alkaessa.", "txt_check_password_security": "Aloita tarkistus", "txt_checking_password_security": "Tarkistetaan", "txt_recheck_password_security": "Tarkista uudelleen", "txt_password_security_ready": "Holvisi on valmis turvatarkistukseen.", "txt_password_security_no_login": "Tarkistettavia kirjautumissalasanoja ei ole.", "txt_password_security_manual": "Tarkistus käynnistyy vain valinnastasi. Tulokset säilyvät vain tällä sivulla.", "txt_password_security_no_login_help": "Lisää kirjautuminen salasanalla ja palaa sitten tarkistamaan se.", "txt_exposed_passwords": "Vuotaneet", "txt_reused_passwords": "Uudelleenkäytetyt", "txt_weak_passwords": "Heikot", "txt_passwords_checked": "Tarkistettu", "txt_password_security_unavailable": "{count} salasanatarkistusta ei tavoittanut vuototietokantaa. Niitä ei merkitä turvallisiksi.", "txt_password_security_not_checked": "Ei tarkistettu", "txt_password_exposed_count": "Löytyi {count} vuodosta", "txt_password_reused_count": "Käytetty {count} kertaa", "txt_weak_password": "Heikko salasana", "txt_no_password_risks": "Salasanariskejä ei löytynyt", "txt_open_vault": "Avaa holvi", "txt_check_password_breach": "Tarkista vuoto", "txt_password_not_found_in_breaches": "Ei löytynyt vuototietokannasta", "txt_password_security_check_failed": "Vuototarkistusta ei voitu suorittaa."
});
Object.assign(fi, { "txt_password_security_last_checked": "Tarkistettu viimeksi: {value}" });
Object.assign(fi, { "txt_no_password_risks_in_filter": "Tässä luokassa ei ole salasanojen riskejä" });
Object.assign(fi, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default fi; export default fi;
+17
View File
@@ -7,10 +7,17 @@ const fr: Record<string, string> = {
"nav_my_vault": "Mon coffre-fort", "nav_my_vault": "Mon coffre-fort",
"nav_vault_items": "Coffre-fort", "nav_vault_items": "Coffre-fort",
"nav_sends": "Envois", "nav_sends": "Envois",
"nav_generator": "Générateur", "txt_password_generator": "Générateur de mots de passe", "txt_password_generator_description": "Créez un mot de passe fort et unique localement sur cet appareil.", "txt_generator_type": "Type de générateur", "txt_passphrase": "Phrase secrète", "txt_generated_password": "Mot de passe généré", "txt_password_strength": "Robustesse", "txt_password_strength_weak": "Faible", "txt_password_strength_fair": "Correcte", "txt_password_strength_good": "Bonne", "txt_password_strength_strong": "Forte", "txt_generator_security_note": "La génération est locale. Votre mot de passe n'est jamais envoyé au serveur.", "txt_generator_length": "Longueur", "txt_generator_character_types": "Types de caractères", "txt_generator_uppercase": "Majuscules (A-Z)", "txt_generator_lowercase": "Minuscules (a-z)", "txt_generator_numbers": "Chiffres (0-9)", "txt_generator_special": "Caractères spéciaux (!@#$%^&*)", "txt_generator_minimum": "Minimum", "txt_generator_avoid_ambiguous": "Éviter les caractères ambigus", "txt_generator_words": "Nombre de mots", "txt_generator_separator": "Séparateur de mots", "txt_generator_capitalize": "Mettre une majuscule", "txt_generator_include_number": "Inclure un chiffre",
"txt_generated_value": "Valeur générée", "txt_generator_character_count": "{count} caractères", "txt_generator_pin": "PIN", "txt_generator_pin_description": "Génère localement un PIN numérique avec un aléa cryptographiquement sûr.", "txt_generator_username": "Nom dutilisateur", "txt_generator_plus_addressed_email": "Adresse e-mail avec plus", "txt_generator_catch_all_email": "Adresse e-mail catch-all", "txt_generator_email": "Adresse e-mail", "txt_generator_domain": "Domaine",
"txt_generator_word_list": "Liste de mots", "txt_generator_eff_word_list": "Liste longue EFF", "txt_generator_custom_word_list": "Liste personnalisée", "txt_generator_custom_words": "Mots personnalisés", "txt_generator_custom_words_placeholder": "Saisissez au moins deux mots séparés par des espaces, virgules ou lignes",
"txt_generator_long_word_username": "Identifiant à mots longs", "txt_generator_custom_word": "Mot aide-mémoire personnalisé", "txt_generator_subdomain_email": "E-mail de sous-domaine", "txt_generator_ssh_key": "Clé SSH", "txt_generator_ssh_generating": "Génération de la clé SSH…", "txt_generator_ssh_error": "Ce navigateur ne prend pas en charge la génération de clés SSH.", "txt_generator_copy_public_key": "Copier la clé publique", "txt_generator_ssh_security_note": "La paire est générée localement et jamais envoyée. Enregistrez la clé privée maintenant ; NodeWarden ne la conserve pas.", "txt_generator_public_key": "Clé publique", "txt_generator_private_key": "Clé privée — à garder secrète", "txt_generator_ssh_algorithm": "Algorithme", "txt_generator_key_length": "Longueur de clé", "txt_generator_ssh_comment": "Commentaire de clé publique", "txt_generator_ssh_rsa_description": "RSA est largement compatible. Utilisez au moins 2048 bits ; 4096 sont recommandés.", "txt_generator_ssh_ed25519_description": "Ed25519 est rapide, compact et recommandé pour les clients SSH modernes.",
"txt_generator_email_alias": "Alias e-mail", "txt_generator_email_type": "Type dalias", "txt_generator_email_required_hint": "Saisissez votre adresse e-mail ou domaine pour générer un alias.", "txt_generator_email_description": "La saisie reste sur cet appareil et sert uniquement à composer lalias.", "txt_generator_long_word_username_description": "Combinez un ou plusieurs mots longs en un identifiant mémorable.",
"nav_backup_strategy": "Sauvegarde Cloud", "nav_backup_strategy": "Sauvegarde Cloud",
"nav_import_export": "Importer & Exporter", "nav_import_export": "Importer & Exporter",
"nav_group_data_backup": "Données & Sauvegarde", "nav_group_data_backup": "Données & Sauvegarde",
"nav_group_management": "Gestion", "nav_group_management": "Gestion",
"nav_group_tools": "Outils",
"nav_group_system_management": "Administration système",
"txt_settings_appearance": "Apparence", "txt_settings_appearance": "Apparence",
"txt_theme": "Thème", "txt_theme": "Thème",
"txt_use_system_theme": "Utiliser le thème du système", "txt_use_system_theme": "Utiliser le thème du système",
@@ -508,6 +515,7 @@ const fr: Record<string, string> = {
"txt_create_account": "Créer un compte", "txt_create_account": "Créer un compte",
"txt_registering": "Création du compte...", "txt_registering": "Création du compte...",
"txt_register_failed": "L'inscription a échoué", "txt_register_failed": "L'inscription a échoué",
"txt_web_crypto_unavailable": "La cryptographie sécurisée du navigateur nest pas disponible. Ouvrez NodeWarden via HTTPS dans un navigateur compatible.",
"txt_create_folder": "Créer un dossier", "txt_create_folder": "Créer un dossier",
"txt_create_folder_failed": "La création du dossier a échoué", "txt_create_folder_failed": "La création du dossier a échoué",
"txt_create_item_failed": "La création de l'élément a échoué", "txt_create_item_failed": "La création de l'élément a échoué",
@@ -978,6 +986,7 @@ const fr: Record<string, string> = {
"txt_save_profile_failed": "L'enregistrement du profil a échoué", "txt_save_profile_failed": "L'enregistrement du profil a échoué",
"txt_search_sends": "Rechercher des envois...", "txt_search_sends": "Rechercher des envois...",
"txt_session_refresh_failed": "L'actualisation de la session a échoué. Veuillez vous reconnecter.", "txt_session_refresh_failed": "L'actualisation de la session a échoué. Veuillez vous reconnecter.",
"txt_session_refresh_temporarily_unavailable": "La session ne peut pas être vérifiée temporairement. Votre connexion est conservée et une nouvelle tentative sera effectuée.",
"txt_search_your_secure_vault": "Recherchez dans votre coffre-fort sécurisé...", "txt_search_your_secure_vault": "Recherchez dans votre coffre-fort sécurisé...",
"txt_search_items_count": "Rechercher parmi {count} éléments...", "txt_search_items_count": "Rechercher parmi {count} éléments...",
"txt_clear_search": "Effacer la recherche", "txt_clear_search": "Effacer la recherche",
@@ -1447,4 +1456,12 @@ const fr: Record<string, string> = {
"txt_ip_address": "Adresse IP" "txt_ip_address": "Adresse IP"
}; };
Object.assign(fr, {
"nav_password_security": "Sécurité des mots de passe", "txt_password_security": "Vérification de sécurité", "txt_password_security_privacy": "Les mots de passe sont vérifiés localement. Seul un préfixe de hachage anonyme est envoyé à la base de fuites après le démarrage.", "txt_check_password_security": "Lancer la vérification", "txt_checking_password_security": "Vérification", "txt_recheck_password_security": "Vérifier à nouveau", "txt_password_security_ready": "Votre coffre est prêt pour une vérification de sécurité.", "txt_password_security_no_login": "Aucun mot de passe de connexion à vérifier.", "txt_password_security_manual": "La vérification ne démarre que sur votre demande. Les résultats restent sur cette page.", "txt_password_security_no_login_help": "Ajoutez une connexion avec mot de passe, puis revenez ici pour la vérifier.", "txt_exposed_passwords": "Exposés", "txt_reused_passwords": "Réutilisés", "txt_weak_passwords": "Faibles", "txt_passwords_checked": "Vérifiés", "txt_password_security_unavailable": "{count} vérifications n'ont pas pu joindre la base de fuites. Elles ne sont pas marquées comme sûres.", "txt_password_security_not_checked": "Non vérifié", "txt_password_exposed_count": "Trouvé dans {count} fuites", "txt_password_reused_count": "Utilisé {count} fois", "txt_weak_password": "Mot de passe faible", "txt_no_password_risks": "Aucun risque de mot de passe détecté", "txt_open_vault": "Ouvrir le coffre", "txt_check_password_breach": "Vérifier la fuite", "txt_password_not_found_in_breaches": "Introuvable dans la base de fuites", "txt_password_security_check_failed": "La vérification de fuite n'a pas pu être terminée."
});
Object.assign(fr, { "txt_password_security_last_checked": "Dernière vérification : {value}" });
Object.assign(fr, { "txt_no_password_risks_in_filter": "Aucun risque de mot de passe dans cette catégorie" });
Object.assign(fr, { "txt_password_security_show_all": "Tout afficher", "txt_password_security_hide_all": "Tout masquer", "txt_password_security_jump": "Ouvrir", "txt_password_security_exposed_short": "Exposé {count} fois", "txt_password_security_weak_short": "Mot de passe faible", "txt_password_security_reused_short": "Réutilisé" });
export default fr; export default fr;
+17
View File
@@ -7,10 +7,17 @@ const it: Record<string, string> = {
"nav_my_vault": "La mia Cassaforte", "nav_my_vault": "La mia Cassaforte",
"nav_vault_items": "Cassaforte", "nav_vault_items": "Cassaforte",
"nav_sends": "Invii", "nav_sends": "Invii",
"nav_generator": "Generatore", "txt_password_generator": "Generatore di password", "txt_password_generator_description": "Crea una password forte e univoca localmente su questo dispositivo.", "txt_generator_type": "Tipo di generatore", "txt_passphrase": "Frase segreta", "txt_generated_password": "Password generata", "txt_password_strength": "Robustezza", "txt_password_strength_weak": "Debole", "txt_password_strength_fair": "Discreta", "txt_password_strength_good": "Buona", "txt_password_strength_strong": "Forte", "txt_generator_security_note": "La generazione avviene localmente. La password non viene mai inviata al server.", "txt_generator_length": "Lunghezza", "txt_generator_character_types": "Tipi di caratteri", "txt_generator_uppercase": "Maiuscole (A-Z)", "txt_generator_lowercase": "Minuscole (a-z)", "txt_generator_numbers": "Numeri (0-9)", "txt_generator_special": "Caratteri speciali (!@#$%^&*)", "txt_generator_minimum": "Minimo", "txt_generator_avoid_ambiguous": "Evita caratteri ambigui", "txt_generator_words": "Numero di parole", "txt_generator_separator": "Separatore di parole", "txt_generator_capitalize": "Iniziale maiuscola", "txt_generator_include_number": "Includi un numero",
"txt_generated_value": "Valore generato", "txt_generator_character_count": "{count} caratteri", "txt_generator_pin": "Codice PIN", "txt_generator_pin_description": "Genera localmente un PIN numerico con casualità crittograficamente sicura.", "txt_generator_username": "Nome utente", "txt_generator_plus_addressed_email": "Email con indirizzamento plus", "txt_generator_catch_all_email": "Email catch-all", "txt_generator_email": "Indirizzo email", "txt_generator_domain": "Dominio",
"txt_generator_word_list": "Elenco parole", "txt_generator_eff_word_list": "Elenco lungo EFF", "txt_generator_custom_word_list": "Elenco personalizzato", "txt_generator_custom_words": "Parole personalizzate", "txt_generator_custom_words_placeholder": "Inserisci almeno due parole separate da spazi, virgole o righe",
"txt_generator_long_word_username": "Nome utente a parole lunghe", "txt_generator_custom_word": "Parola mnemonica personalizzata", "txt_generator_subdomain_email": "Email con sottodominio", "txt_generator_ssh_key": "Chiave SSH", "txt_generator_ssh_generating": "Generazione chiave SSH…", "txt_generator_ssh_error": "Questo browser non supporta la generazione di chiavi SSH.", "txt_generator_copy_public_key": "Copia chiave pubblica", "txt_generator_ssh_security_note": "La coppia viene generata localmente e mai inviata. Salva ora la chiave privata; NodeWarden non la conserva.", "txt_generator_public_key": "Chiave pubblica", "txt_generator_private_key": "Chiave privata — mantienila segreta", "txt_generator_ssh_algorithm": "Algoritmo", "txt_generator_key_length": "Lunghezza chiave", "txt_generator_ssh_comment": "Commento chiave pubblica", "txt_generator_ssh_rsa_description": "RSA è ampiamente compatibile. Usa almeno 2048 bit; 4096 sono consigliati.", "txt_generator_ssh_ed25519_description": "Ed25519 è veloce, compatto e consigliato per client SSH moderni.",
"txt_generator_email_alias": "Alias email", "txt_generator_email_type": "Tipo di alias", "txt_generator_email_required_hint": "Inserisci lindirizzo email o il dominio per generare un alias.", "txt_generator_email_description": "I dati restano su questo dispositivo e servono solo a comporre lalias.", "txt_generator_long_word_username_description": "Combina una o più parole lunghe in un nome utente facile da ricordare.",
"nav_backup_strategy": "Backup su Cloud", "nav_backup_strategy": "Backup su Cloud",
"nav_import_export": "Importa ed Esporta", "nav_import_export": "Importa ed Esporta",
"nav_group_data_backup": "Dati e Backup", "nav_group_data_backup": "Dati e Backup",
"nav_group_management": "Gestione", "nav_group_management": "Gestione",
"nav_group_tools": "Strumenti",
"nav_group_system_management": "Gestione del sistema",
"txt_settings_appearance": "Aspetto", "txt_settings_appearance": "Aspetto",
"txt_theme": "Tema", "txt_theme": "Tema",
"txt_use_system_theme": "Usa il tema del sistema", "txt_use_system_theme": "Usa il tema del sistema",
@@ -508,6 +515,7 @@ const it: Record<string, string> = {
"txt_create_account": "Crea Account", "txt_create_account": "Crea Account",
"txt_registering": "Creazione Account in corso...", "txt_registering": "Creazione Account in corso...",
"txt_register_failed": "Registrazione fallita", "txt_register_failed": "Registrazione fallita",
"txt_web_crypto_unavailable": "La crittografia sicura del browser non è disponibile. Apri NodeWarden tramite HTTPS in un browser supportato.",
"txt_create_folder": "Crea cartella", "txt_create_folder": "Crea cartella",
"txt_create_folder_failed": "Impossibile creare la cartella", "txt_create_folder_failed": "Impossibile creare la cartella",
"txt_create_item_failed": "Impossibile creare l'elemento", "txt_create_item_failed": "Impossibile creare l'elemento",
@@ -978,6 +986,7 @@ const it: Record<string, string> = {
"txt_save_profile_failed": "Salvataggio Profilo fallito", "txt_save_profile_failed": "Salvataggio Profilo fallito",
"txt_search_sends": "Cerca invii...", "txt_search_sends": "Cerca invii...",
"txt_session_refresh_failed": "Aggiornamento della sessione fallito. Per favore, accedi di nuovo.", "txt_session_refresh_failed": "Aggiornamento della sessione fallito. Per favore, accedi di nuovo.",
"txt_session_refresh_temporarily_unavailable": "La sessione non può essere verificata temporaneamente. L'accesso viene mantenuto e verrà effettuato un nuovo tentativo.",
"txt_search_your_secure_vault": "Cerca nella tua cassaforte sicura...", "txt_search_your_secure_vault": "Cerca nella tua cassaforte sicura...",
"txt_search_items_count": "Cerca in {count} elementi...", "txt_search_items_count": "Cerca in {count} elementi...",
"txt_clear_search": "Cancella Ricerca", "txt_clear_search": "Cancella Ricerca",
@@ -1447,4 +1456,12 @@ const it: Record<string, string> = {
"txt_ip_address": "Indirizzo IP" "txt_ip_address": "Indirizzo IP"
}; };
Object.assign(it, {
"nav_password_security": "Sicurezza password", "txt_password_security": "Controllo sicurezza password", "txt_password_security_privacy": "Le password vengono controllate localmente. Solo un prefisso hash anonimo viene inviato al database delle violazioni dopo l'avvio.", "txt_check_password_security": "Avvia controllo", "txt_checking_password_security": "Controllo in corso", "txt_recheck_password_security": "Controlla di nuovo", "txt_password_security_ready": "Il tuo archivio è pronto per un controllo di sicurezza.", "txt_password_security_no_login": "Non ci sono password di accesso da controllare.", "txt_password_security_manual": "Il controllo parte solo quando lo scegli. I risultati restano solo in questa pagina.", "txt_password_security_no_login_help": "Aggiungi un accesso con password, quindi torna qui per controllarlo.", "txt_exposed_passwords": "Esposte", "txt_reused_passwords": "Riutilizzate", "txt_weak_passwords": "Deboli", "txt_passwords_checked": "Controllate", "txt_password_security_unavailable": "{count} controlli non hanno raggiunto il database delle violazioni. Non sono contrassegnati come sicuri.", "txt_password_security_not_checked": "Non controllata", "txt_password_exposed_count": "Trovata in {count} violazioni", "txt_password_reused_count": "Usata {count} volte", "txt_weak_password": "Password debole", "txt_no_password_risks": "Nessun rischio password trovato", "txt_open_vault": "Apri archivio", "txt_check_password_breach": "Controlla violazione", "txt_password_not_found_in_breaches": "Non trovata nel database delle violazioni", "txt_password_security_check_failed": "Impossibile completare il controllo delle violazioni."
});
Object.assign(it, { "txt_password_security_last_checked": "Ultimo controllo: {value}" });
Object.assign(it, { "txt_no_password_risks_in_filter": "Nessun rischio password in questa categoria" });
Object.assign(it, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default it; export default it;
+17
View File
@@ -8,10 +8,17 @@ const ru: Record<string, string> = {
"nav_my_vault": "Мое хранилище", "nav_my_vault": "Мое хранилище",
"nav_vault_items": "Хранилище", "nav_vault_items": "Хранилище",
"nav_sends": "Отправляет", "nav_sends": "Отправляет",
"nav_generator": "Генератор", "txt_password_generator": "Генератор паролей", "txt_password_generator_description": "Создайте надежный уникальный пароль локально на этом устройстве.", "txt_generator_type": "Тип генератора", "txt_passphrase": "Парольная фраза", "txt_generated_password": "Созданный пароль", "txt_password_strength": "Надежность", "txt_password_strength_weak": "Слабый", "txt_password_strength_fair": "Средний", "txt_password_strength_good": "Хороший", "txt_password_strength_strong": "Надежный", "txt_generator_security_note": "Генерация выполняется локально. Пароль никогда не отправляется на сервер.", "txt_generator_length": "Длина", "txt_generator_character_types": "Типы символов", "txt_generator_uppercase": "Заглавные буквы (A-Z)", "txt_generator_lowercase": "Строчные буквы (a-z)", "txt_generator_numbers": "Цифры (0-9)", "txt_generator_special": "Специальные символы (!@#$%^&*)", "txt_generator_minimum": "Минимум", "txt_generator_avoid_ambiguous": "Исключить похожие символы", "txt_generator_words": "Количество слов", "txt_generator_separator": "Разделитель слов", "txt_generator_capitalize": "С заглавной буквы", "txt_generator_include_number": "Добавить число",
"txt_generated_value": "Созданное значение", "txt_generator_character_count": "Символов: {count}", "txt_generator_pin": "PIN-код", "txt_generator_pin_description": "Создаёт числовой PIN локально с криптографически стойкой случайностью.", "txt_generator_username": "Имя пользователя", "txt_generator_plus_addressed_email": "Email с плюс-адресацией", "txt_generator_catch_all_email": "Catch-all email", "txt_generator_email": "Адрес электронной почты", "txt_generator_domain": "Домен",
"txt_generator_word_list": "Список слов", "txt_generator_eff_word_list": "Длинный список EFF", "txt_generator_custom_word_list": "Свой список слов", "txt_generator_custom_words": "Свои слова", "txt_generator_custom_words_placeholder": "Введите не менее двух слов через пробел, запятую или с новой строки",
"txt_generator_long_word_username": "Имя из длинных слов", "txt_generator_custom_word": "Своё мнемоническое слово", "txt_generator_subdomain_email": "Email с поддоменом", "txt_generator_ssh_key": "SSH-ключ", "txt_generator_ssh_generating": "Создание SSH-ключа…", "txt_generator_ssh_error": "Этот браузер не поддерживает создание SSH-ключей.", "txt_generator_copy_public_key": "Копировать открытый ключ", "txt_generator_ssh_security_note": "Пара создаётся локально и не отправляется. Сохраните закрытый ключ сейчас; NodeWarden его не хранит.", "txt_generator_public_key": "Открытый ключ", "txt_generator_private_key": "Закрытый ключ — храните в тайне", "txt_generator_ssh_algorithm": "Алгоритм", "txt_generator_key_length": "Длина ключа", "txt_generator_ssh_comment": "Комментарий открытого ключа", "txt_generator_ssh_rsa_description": "RSA широко совместим. Используйте минимум 2048 бит; рекомендуется 4096.", "txt_generator_ssh_ed25519_description": "Ed25519 быстр, компактен и рекомендуется для современных SSH-клиентов.",
"txt_generator_email_alias": "Почтовый псевдоним", "txt_generator_email_type": "Тип псевдонима", "txt_generator_email_required_hint": "Введите адрес электронной почты или домен, чтобы создать псевдоним.", "txt_generator_email_description": "Введённые данные остаются на устройстве и используются только для создания псевдонима.", "txt_generator_long_word_username_description": "Объедините одно или несколько длинных слов в запоминающееся имя пользователя.",
"nav_backup_strategy": "Облачное резервное копирование", "nav_backup_strategy": "Облачное резервное копирование",
"nav_import_export": "Импорт и экспорт", "nav_import_export": "Импорт и экспорт",
"nav_group_data_backup": "Данные и резервные копии", "nav_group_data_backup": "Данные и резервные копии",
"nav_group_management": "Управление", "nav_group_management": "Управление",
"nav_group_tools": "Инструменты",
"nav_group_system_management": "Управление системой",
"txt_settings_appearance": "Внешний вид", "txt_settings_appearance": "Внешний вид",
"txt_theme": "Тема", "txt_theme": "Тема",
"txt_use_system_theme": "Использовать системную тему", "txt_use_system_theme": "Использовать системную тему",
@@ -508,6 +515,7 @@ const ru: Record<string, string> = {
"txt_create_account": "Создать учетную запись", "txt_create_account": "Создать учетную запись",
"txt_registering": "Создание учетной записи...", "txt_registering": "Создание учетной записи...",
"txt_register_failed": "Не удалось зарегистрироваться", "txt_register_failed": "Не удалось зарегистрироваться",
"txt_web_crypto_unavailable": "Безопасная криптография браузера недоступна. Откройте NodeWarden по HTTPS в поддерживаемом браузере.",
"txt_create_folder": "Создать папку", "txt_create_folder": "Создать папку",
"txt_create_folder_failed": "Создать папку не удалось", "txt_create_folder_failed": "Создать папку не удалось",
"txt_create_item_failed": "Создать элемент не удалось", "txt_create_item_failed": "Создать элемент не удалось",
@@ -978,6 +986,7 @@ const ru: Record<string, string> = {
"txt_save_profile_failed": "Сохранить профиль не удалось", "txt_save_profile_failed": "Сохранить профиль не удалось",
"txt_search_sends": "Поиск отправляет...", "txt_search_sends": "Поиск отправляет...",
"txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.", "txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.",
"txt_session_refresh_temporarily_unavailable": "Сеанс временно не удаётся проверить. Вход сохранён, проверка будет повторена.",
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...", "txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
"txt_search_items_count": "Поиск по {count} элементам...", "txt_search_items_count": "Поиск по {count} элементам...",
"txt_clear_search": "Очистить поиск", "txt_clear_search": "Очистить поиск",
@@ -1447,4 +1456,12 @@ const ru: Record<string, string> = {
"txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ" "txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ"
}; };
Object.assign(ru, {
"nav_password_security": "Безопасность паролей", "txt_password_security": "Проверка безопасности паролей", "txt_password_security_privacy": "Пароли проверяются локально. После запуска в базу утечек передаётся только анонимный префикс хеша.", "txt_check_password_security": "Начать проверку", "txt_checking_password_security": "Проверка", "txt_recheck_password_security": "Проверить снова", "txt_password_security_ready": "Ваше хранилище готово к проверке безопасности.", "txt_password_security_no_login": "Нет паролей для входа, доступных для проверки.", "txt_password_security_manual": "Проверка запускается только по вашему выбору. Результаты остаются только на этой странице.", "txt_password_security_no_login_help": "Добавьте запись входа с паролем и вернитесь сюда для проверки.", "txt_exposed_passwords": "Скомпрометированы", "txt_reused_passwords": "Повторно используются", "txt_weak_passwords": "Слабые", "txt_passwords_checked": "Проверено", "txt_password_security_unavailable": "{count} проверок не смогли обратиться к базе утечек. Они не помечены безопасными.", "txt_password_security_not_checked": "Не проверено", "txt_password_exposed_count": "Найдено в {count} утечках", "txt_password_reused_count": "Используется {count} раз", "txt_weak_password": "Слабый пароль", "txt_no_password_risks": "Рисков паролей не найдено", "txt_open_vault": "Открыть хранилище", "txt_check_password_breach": "Проверить утечку", "txt_password_not_found_in_breaches": "Не найден в базе утечек", "txt_password_security_check_failed": "Не удалось завершить проверку утечки."
});
Object.assign(ru, { "txt_password_security_last_checked": "Последняя проверка: {value}" });
Object.assign(ru, { "txt_no_password_risks_in_filter": "В этой категории нет рисков для паролей" });
Object.assign(ru, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default ru; export default ru;
+17
View File
@@ -7,10 +7,17 @@ const sv: Record<string, string> = {
"nav_my_vault": "Mitt valv", "nav_my_vault": "Mitt valv",
"nav_vault_items": "Valv", "nav_vault_items": "Valv",
"nav_sends": "Skickat", "nav_sends": "Skickat",
"nav_generator": "Generator", "txt_password_generator": "Lösenordsgenerator", "txt_password_generator_description": "Skapa ett starkt och unikt lösenord lokalt på den här enheten.", "txt_generator_type": "Generatortyp", "txt_passphrase": "Lösenfras", "txt_generated_password": "Genererat lösenord", "txt_password_strength": "Styrka", "txt_password_strength_weak": "Svagt", "txt_password_strength_fair": "Medel", "txt_password_strength_good": "Bra", "txt_password_strength_strong": "Starkt", "txt_generator_security_note": "Generering sker lokalt. Ditt lösenord skickas aldrig till servern.", "txt_generator_length": "Längd", "txt_generator_character_types": "Teckentyper", "txt_generator_uppercase": "Versaler (A-Z)", "txt_generator_lowercase": "Gemener (a-z)", "txt_generator_numbers": "Siffror (0-9)", "txt_generator_special": "Specialtecken (!@#$%^&*)", "txt_generator_minimum": "Minst", "txt_generator_avoid_ambiguous": "Undvik tvetydiga tecken", "txt_generator_words": "Antal ord", "txt_generator_separator": "Ordavgränsare", "txt_generator_capitalize": "Stor begynnelsebokstav", "txt_generator_include_number": "Inkludera en siffra",
"txt_generated_value": "Genererat värde", "txt_generator_character_count": "{count} tecken", "txt_generator_pin": "PIN-kod", "txt_generator_pin_description": "Skapar en numerisk PIN lokalt med kryptografiskt säker slumpning.", "txt_generator_username": "Användarnamn", "txt_generator_plus_addressed_email": "Plusadresserad e-post", "txt_generator_catch_all_email": "Catch-all-e-post", "txt_generator_email": "E-postadress", "txt_generator_domain": "Domän",
"txt_generator_word_list": "Ordlista", "txt_generator_eff_word_list": "Lång EFF-ordlista", "txt_generator_custom_word_list": "Egen ordlista", "txt_generator_custom_words": "Egna ord", "txt_generator_custom_words_placeholder": "Ange minst två ord åtskilda med blanksteg, kommatecken eller rader",
"txt_generator_long_word_username": "Användarnamn med långa ord", "txt_generator_custom_word": "Eget minnesord", "txt_generator_subdomain_email": "E-post med underdomän", "txt_generator_ssh_key": "SSH-nyckel", "txt_generator_ssh_generating": "Skapar SSH-nyckel…", "txt_generator_ssh_error": "Webbläsaren stöder inte skapande av SSH-nycklar.", "txt_generator_copy_public_key": "Kopiera offentlig nyckel", "txt_generator_ssh_security_note": "Nyckelparet skapas lokalt och skickas aldrig. Spara den privata nyckeln nu; NodeWarden behåller den inte.", "txt_generator_public_key": "Offentlig nyckel", "txt_generator_private_key": "Privat nyckel — håll den hemlig", "txt_generator_ssh_algorithm": "Algoritm", "txt_generator_key_length": "Nyckellängd", "txt_generator_ssh_comment": "Kommentar för offentlig nyckel", "txt_generator_ssh_rsa_description": "RSA har bred kompatibilitet. Använd minst 2048 bitar; 4096 rekommenderas.", "txt_generator_ssh_ed25519_description": "Ed25519 är snabb, kompakt och rekommenderas för moderna SSH-klienter.",
"txt_generator_email_alias": "E-postalias", "txt_generator_email_type": "Aliastyp", "txt_generator_email_required_hint": "Ange din e-postadress eller domän för att skapa ett alias.", "txt_generator_email_description": "Inmatningen stannar på enheten och används endast för att skapa aliaset.", "txt_generator_long_word_username_description": "Kombinera ett eller flera långa ord till ett minnesvärt användarnamn.",
"nav_backup_strategy": "Molnsäkerhetskopiering", "nav_backup_strategy": "Molnsäkerhetskopiering",
"nav_import_export": "Importera och Exportera", "nav_import_export": "Importera och Exportera",
"nav_group_data_backup": "Data och Säkerhetskopiering", "nav_group_data_backup": "Data och Säkerhetskopiering",
"nav_group_management": "Hantering", "nav_group_management": "Hantering",
"nav_group_tools": "Verktyg",
"nav_group_system_management": "Systemadministration",
"txt_settings_appearance": "Utseende", "txt_settings_appearance": "Utseende",
"txt_theme": "Tema", "txt_theme": "Tema",
"txt_use_system_theme": "Använd systemtema", "txt_use_system_theme": "Använd systemtema",
@@ -508,6 +515,7 @@ const sv: Record<string, string> = {
"txt_create_account": "Skapa konto", "txt_create_account": "Skapa konto",
"txt_registering": "Skapar konto...", "txt_registering": "Skapar konto...",
"txt_register_failed": "Registrering misslyckades", "txt_register_failed": "Registrering misslyckades",
"txt_web_crypto_unavailable": "Säker webbläsarkryptografi är inte tillgänglig. Öppna NodeWarden via HTTPS i en webbläsare som stöds.",
"txt_create_folder": "Skapa mapp", "txt_create_folder": "Skapa mapp",
"txt_create_folder_failed": "Misslyckades med att skapa mapp", "txt_create_folder_failed": "Misslyckades med att skapa mapp",
"txt_create_item_failed": "Misslyckades med att skapa objekt", "txt_create_item_failed": "Misslyckades med att skapa objekt",
@@ -978,6 +986,7 @@ const sv: Record<string, string> = {
"txt_save_profile_failed": "Misslyckades med att spara profil", "txt_save_profile_failed": "Misslyckades med att spara profil",
"txt_search_sends": "Sök sändningar...", "txt_search_sends": "Sök sändningar...",
"txt_session_refresh_failed": "Sessionsuppdatering misslyckades. Vänligen logga in igen.", "txt_session_refresh_failed": "Sessionsuppdatering misslyckades. Vänligen logga in igen.",
"txt_session_refresh_temporarily_unavailable": "Sessionen kan inte verifieras tillfälligt. Inloggningen bevaras och ett nytt försök görs.",
"txt_search_your_secure_vault": "Sök i ditt säkra valv...", "txt_search_your_secure_vault": "Sök i ditt säkra valv...",
"txt_search_items_count": "Sök bland {count} objekt...", "txt_search_items_count": "Sök bland {count} objekt...",
"txt_clear_search": "Rensa sökning", "txt_clear_search": "Rensa sökning",
@@ -1447,4 +1456,12 @@ const sv: Record<string, string> = {
"txt_ip_address": "IP-adress" "txt_ip_address": "IP-adress"
}; };
Object.assign(sv, {
"nav_password_security": "Lösenordssäkerhet", "txt_password_security": "Säkerhetskontroll för lösenord", "txt_password_security_privacy": "Lösenord kontrolleras lokalt. Endast ett anonymt hashprefix skickas till läckdatabasen när du startar kontrollen.", "txt_check_password_security": "Starta kontroll", "txt_checking_password_security": "Kontrollerar", "txt_recheck_password_security": "Kontrollera igen", "txt_password_security_ready": "Ditt valv är redo för en säkerhetskontroll.", "txt_password_security_no_login": "Det finns inga inloggningslösenord att kontrollera.", "txt_password_security_manual": "Kontrollen startar bara när du väljer den. Resultaten stannar på denna sida.", "txt_password_security_no_login_help": "Lägg till en inloggning med lösenord och återvänd sedan hit för att kontrollera den.", "txt_exposed_passwords": "Läckta", "txt_reused_passwords": "Återanvända", "txt_weak_passwords": "Svaga", "txt_passwords_checked": "Kontrollerade", "txt_password_security_unavailable": "{count} lösenordskontroller kunde inte nå läckdatabasen. De markeras inte som säkra.", "txt_password_security_not_checked": "Inte kontrollerad", "txt_password_exposed_count": "Hittades i {count} läckor", "txt_password_reused_count": "Användes {count} gånger", "txt_weak_password": "Svagt lösenord", "txt_no_password_risks": "Inga lösenordsrisker hittades", "txt_open_vault": "Öppna valv", "txt_check_password_breach": "Kontrollera läcka", "txt_password_not_found_in_breaches": "Hittades inte i läckdatabasen", "txt_password_security_check_failed": "Läckkontrollen kunde inte slutföras."
});
Object.assign(sv, { "txt_password_security_last_checked": "Senast kontrollerad: {value}" });
Object.assign(sv, { "txt_no_password_risks_in_filter": "Inga lösenordsrisker i denna kategori" });
Object.assign(sv, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default sv; export default sv;
+47
View File
@@ -7,10 +7,20 @@ const zhCN: Record<string, string> = {
"nav_my_vault": "我的密码库", "nav_my_vault": "我的密码库",
"nav_vault_items": "密码库", "nav_vault_items": "密码库",
"nav_sends": "Send", "nav_sends": "Send",
"nav_generator": "密码生成器",
"txt_password_generator": "密码生成器",
"txt_password_generator_description": "在此设备本地生成强且唯一的密码。",
"txt_generator_type": "生成类型", "txt_passphrase": "密码短语", "txt_generated_password": "已生成密码", "txt_password_strength": "强度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "一般", "txt_password_strength_good": "良好", "txt_password_strength_strong": "强", "txt_generator_security_note": "生成过程仅在本地进行,密码不会发送到服务器。", "txt_generator_length": "长度", "txt_generator_character_types": "字符类型", "txt_generator_uppercase": "大写字母 (A-Z)", "txt_generator_lowercase": "小写字母 (a-z)", "txt_generator_numbers": "数字 (0-9)", "txt_generator_special": "特殊字符 (!@#$%^&*)", "txt_generator_minimum": "最少数量", "txt_generator_avoid_ambiguous": "避免易混淆字符", "txt_generator_words": "单词数量", "txt_generator_separator": "单词分隔符", "txt_generator_capitalize": "首字母大写", "txt_generator_include_number": "包含数字",
"txt_generated_value": "已生成内容", "txt_generator_character_count": "{count} 个字符", "txt_generator_pin": "PIN 码", "txt_generator_pin_description": "使用密码学安全随机数在本地生成纯数字 PIN 码。", "txt_generator_username": "用户名", "txt_generator_plus_addressed_email": "加号别名邮箱", "txt_generator_catch_all_email": "全域接收邮箱", "txt_generator_email": "邮箱地址", "txt_generator_domain": "域名",
"txt_generator_word_list": "词表", "txt_generator_eff_word_list": "EFF 长词表", "txt_generator_custom_word_list": "自定义词表", "txt_generator_custom_words": "自定义单词", "txt_generator_custom_words_placeholder": "输入至少两个单词,用空格、逗号或换行分隔",
"txt_generator_long_word_username": "长单词用户名", "txt_generator_custom_word": "自定义助记词", "txt_generator_subdomain_email": "子域电子邮件", "txt_generator_ssh_key": "SSH 密钥", "txt_generator_ssh_generating": "正在生成 SSH 密钥…", "txt_generator_ssh_error": "此浏览器不支持 SSH 密钥生成。", "txt_generator_copy_public_key": "复制公钥", "txt_generator_ssh_security_note": "密钥对仅在本地生成,不会发送到服务器。请立即保存私钥,NodeWarden 不会保留它。", "txt_generator_public_key": "公钥", "txt_generator_private_key": "私钥 — 请保密", "txt_generator_ssh_algorithm": "算法", "txt_generator_key_length": "密钥长度", "txt_generator_ssh_comment": "公钥注释", "txt_generator_ssh_rsa_description": "RSA 兼容性广泛。请至少使用 2048 位,推荐 4096 位。", "txt_generator_ssh_ed25519_description": "Ed25519 快速且紧凑,推荐用于现代 SSH 客户端。",
"txt_generator_email_alias": "邮箱别名", "txt_generator_email_type": "邮箱别名类型", "txt_generator_email_required_hint": "输入邮箱地址或域名后,即可生成别名。", "txt_generator_email_description": "输入内容只保留在此设备上,仅用于组合邮箱别名。", "txt_generator_long_word_username_description": "将一个或多个长单词组合成易记的用户名。",
"nav_backup_strategy": "云端备份", "nav_backup_strategy": "云端备份",
"nav_import_export": "导入导出", "nav_import_export": "导入导出",
"nav_group_data_backup": "数据与备份", "nav_group_data_backup": "数据与备份",
"nav_group_management": "管理", "nav_group_management": "管理",
"nav_group_tools": "工具",
"nav_group_system_management": "系统管理",
"txt_settings_appearance": "外观", "txt_settings_appearance": "外观",
"txt_theme": "主题", "txt_theme": "主题",
"txt_use_system_theme": "使用系统主题", "txt_use_system_theme": "使用系统主题",
@@ -508,6 +518,7 @@ const zhCN: Record<string, string> = {
"txt_create_account": "创建账户", "txt_create_account": "创建账户",
"txt_registering": "正在注册...", "txt_registering": "正在注册...",
"txt_register_failed": "注册失败", "txt_register_failed": "注册失败",
"txt_web_crypto_unavailable": "当前浏览器环境无法使用安全加密。请通过 HTTPS 打开 NodeWarden,并使用受支持的现代浏览器。",
"txt_create_folder": "创建文件夹", "txt_create_folder": "创建文件夹",
"txt_create_folder_failed": "创建文件夹失败", "txt_create_folder_failed": "创建文件夹失败",
"txt_create_item_failed": "创建项目失败", "txt_create_item_failed": "创建项目失败",
@@ -978,6 +989,7 @@ const zhCN: Record<string, string> = {
"txt_save_profile_failed": "保存资料失败", "txt_save_profile_failed": "保存资料失败",
"txt_search_sends": "搜索 Send...", "txt_search_sends": "搜索 Send...",
"txt_session_refresh_failed": "会话刷新失败,请重新登录", "txt_session_refresh_failed": "会话刷新失败,请重新登录",
"txt_session_refresh_temporarily_unavailable": "暂时无法验证会话,登录状态已保留,稍后会自动重试",
"txt_search_your_secure_vault": "搜索你的密码库...", "txt_search_your_secure_vault": "搜索你的密码库...",
"txt_search_items_count": "共 {count} 项中搜索...", "txt_search_items_count": "共 {count} 项中搜索...",
"txt_clear_search": "清空搜索", "txt_clear_search": "清空搜索",
@@ -1447,4 +1459,39 @@ const zhCN: Record<string, string> = {
"txt_ip_address": "IP 地址" "txt_ip_address": "IP 地址"
}; };
Object.assign(zhCN, {
"nav_password_security": "安全检测",
"txt_password_security": "安全检测",
"txt_password_security_privacy": "密码仅在本地前端检查;只有匿名哈希前缀会发送到泄露密码库。",
"txt_check_password_security": "开始检查",
"txt_checking_password_security": "检查中",
"txt_recheck_password_security": "重新检查",
"txt_password_security_ready": "密码库已准备好进行安全检查。",
"txt_password_security_no_login": "没有可检查的登录密码。",
"txt_password_security_manual": "仅在您主动开始后才会联网检查;结果会保留到刷新页面或密码库内容变更前。",
"txt_password_security_no_login_help": "添加一个包含密码的登录项目后,再回到此处检查。",
"txt_exposed_passwords": "已泄露",
"txt_reused_passwords": "重复使用",
"txt_weak_passwords": "较弱",
"txt_passwords_checked": "已检查",
"txt_password_security_last_checked": "上次检测:{value}",
"txt_password_security_show_all": "显示全部",
"txt_password_security_hide_all": "隐藏全部",
"txt_password_security_jump": "跳转",
"txt_password_security_exposed_short": "泄露 {count} 次",
"txt_password_security_weak_short": "弱密码",
"txt_password_security_reused_short": "重复",
"txt_password_security_unavailable": "有 {count} 个密码无法连接泄露库,未被标记为安全。",
"txt_password_security_not_checked": "未检查",
"txt_password_exposed_count": "已在 {count} 次泄露中出现",
"txt_password_reused_count": "使用了 {count} 次",
"txt_weak_password": "较弱密码",
"txt_no_password_risks": "未发现密码风险",
"txt_no_password_risks_in_filter": "此类别中没有密码风险",
"txt_open_vault": "打开密码库",
"txt_check_password_breach": "检查泄露",
"txt_password_not_found_in_breaches": "未在泄露密码库中发现",
"txt_password_security_check_failed": "无法完成泄露检查。"
});
export default zhCN; export default zhCN;
+47
View File
@@ -7,10 +7,20 @@ const zhTW: Record<string, string> = {
"nav_my_vault": "我的密碼庫", "nav_my_vault": "我的密碼庫",
"nav_vault_items": "密碼庫", "nav_vault_items": "密碼庫",
"nav_sends": "Send", "nav_sends": "Send",
"nav_generator": "密碼產生器",
"txt_password_generator": "密碼產生器",
"txt_password_generator_description": "在此裝置本機建立強而唯一的密碼。",
"txt_generator_type": "產生類型", "txt_passphrase": "密碼片語", "txt_generated_password": "已產生密碼", "txt_password_strength": "強度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "普通", "txt_password_strength_good": "良好", "txt_password_strength_strong": "強", "txt_generator_security_note": "產生程序僅在本機進行,密碼不會傳送到伺服器。", "txt_generator_length": "長度", "txt_generator_character_types": "字元類型", "txt_generator_uppercase": "大寫字母 (A-Z)", "txt_generator_lowercase": "小寫字母 (a-z)", "txt_generator_numbers": "數字 (0-9)", "txt_generator_special": "特殊字元 (!@#$%^&*)", "txt_generator_minimum": "最少數量", "txt_generator_avoid_ambiguous": "避免易混淆字元", "txt_generator_words": "單字數量", "txt_generator_separator": "單字分隔符號", "txt_generator_capitalize": "首字母大寫", "txt_generator_include_number": "包含數字",
"txt_generated_value": "已產生內容", "txt_generator_character_count": "{count} 個字元", "txt_generator_pin": "PIN 碼", "txt_generator_pin_description": "使用密碼學安全亂數在本機產生純數字 PIN 碼。", "txt_generator_username": "使用者名稱", "txt_generator_plus_addressed_email": "加號別名信箱", "txt_generator_catch_all_email": "全域接收信箱", "txt_generator_email": "電子郵件地址", "txt_generator_domain": "網域",
"txt_generator_word_list": "詞表", "txt_generator_eff_word_list": "EFF 長詞表", "txt_generator_custom_word_list": "自訂詞表", "txt_generator_custom_words": "自訂單字", "txt_generator_custom_words_placeholder": "輸入至少兩個單字,以空格、逗號或換行分隔",
"txt_generator_long_word_username": "長單字使用者名稱", "txt_generator_custom_word": "自訂助記詞", "txt_generator_subdomain_email": "子網域電子郵件", "txt_generator_ssh_key": "SSH 金鑰", "txt_generator_ssh_generating": "正在產生 SSH 金鑰…", "txt_generator_ssh_error": "此瀏覽器不支援 SSH 金鑰產生。", "txt_generator_copy_public_key": "複製公鑰", "txt_generator_ssh_security_note": "金鑰組只在本機產生,不會傳送到伺服器。請立即儲存私鑰,NodeWarden 不會保留它。", "txt_generator_public_key": "公鑰", "txt_generator_private_key": "私鑰 — 請保密", "txt_generator_ssh_algorithm": "演算法", "txt_generator_key_length": "金鑰長度", "txt_generator_ssh_comment": "公鑰註解", "txt_generator_ssh_rsa_description": "RSA 相容性廣泛。請至少使用 2048 位元,建議 4096 位元。", "txt_generator_ssh_ed25519_description": "Ed25519 快速且精簡,建議用於現代 SSH 用戶端。",
"txt_generator_email_alias": "信箱別名", "txt_generator_email_type": "信箱別名類型", "txt_generator_email_required_hint": "輸入電子郵件地址或網域後,即可產生別名。", "txt_generator_email_description": "輸入內容只保留在此裝置上,僅用於組合信箱別名。", "txt_generator_long_word_username_description": "將一個或多個長單字組合成容易記住的使用者名稱。",
"nav_backup_strategy": "雲端備份", "nav_backup_strategy": "雲端備份",
"nav_import_export": "導入導出", "nav_import_export": "導入導出",
"nav_group_data_backup": "資料與備份", "nav_group_data_backup": "資料與備份",
"nav_group_management": "管理", "nav_group_management": "管理",
"nav_group_tools": "工具",
"nav_group_system_management": "系統管理",
"txt_settings_appearance": "外觀", "txt_settings_appearance": "外觀",
"txt_theme": "主題", "txt_theme": "主題",
"txt_use_system_theme": "使用系統主題", "txt_use_system_theme": "使用系統主題",
@@ -508,6 +518,7 @@ const zhTW: Record<string, string> = {
"txt_create_account": "創建賬戶", "txt_create_account": "創建賬戶",
"txt_registering": "正在註冊...", "txt_registering": "正在註冊...",
"txt_register_failed": "註冊失敗", "txt_register_failed": "註冊失敗",
"txt_web_crypto_unavailable": "目前瀏覽器環境無法使用安全加密。請透過 HTTPS 開啟 NodeWarden,並使用受支援的現代瀏覽器。",
"txt_create_folder": "創建文件夾", "txt_create_folder": "創建文件夾",
"txt_create_folder_failed": "創建文件夾失敗", "txt_create_folder_failed": "創建文件夾失敗",
"txt_create_item_failed": "創建項目失敗", "txt_create_item_failed": "創建項目失敗",
@@ -978,6 +989,7 @@ const zhTW: Record<string, string> = {
"txt_save_profile_failed": "保存資料失敗", "txt_save_profile_failed": "保存資料失敗",
"txt_search_sends": "搜索 Send...", "txt_search_sends": "搜索 Send...",
"txt_session_refresh_failed": "會話刷新失敗,請重新登入", "txt_session_refresh_failed": "會話刷新失敗,請重新登入",
"txt_session_refresh_temporarily_unavailable": "暫時無法驗證會話,登入狀態已保留,稍後會自動重試",
"txt_search_your_secure_vault": "搜索你的密碼庫...", "txt_search_your_secure_vault": "搜索你的密碼庫...",
"txt_search_items_count": "在共 {count} 項中搜索...", "txt_search_items_count": "在共 {count} 項中搜索...",
"txt_clear_search": "清空搜索", "txt_clear_search": "清空搜索",
@@ -1447,4 +1459,39 @@ const zhTW: Record<string, string> = {
"txt_auth_request_missing_public_key": "裝置登入請求缺少公鑰" "txt_auth_request_missing_public_key": "裝置登入請求缺少公鑰"
}; };
Object.assign(zhTW, {
"nav_password_security": "密碼安全",
"txt_password_security": "密碼安全檢查",
"txt_password_security_privacy": "密碼僅在本機前端檢查;只有匿名雜湊前綴會傳送到外洩密碼庫。",
"txt_check_password_security": "開始檢查",
"txt_checking_password_security": "檢查中",
"txt_recheck_password_security": "重新檢查",
"txt_password_security_ready": "密碼庫已準備好進行安全檢查。",
"txt_password_security_no_login": "沒有可檢查的登入密碼。",
"txt_password_security_manual": "僅在您主動開始後才會連線檢查;結果會保留到重新整理頁面或密碼庫內容變更前。",
"txt_password_security_no_login_help": "新增一個含有密碼的登入項目後,再回到此處檢查。",
"txt_exposed_passwords": "已外洩",
"txt_reused_passwords": "重複使用",
"txt_weak_passwords": "較弱",
"txt_passwords_checked": "已檢查",
"txt_password_security_last_checked": "上次檢查:{value}",
"txt_password_security_show_all": "顯示全部",
"txt_password_security_hide_all": "隱藏全部",
"txt_password_security_jump": "跳轉",
"txt_password_security_exposed_short": "外洩 {count} 次",
"txt_password_security_weak_short": "弱密碼",
"txt_password_security_reused_short": "重複",
"txt_password_security_unavailable": "有 {count} 個密碼無法連線至外洩資料庫,未被標記為安全。",
"txt_password_security_not_checked": "未檢查",
"txt_password_exposed_count": "已在 {count} 次外洩中出現",
"txt_password_reused_count": "使用了 {count} 次",
"txt_weak_password": "較弱密碼",
"txt_no_password_risks": "未發現密碼風險",
"txt_no_password_risks_in_filter": "此類別中沒有密碼風險",
"txt_open_vault": "開啟密碼庫",
"txt_check_password_breach": "檢查外洩",
"txt_password_not_found_in_breaches": "未在外洩密碼庫中發現",
"txt_password_security_check_failed": "無法完成外洩檢查。"
});
export default zhTW; export default zhTW;
+271
View File
@@ -0,0 +1,271 @@
import { EFFLongWordList } from '@/lib/eff-word-list';
export type GeneratorMode = 'password' | 'passphrase' | 'pin' | 'username' | 'email' | 'sshKey';
export type EmailMode = 'plusAddressed' | 'catchAll' | 'subdomain';
export interface PasswordOptions {
length: number;
uppercase: boolean;
lowercase: boolean;
numbers: boolean;
special: boolean;
minUppercase: number;
minLowercase: number;
minNumbers: number;
minSpecial: number;
avoidAmbiguous: boolean;
}
export interface PassphraseOptions {
words: number;
separator: string;
capitalize: boolean;
includeNumber: boolean;
wordList: 'eff' | 'custom';
customWords: string;
}
export interface PinOptions { length: number }
export interface UsernameOptions {
words: number;
delimiter: string;
capitalize: boolean;
includeNumber: boolean;
customWord: string;
wordList: 'eff' | 'custom';
customWords: string;
}
export interface EmailOptions {
type: EmailMode;
email: string;
domain: string;
}
export interface SshKeyOptions {
type: 'ed25519' | 'rsa';
rsaLength: 2048 | 3072 | 4096;
comment: string;
}
export interface GeneratorSettings {
mode: GeneratorMode;
password: PasswordOptions;
passphrase: PassphraseOptions;
pin: PinOptions;
username: UsernameOptions;
email: EmailOptions;
sshKey: SshKeyOptions;
}
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
const DIGITS = '0123456789';
const SPECIAL = '!@#$%^&*_-+=:;,.?~';
const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1', '|']);
export const defaultGeneratorSettings: GeneratorSettings = {
mode: 'password',
password: {
length: 16,
uppercase: true,
lowercase: true,
numbers: true,
special: false,
minUppercase: 1,
minLowercase: 1,
minNumbers: 1,
minSpecial: 1,
avoidAmbiguous: false,
},
passphrase: { words: 6, separator: '-', capitalize: false, includeNumber: false, wordList: 'eff', customWords: '' },
pin: { length: 6 },
username: { words: 2, delimiter: '', capitalize: true, includeNumber: true, customWord: '', wordList: 'eff', customWords: '' },
email: { type: 'plusAddressed', email: '', domain: '' },
sshKey: { type: 'ed25519', rsaLength: 4096, comment: '' },
};
export function clampInteger(value: unknown, minimum: number, maximum: number, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, Math.round(parsed))) : fallback;
}
export function normalizeGeneratorSettings(value: unknown): GeneratorSettings {
const stored = value && typeof value === 'object' ? value as Partial<GeneratorSettings> : {};
const password: Partial<PasswordOptions> = stored.password && typeof stored.password === 'object' ? stored.password : {};
const passphrase: Partial<PassphraseOptions> = stored.passphrase && typeof stored.passphrase === 'object' ? stored.passphrase : {};
const pin: Partial<PinOptions> = stored.pin && typeof stored.pin === 'object' ? stored.pin : {};
const username: Partial<UsernameOptions> = stored.username && typeof stored.username === 'object' ? stored.username : {};
const legacyUsername = stored.username && typeof stored.username === 'object' ? stored.username as Partial<UsernameOptions> & Partial<EmailOptions> : {};
const email: Partial<EmailOptions> = stored.email && typeof stored.email === 'object' ? stored.email : legacyUsername;
const sshKey: Partial<SshKeyOptions> = stored.sshKey && typeof stored.sshKey === 'object' ? stored.sshKey : {};
const modes: GeneratorMode[] = ['password', 'passphrase', 'pin', 'username', 'email', 'sshKey'];
const emailModes: EmailMode[] = ['plusAddressed', 'catchAll', 'subdomain'];
const rsaLengths: SshKeyOptions['rsaLength'][] = [2048, 3072, 4096];
const mode = stored.mode === 'username' && emailModes.includes(legacyUsername.type as EmailMode)
? 'email'
: modes.includes(stored.mode as GeneratorMode) ? stored.mode as GeneratorMode : defaultGeneratorSettings.mode;
return {
mode,
password: {
...defaultGeneratorSettings.password,
...password,
length: clampInteger(password.length, 5, 128, defaultGeneratorSettings.password.length),
minUppercase: clampInteger(password.minUppercase, 0, 9, defaultGeneratorSettings.password.minUppercase),
minLowercase: clampInteger(password.minLowercase, 0, 9, defaultGeneratorSettings.password.minLowercase),
minNumbers: clampInteger(password.minNumbers, 0, 9, defaultGeneratorSettings.password.minNumbers),
minSpecial: clampInteger(password.minSpecial, 0, 9, defaultGeneratorSettings.password.minSpecial),
},
passphrase: {
...defaultGeneratorSettings.passphrase,
...passphrase,
words: clampInteger(passphrase.words, 3, 20, defaultGeneratorSettings.passphrase.words),
separator: String(passphrase.separator ?? defaultGeneratorSettings.passphrase.separator).slice(0, 1),
wordList: passphrase.wordList === 'custom' ? 'custom' : 'eff',
customWords: String(passphrase.customWords ?? '').slice(0, 50_000),
},
pin: { length: clampInteger(pin.length, 3, 64, defaultGeneratorSettings.pin.length) },
username: {
...defaultGeneratorSettings.username,
...username,
words: clampInteger(username.words, 1, 10, defaultGeneratorSettings.username.words),
delimiter: String(username.delimiter ?? defaultGeneratorSettings.username.delimiter).slice(0, 8),
customWord: String(username.customWord ?? '').trim().slice(0, 128),
wordList: username.wordList === 'custom' ? 'custom' : 'eff',
customWords: String(username.customWords ?? '').slice(0, 50_000),
},
email: {
type: emailModes.includes(email.type as EmailMode) ? email.type as EmailMode : defaultGeneratorSettings.email.type,
email: String(email.email ?? '').trim().slice(0, 254),
domain: String(email.domain ?? '').trim().slice(0, 253),
},
sshKey: {
type: sshKey.type === 'rsa' ? 'rsa' : 'ed25519',
rsaLength: rsaLengths.includes(sshKey.rsaLength as SshKeyOptions['rsaLength']) ? sshKey.rsaLength as SshKeyOptions['rsaLength'] : defaultGeneratorSettings.sshKey.rsaLength,
comment: String(sshKey.comment ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, 256),
},
};
}
function randomIndex(length: number): number {
if (!Number.isSafeInteger(length) || length < 1) throw new RangeError('Random source must not be empty');
const range = 0x1_0000_0000;
const upperBound = Math.floor(range / length) * length;
const buffer = new Uint32Array(1);
do crypto.getRandomValues(buffer); while (buffer[0] >= upperBound);
return buffer[0] % length;
}
function pick(characters: string): string { return characters[randomIndex(characters.length)]; }
function shuffle(value: string[]): string[] {
for (let index = value.length - 1; index > 0; index -= 1) {
const next = randomIndex(index + 1);
[value[index], value[next]] = [value[next], value[index]];
}
return value;
}
function filtered(characters: string, avoidAmbiguous: boolean): string {
return avoidAmbiguous ? [...characters].filter((character) => !AMBIGUOUS.has(character)).join('') : characters;
}
export function generatePassword(options: PasswordOptions): string {
const sets: Array<{ chars: string; minimum: number }> = [];
if (options.uppercase) sets.push({ chars: filtered(UPPERCASE, options.avoidAmbiguous), minimum: options.minUppercase });
if (options.lowercase) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: options.minLowercase });
if (options.numbers) sets.push({ chars: filtered(DIGITS, options.avoidAmbiguous), minimum: options.minNumbers });
if (options.special) sets.push({ chars: SPECIAL, minimum: options.minSpecial });
if (!sets.length) sets.push({ chars: LOWERCASE, minimum: 1 });
const required = sets.reduce((total, set) => total + set.minimum, 0);
const length = Math.max(options.length, required, 5);
const allCharacters = sets.map((set) => set.chars).join('');
const result = sets.flatMap((set) => Array.from({ length: set.minimum }, () => pick(set.chars)));
while (result.length < length) result.push(pick(allCharacters));
return shuffle(result).join('');
}
export function generatePassphrase(options: PassphraseOptions): string {
const customWords = [...new Set(options.customWords.split(/[\s,;]+/).map((word) => word.trim()).filter(Boolean))];
const wordList = options.wordList === 'custom' && customWords.length >= 2 ? customWords : EFFLongWordList;
const words = Array.from({ length: options.words }, () => wordList[randomIndex(wordList.length)]);
if (options.capitalize) {
for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1);
}
if (options.includeNumber) words[randomIndex(words.length)] += String(randomIndex(10));
return words.join(options.separator);
}
export function generatePin(options: PinOptions): string {
return Array.from({ length: options.length }, () => pick(DIGITS)).join('');
}
function randomWord(options: Pick<UsernameOptions, 'words' | 'delimiter' | 'capitalize' | 'includeNumber' | 'customWord' | 'wordList' | 'customWords'>): string {
const customWords = [...new Set(options.customWords.split(/[\s,;]+/).map((word) => word.trim()).filter(Boolean))];
const wordList = options.wordList === 'custom' && customWords.length >= 2 ? customWords : EFFLongWordList;
const words = Array.from({ length: options.words }, () => wordList[randomIndex(wordList.length)]);
if (options.customWord) words[randomIndex(words.length)] = options.customWord;
if (options.capitalize) {
for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1);
}
if (options.includeNumber) {
const digits = options.words === 1 ? 4 : options.words === 2 ? 3 : 2;
const minimum = 10 ** (digits - 1);
words[randomIndex(words.length)] += String(minimum + randomIndex(9 * minimum));
}
return words.join(options.delimiter);
}
function normalizeDomain(value: string): string {
return value.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0].replace(/^@/, '');
}
export function generateUsername(options: UsernameOptions): string {
return randomWord(options);
}
function validDomain(value: string): string | null {
const domain = normalizeDomain(value);
return /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(domain) ? domain : null;
}
function validEmail(value: string): { local: string; domain: string } | null {
const match = value.trim().match(/^([^\s@+]+)(?:\+[^\s@]*)?@([^\s@]+)$/);
const domain = match ? validDomain(match[2]) : null;
return match && domain ? { local: match[1], domain } : null;
}
export function generateEmail(options: EmailOptions): string {
const suffix = randomWord({ ...defaultGeneratorSettings.username, words: 1, capitalize: false, includeNumber: true });
if (options.type === 'catchAll') {
const domain = validDomain(options.domain);
return domain ? `${suffix}@${domain}` : '';
}
const email = validEmail(options.email);
if (!email) return '';
return options.type === 'subdomain'
? `${email.local}@${suffix}.${email.domain}`
: `${email.local}+${suffix}@${email.domain}`;
}
export function generateValue(settings: GeneratorSettings): string {
if (settings.mode === 'passphrase') return generatePassphrase(settings.passphrase);
if (settings.mode === 'pin') return generatePin(settings.pin);
if (settings.mode === 'username') return generateUsername(settings.username);
if (settings.mode === 'email') return generateEmail(settings.email);
if (settings.mode === 'sshKey') return '';
return generatePassword(settings.password);
}
export function estimateStrength(mode: GeneratorMode, value: string, passphraseWordCount?: number): number {
if (mode === 'username' || mode === 'email' || mode === 'sshKey') return 0;
if (mode === 'pin') return value.length >= 10 ? 4 : value.length >= 8 ? 3 : value.length >= 6 ? 2 : 1;
if (mode === 'passphrase') {
const words = passphraseWordCount ?? value.split(/[-_. ]/).filter(Boolean).length;
return Math.min(4, Math.max(1, Math.floor(words / 2)));
}
return Math.min(4, Math.max(1, Math.floor(value.length / 5)));
}
+74
View File
@@ -0,0 +1,74 @@
import { inspectVaultPasswordSecurity, type PasswordSecurityReport } from '@/lib/password-security';
import type { Cipher } from '@/lib/types';
export interface PasswordSecurityState {
fingerprint: string;
report: PasswordSecurityReport | null;
scannedAt: number | null;
scanning: boolean;
progress: { checked: number; total: number };
scanError: boolean;
}
type InternalPasswordSecurityState = PasswordSecurityState & { controller: AbortController | null };
let state: InternalPasswordSecurityState | null = null;
const listeners = new Set<() => void>();
function notify(): void {
listeners.forEach((listener) => listener());
}
function createState(fingerprint: string): InternalPasswordSecurityState {
return { fingerprint, report: null, scannedAt: null, scanning: false, progress: { checked: 0, total: 0 }, scanError: false, controller: null };
}
export function getPasswordSecurityState(fingerprint: string): PasswordSecurityState {
if (state?.fingerprint !== fingerprint) {
state?.controller?.abort();
state = createState(fingerprint);
}
return state;
}
export function readPasswordSecurityState(fingerprint: string): PasswordSecurityState | null {
return state?.fingerprint === fingerprint ? state : null;
}
export function subscribePasswordSecurityState(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function startPasswordSecurityScan(fingerprint: string, ciphers: Cipher[]): void {
const current = getPasswordSecurityState(fingerprint);
current.controller?.abort();
const controller = new AbortController();
const total = ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length;
state = { ...current, report: null, scannedAt: null, scanning: true, progress: { checked: 0, total }, scanError: false, controller };
notify();
void (async () => {
try {
const report = await inspectVaultPasswordSecurity(ciphers, (checked, total) => {
if (controller.signal.aborted || state?.controller !== controller) return;
state = { ...state, progress: { checked, total } };
notify();
}, fetch, controller.signal);
if (controller.signal.aborted || state?.controller !== controller) return;
state = { ...state, report, scannedAt: Date.now() };
} catch (error) {
if (controller.signal.aborted || (error as { name?: string } | null)?.name === 'AbortError') return;
if (state?.controller === controller) state = { ...state, scanError: true };
} finally {
if (state?.controller === controller) state = { ...state, controller: null, scanning: false };
notify();
}
})();
}
export function clearPasswordSecurityCache(): void {
state?.controller?.abort();
state = null;
notify();
}
+229
View File
@@ -0,0 +1,229 @@
import type { Cipher } from '@/lib/types';
const PWNED_PASSWORDS_RANGE_URL = 'https://api.pwnedpasswords.com/range/';
const MAX_CONCURRENT_BREACH_CHECKS = 5;
const COMMON_PASSWORDS = new Set([
'password', 'password1', '123456', '12345678', '123456789', 'qwerty', 'abc123', 'letmein', 'welcome', 'iloveyou', 'admin', 'changeme',
]);
export interface PasswordBreachResult {
count: number | null;
available: boolean;
}
export interface PasswordSecurityItem {
cipherId: string;
exposedCount: number | null;
reusedCount: number;
weak: boolean;
}
export interface PasswordSecurityReport {
eligibleCount: number;
checkedCount: number;
exposedCount: number;
reusedCount: number;
weakCount: number;
unavailableCount: number;
items: PasswordSecurityItem[];
}
type Candidate = {
cipherId: string;
name: string;
hash: string;
weak: boolean;
};
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('').toUpperCase();
}
function isAbortError(error: unknown): boolean {
return !!error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError';
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
const error = new Error('The operation was aborted.');
error.name = 'AbortError';
throw error;
}
}
export async function sha1Password(password: string): Promise<string> {
const input = new TextEncoder().encode(password);
return bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-1', input)));
}
function parseRangeResponse(text: string, suffix: string): number {
for (const line of text.split(/\r?\n/)) {
const separator = line.indexOf(':');
if (separator !== 35) continue;
if (line.slice(0, separator).toUpperCase() !== suffix) continue;
const count = Number.parseInt(line.slice(separator + 1), 10);
return Number.isSafeInteger(count) && count > 0 ? count : 0;
}
return 0;
}
export async function checkPasswordHashLeaked(
hash: string,
fetchImpl: typeof fetch = fetch,
signal?: AbortSignal,
): Promise<number> {
if (!/^[A-F0-9]{40}$/.test(hash)) throw new Error('Password hash is invalid.');
throwIfAborted(signal);
const controller = new AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), 12_000);
const onExternalAbort = () => controller.abort();
signal?.addEventListener('abort', onExternalAbort, { once: true });
if (signal?.aborted) controller.abort();
try {
const response = await fetchImpl(`${PWNED_PASSWORDS_RANGE_URL}${hash.slice(0, 5)}`, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
cache: 'no-store',
referrerPolicy: 'no-referrer',
headers: { 'Add-Padding': 'true' },
signal: controller.signal,
});
if (!response.ok) throw new Error(`Pwned Passwords returned ${response.status}.`);
return parseRangeResponse(await response.text(), hash.slice(5));
} catch (error) {
// External cancel (leave page / re-scan) must stay distinguishable from timeout/network failures.
if (signal?.aborted) {
const abortError = new Error('The operation was aborted.');
abortError.name = 'AbortError';
throw abortError;
}
if (isAbortError(error)) throw new Error('Pwned Passwords request timed out.');
throw error;
} finally {
globalThis.clearTimeout(timeout);
signal?.removeEventListener('abort', onExternalAbort);
}
}
export async function checkPasswordLeaked(
password: string,
fetchImpl: typeof fetch = fetch,
signal?: AbortSignal,
): Promise<PasswordBreachResult> {
if (!password) return { count: 0, available: true };
try {
return { count: await checkPasswordHashLeaked(await sha1Password(password), fetchImpl, signal), available: true };
} catch (error) {
if (isAbortError(error) || signal?.aborted) throw error;
return { count: null, available: false };
}
}
function hasSimpleSequence(value: string): boolean {
const normalized = value.toLowerCase();
return ['0123456789', '9876543210', 'abcdefghijklmnopqrstuvwxyz', 'zyxwvutsrqponmlkjihgfedcba', 'qwertyuiop', 'poiuytrewq']
.some((sequence) => sequence.includes(normalized) || normalized.includes(sequence.slice(0, 5)));
}
export function isWeakPassword(password: string, username: string = ''): boolean {
const normalized = password.toLowerCase();
const compactUsername = username.split('@')[0]?.trim().toLowerCase() || '';
if (COMMON_PASSWORDS.has(normalized) || password.length < 10) return true;
if (/^(.)\1+$/.test(password) || hasSimpleSequence(password)) return true;
if (compactUsername.length >= 3 && normalized.includes(compactUsername)) return true;
const classes = [/[a-z]/.test(password), /[A-Z]/.test(password), /\d/.test(password), /[^A-Za-z0-9]/.test(password)].filter(Boolean).length;
return password.length < 14 && classes < 3;
}
function isEligibleCipher(cipher: Cipher): boolean {
return Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword;
}
async function mapWithConcurrency<T, R>(
values: T[],
limit: number,
worker: (value: T) => Promise<R>,
signal?: AbortSignal,
): Promise<R[]> {
const results = new Array<R>(values.length);
let nextIndex = 0;
const run = async () => {
while (true) {
throwIfAborted(signal);
const index = nextIndex;
nextIndex += 1;
if (index >= values.length) return;
results[index] = await worker(values[index]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, run));
return results;
}
export async function inspectVaultPasswordSecurity(
ciphers: Cipher[],
onProgress?: (checked: number, total: number) => void,
fetchImpl: typeof fetch = fetch,
signal?: AbortSignal,
): Promise<PasswordSecurityReport> {
throwIfAborted(signal);
const eligible = ciphers.filter(isEligibleCipher);
const candidates: Candidate[] = await Promise.all(eligible.map(async (cipher) => {
throwIfAborted(signal);
const password = String(cipher.login?.decPassword || '');
const username = String(cipher.login?.decUsername || '');
return {
cipherId: cipher.id,
name: String(cipher.decName || cipher.name || ''),
hash: await sha1Password(password),
weak: isWeakPassword(password, username),
};
}));
const candidatesByHash = new Map<string, Candidate[]>();
for (const candidate of candidates) {
const group = candidatesByHash.get(candidate.hash) || [];
group.push(candidate);
candidatesByHash.set(candidate.hash, group);
}
const exposureByHash = new Map<string, PasswordBreachResult>();
let checked = 0;
await mapWithConcurrency([...candidatesByHash.keys()], MAX_CONCURRENT_BREACH_CHECKS, async (hash) => {
throwIfAborted(signal);
let result: PasswordBreachResult;
try {
result = { count: await checkPasswordHashLeaked(hash, fetchImpl, signal), available: true };
} catch (error) {
if (isAbortError(error) || signal?.aborted) throw error;
result = { count: null, available: false };
}
exposureByHash.set(hash, result);
checked += candidatesByHash.get(hash)?.length || 0;
onProgress?.(Math.min(checked, candidates.length), candidates.length);
return result;
}, signal);
throwIfAborted(signal);
const items = candidates.map((candidate) => {
const exposure = exposureByHash.get(candidate.hash) || { count: null, available: false };
return {
cipherId: candidate.cipherId,
exposedCount: exposure.count,
reusedCount: candidatesByHash.get(candidate.hash)?.length || 1,
weak: candidate.weak,
};
}).filter((item) => item.exposedCount === null || (item.exposedCount || 0) > 0 || item.reusedCount > 1 || item.weak)
.sort((a, b) => (Number(b.exposedCount || 0) - Number(a.exposedCount || 0)) || (b.reusedCount - a.reusedCount) || Number(b.weak) - Number(a.weak) || a.cipherId.localeCompare(b.cipherId));
return {
eligibleCount: candidates.length,
checkedCount: checked,
exposedCount: candidates.filter((candidate) => (exposureByHash.get(candidate.hash)?.count || 0) > 0).length,
reusedCount: candidates.filter((candidate) => (candidatesByHash.get(candidate.hash)?.length || 0) > 1).length,
weakCount: candidates.filter((candidate) => candidate.weak).length,
unavailableCount: candidates.filter((candidate) => exposureByHash.get(candidate.hash)?.count === null).length,
items,
};
}
+141
View File
@@ -0,0 +1,141 @@
import type { SshKeyOptions } from '@/lib/password-generator';
export interface GeneratedSshKey {
type: 'ED25519' | 'RSA';
bits: number;
publicKey: string;
privateKey: string;
fingerprint: string;
}
const encoder = new TextEncoder();
function concat(...chunks: Uint8Array[]): Uint8Array {
const output = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.length;
}
return output;
}
function uint32(value: number): Uint8Array {
const output = new Uint8Array(4);
new DataView(output.buffer).setUint32(0, value, false);
return output;
}
function sshString(value: string | Uint8Array): Uint8Array {
const bytes = typeof value === 'string' ? encoder.encode(value) : value;
return concat(uint32(bytes.length), bytes);
}
function base64UrlBytes(value: string): Uint8Array {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const decoded = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='));
return Uint8Array.from(decoded, (character) => character.charCodeAt(0));
}
function base64(bytes: Uint8Array): string {
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return btoa(binary);
}
function mpint(value: Uint8Array): Uint8Array {
let offset = 0;
while (offset < value.length - 1 && value[offset] === 0) offset += 1;
let bytes = value.subarray(offset);
if (bytes[0] & 0x80) bytes = concat(new Uint8Array([0]), bytes);
return sshString(bytes);
}
function openSshPrivateKey(publicBlob: Uint8Array, fields: Uint8Array[]): string {
const check = new Uint32Array(1);
crypto.getRandomValues(check);
let privateBlock = concat(uint32(check[0]), uint32(check[0]), ...fields, sshString(''));
const paddingLength = 8 - (privateBlock.length % 8);
privateBlock = concat(privateBlock, Uint8Array.from({ length: paddingLength }, (_, index) => index + 1));
const envelope = concat(
encoder.encode('openssh-key-v1\0'),
sshString('none'),
sshString('none'),
sshString(new Uint8Array()),
uint32(1),
sshString(publicBlob),
sshString(privateBlock),
);
const body = base64(envelope).match(/.{1,70}/g)?.join('\n') || '';
return `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
}
async function fingerprint(publicBlob: Uint8Array): Promise<string> {
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', Uint8Array.from(publicBlob).buffer));
return `SHA256:${base64(digest).replace(/=+$/, '')}`;
}
function required(jwk: JsonWebKey, property: keyof JsonWebKey): Uint8Array {
const value = jwk[property];
if (typeof value !== 'string' || !value) throw new Error(`The generated key is missing ${String(property)}`);
return base64UrlBytes(value);
}
async function generateEd25519(comment: string): Promise<GeneratedSshKey> {
const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']) as CryptoKeyPair;
const [privateJwk, publicJwk] = await Promise.all([
crypto.subtle.exportKey('jwk', pair.privateKey),
crypto.subtle.exportKey('jwk', pair.publicKey),
]);
const publicBytes = required(publicJwk, 'x');
const seed = required(privateJwk, 'd');
const publicBlob = concat(sshString('ssh-ed25519'), sshString(publicBytes));
const privateKey = openSshPrivateKey(publicBlob, [
sshString('ssh-ed25519'),
sshString(publicBytes),
sshString(concat(seed, publicBytes)),
]);
return {
type: 'ED25519',
bits: 256,
publicKey: `ssh-ed25519 ${base64(publicBlob)}${comment ? ` ${comment}` : ''}`,
privateKey,
fingerprint: await fingerprint(publicBlob),
};
}
async function generateRsa(length: SshKeyOptions['rsaLength'], comment: string): Promise<GeneratedSshKey> {
const pair = await crypto.subtle.generateKey({
name: 'RSASSA-PKCS1-v1_5',
modulusLength: length,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
}, true, ['sign', 'verify']) as CryptoKeyPair;
const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey);
const n = required(privateJwk, 'n');
const e = required(privateJwk, 'e');
const d = required(privateJwk, 'd');
const qi = required(privateJwk, 'qi');
const p = required(privateJwk, 'p');
const q = required(privateJwk, 'q');
const publicBlob = concat(sshString('ssh-rsa'), mpint(e), mpint(n));
const privateKey = openSshPrivateKey(publicBlob, [
sshString('ssh-rsa'),
mpint(n), mpint(e), mpint(d), mpint(qi), mpint(p), mpint(q),
]);
return {
type: 'RSA',
bits: length,
publicKey: `ssh-rsa ${base64(publicBlob)}${comment ? ` ${comment}` : ''}`,
privateKey,
fingerprint: await fingerprint(publicBlob),
};
}
export async function generateSshKey(options: SshKeyOptions): Promise<GeneratedSshKey> {
if (!crypto?.subtle) throw new Error('Web Crypto is unavailable');
const comment = options.comment.replace(/[\r\n]+/g, ' ').trim();
return options.type === 'rsa' ? generateRsa(options.rsaLength, comment) : generateEd25519(comment);
}
+1
View File
@@ -423,6 +423,7 @@ export interface YubiKeyOtpSettings {
keys: [string, string, string, string, string]; keys: [string, string, string, string, string];
nfc: boolean; nfc: boolean;
yubicoConfigured: boolean; yubicoConfigured: boolean;
yubicoCanManage: boolean;
yubicoClientId: string; yubicoClientId: string;
yubicoSecretKey: string; yubicoSecretKey: string;
} }
+4 -2
View File
@@ -2,6 +2,8 @@
@import './styles/base.css'; @import './styles/base.css';
@import './styles/auth.css'; @import './styles/auth.css';
@import './styles/forms.css'; @import './styles/forms.css';
@import './styles/generator.css';
@import './styles/password-security.css';
@import './styles/shell.css'; @import './styles/shell.css';
@import './styles/vault.css'; @import './styles/vault.css';
@import './styles/management.css'; @import './styles/management.css';
@@ -428,7 +430,7 @@ h4 {
min-height: min(640px, calc(100dvh - 180px)); min-height: min(640px, calc(100dvh - 180px));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 10px;
} }
.settings-home-section { .settings-home-section {
@@ -675,7 +677,7 @@ h4 {
} }
.card { .card {
margin-bottom: 8px; margin-bottom: 10;
padding: 14px; padding: 14px;
} }
+32
View File
@@ -0,0 +1,32 @@
.generator-page {
width: min(100%, 1180px);
margin: 0;
padding: 4px 0 28px;
}
.generator-layout { display: grid; grid-template-columns: minmax(300px, .82fr) minmax(0, 1.18fr); grid-template-areas: 'options output'; gap: 16px; align-items: start; }
.generator-output-card, .generator-options-card { border: 1px solid var(--line); border-radius: 20px; background: var(--panel); box-shadow: var(--shadow-sm); }
.generator-output-card { grid-area: output; padding: 20px; }
.generator-options-card { grid-area: options; padding: 19px; }
.generator-options-card h2 { margin: 0 0 18px; font-size: 17px; }
.generator-value { display: block; min-height: 110px; margin: 18px 0 10px; padding: 18px; border: 1px solid color-mix(in srgb, var(--primary) 22%, var(--line)); border-radius: 16px; background: color-mix(in srgb, var(--primary) 5%, var(--panel)); color: var(--text); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: clamp(19px, 2.2vw, 27px); font-weight: 700; line-height: 1.45; overflow-wrap: anywhere; user-select: all; }
.generator-value.empty { display: grid; place-items: center; color: var(--muted); font-family: inherit; font-size: 14px; font-weight: 500; text-align: center; user-select: none; }
.generator-mode-tabs { flex-wrap: wrap; }
.generator-meta-row { display: grid; grid-template-columns: minmax(90px, 1fr) auto auto; align-items: center; gap: 12px; color: var(--muted-strong); font-size: 13px; font-weight: 700; }
.generator-meta-row > span, .generator-security-note { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.generator-strength { display: flex; flex: 1; gap: 4px; }
.generator-strength span { height: 5px; flex: 1; border-radius: 999px; background: var(--line); }
.generator-strength span.active.level-1 { background: #e87171; }.generator-strength span.active.level-2 { background: #db9b38; }.generator-strength span.active.level-3 { background: #46936c; }.generator-strength span.active.level-4 { background: var(--primary); }
.generator-actions { margin-top: 22px; }.generator-actions .btn { flex: 1; }
.generator-security-note { margin: 18px 0 0; color: var(--muted); font-size: 12px; line-height: 1.45; }.generator-security-note svg { color: var(--success); flex: 0 0 auto; }
.generator-number-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 15px; color: var(--text); font-size: 14px; font-weight: 700; }.generator-number-field > label { min-width: 0; }.generator-stepper { display: grid; grid-template-columns: 38px 64px 38px; align-items: center; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }.generator-stepper button { display: grid; width: 38px; height: 40px; place-items: center; border: 0; background: transparent; color: var(--primary-strong); cursor: pointer; transition: background-color 160ms ease, color 160ms ease; }.generator-stepper button:hover:not(:disabled) { background: color-mix(in srgb, var(--primary) 10%, var(--panel)); }.generator-stepper button:active:not(:disabled) { background: color-mix(in srgb, var(--primary) 17%, var(--panel)); }.generator-stepper button:focus-visible { position: relative; z-index: 1; outline: 3px solid color-mix(in srgb, var(--primary) 35%, transparent); outline-offset: -3px; }.generator-stepper button:disabled { color: var(--muted); cursor: not-allowed; }.generator-stepper .input { width: 64px; height: 40px; min-width: 0; border: 0; border-radius: 0; padding: 0; background: transparent; text-align: center; font-variant-numeric: tabular-nums; }.generator-stepper .input:focus { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--primary) 36%, transparent); }.generator-number-field.compact { grid-template-columns: minmax(0, 1fr) auto; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 32px 46px 32px; border-radius: 9px; }.generator-number-field.compact .generator-stepper button { width: 32px; height: 34px; }.generator-number-field.compact .generator-stepper .input { width: 46px; height: 34px; font-size: 13px; }
.generator-option-group { display: grid; gap: 9px; margin: 18px 0; padding: 0; border: 0; }.generator-option-group legend { margin-bottom: 10px; padding: 0; font-size: 14px; font-weight: 700; }
.generator-toggle { display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: 10px; min-height: 32px; cursor: pointer; }.generator-toggle input { position: absolute; opacity: 0; }.generator-toggle > span { position: relative; width: 38px; height: 22px; border-radius: 999px; background: #cbd5e1; transition: background 180ms ease; }.generator-toggle > span::after { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.25); content: ''; transition: transform 180ms ease; }.generator-toggle input:checked + span { background: var(--primary); }.generator-toggle input:checked + span::after { transform: translateX(16px); }.generator-toggle input:focus-visible + span { outline: 3px solid color-mix(in srgb, var(--primary) 30%, transparent); outline-offset: 2px; }.generator-toggle strong { font-size: 14px; font-weight: 600; }
.generator-select-field, .generator-text-field { display: grid; gap: 8px; margin-bottom: 16px; color: var(--text); font-size: 14px; font-weight: 700; }.generator-select-field .input, .generator-text-field .input { width: 100%; }.generator-options-note { margin: 14px 0 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
.generator-word-list-input { min-height: 118px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 13px; line-height: 1.5; }
.generator-key-status { display: flex; min-height: 180px; margin: 18px 0 10px; align-items: center; justify-content: center; gap: 10px; border: 1px solid var(--line); border-radius: 16px; color: var(--muted-strong); }.generator-key-status.error { padding: 18px; color: var(--danger); text-align: center; }
.generator-spinner { animation: generator-spin 900ms linear infinite; }.generator-key-output { display: grid; gap: 12px; margin: 18px 0 10px; }.generator-key-summary, .generator-key-field, .generator-private-key { border: 1px solid var(--line); border-radius: 14px; background: color-mix(in srgb, var(--primary) 4%, var(--panel)); }.generator-key-summary { display: grid; gap: 6px; padding: 14px; }.generator-key-summary code { overflow-wrap: anywhere; color: var(--muted-strong); font-size: 12px; }.generator-key-field { display: grid; gap: 9px; padding: 14px; }.generator-key-field > span { font-size: 13px; font-weight: 700; }.generator-key-field > code, .generator-private-key > code { display: block; max-height: 150px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11px; line-height: 1.45; user-select: all; }.generator-key-field-actions { display: flex; flex-wrap: wrap; gap: 8px; }.generator-key-field-actions .btn { display: inline-flex; gap: 6px; }.generator-private-key { padding: 0 14px 14px; }.generator-private-key summary { padding: 14px 0; cursor: pointer; font-size: 13px; font-weight: 700; }.generator-private-key .generator-key-field-actions { margin-top: 10px; }
@keyframes generator-spin { to { transform: rotate(360deg); } }
.generator-inline-number { display: grid; grid-template-columns: minmax(0, 1fr) 72px; align-items: center; gap: 14px; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-inline-number .input { height: 34px; text-align: center; }
@media (max-width: 760px) { .generator-page { width: 100%; padding: 0 0 18px; }.generator-layout { grid-template-columns: 1fr; grid-template-areas: 'output' 'options'; gap: 10px; }.generator-output-card, .generator-options-card { padding: 15px; border-radius: 16px; }.generator-value { min-height: 94px; margin: 14px 0 10px; padding: 14px; font-size: 19px; }.generator-meta-row { grid-template-columns: minmax(60px, 1fr) auto; }.generator-meta-row > span:last-child { grid-column: 1 / -1; }.generator-actions .btn { justify-content: center; padding-inline: 10px; }.generator-option-group { margin: 15px 0; }.generator-toggle { min-height: 44px; }.generator-number-field.compact { margin-left: 50px; }.generator-stepper { grid-template-columns: 40px 64px 40px; }.generator-stepper button { width: 40px; min-height: 44px; }.generator-stepper .input { height: 44px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 36px 46px 36px; }.generator-number-field.compact .generator-stepper button { width: 36px; height: 40px; min-height: 40px; } }
@media (prefers-reduced-motion: reduce) { .generator-toggle > span, .generator-toggle > span::after { transition: none; }.generator-spinner { animation: none; } }
+77
View File
@@ -0,0 +1,77 @@
.password-security-page { width: min(100%, 1180px); margin: 0; display: grid; gap: 10px; padding: 4px 0 24px; }
.password-security-intro { display: flex; align-items: center; gap: 12px; padding: 14px 16px; }
.password-security-intro-icon { width: 42px; height: 42px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 14px; color: var(--primary-strong); background: color-mix(in srgb, var(--primary) 12%, var(--panel)); }
.password-security-intro h2 { margin: 0 0 3px; font-size: 18px; }
.password-security-intro p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
.password-security-intro .password-security-checked-at { margin-top: 4px; font-size: 12px; font-variant-numeric: tabular-nums; }
.password-security-intro-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.password-security-scan, .password-security-toggle-all { min-height: 40px; }
.password-security-empty { min-height: 190px; display: grid; place-items: center; align-content: center; gap: 9px; text-align: center; color: var(--muted); padding: 28px; }
.password-security-empty > svg { color: var(--primary); }
.password-security-empty strong { color: var(--ink); }
.password-security-empty span { font-size: 14px; max-width: 520px; line-height: 1.5; }
.password-security-empty.compact { min-height: 150px; }
.password-security-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.password-security-metric { display: flex; align-items: center; gap: 10px; min-height: 72px; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--panel); box-shadow: var(--shadow-sm); color: inherit; font: inherit; text-align: left; cursor: pointer; transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; }
.password-security-metric:hover:not(:disabled), .password-security-metric[aria-pressed='true'] { border-color: var(--primary); box-shadow: var(--shadow-md); }
.password-security-metric:active:not(:disabled) { transform: scale(.99); }
.password-security-metric:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 45%, transparent); outline-offset: 2px; }
.password-security-metric:disabled { cursor: default; }
.password-security-metric > span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 12px; }
.password-security-metric.danger > span { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, var(--panel)); }
.password-security-metric.warning > span { color: #b45309; background: #fff7e6; }
.password-security-metric.primary > span { color: var(--primary-strong); background: color-mix(in srgb, var(--primary) 12%, var(--panel)); }
.password-security-metric div { display: grid; gap: 1px; min-width: 0; }
.password-security-metric strong { font-size: 20px; line-height: 1.15; font-variant-numeric: tabular-nums; }
.password-security-metric small { color: var(--muted); font-size: 12px; }
.password-security-results { padding: 8px; }
.password-security-notice { display: flex; align-items: center; gap: 8px; padding: 9px 10px; margin-bottom: 8px; border-radius: var(--radius-md); font-size: 13px; }
.password-security-notice.warning { color: #92400e; background: #fff7e6; border: 1px solid #fcd8a3; }
.password-security-list { display: grid; }
.password-security-item { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 64px; padding: 10px; border-bottom: 1px solid var(--line-soft); }
.password-security-item:last-child { border-bottom: 0; }
.password-security-item-main { min-width: 0; display: grid; gap: 5px; }
.password-security-item-header { display: flex; align-items: center; gap: 8px; min-width: 0; }
.password-security-item-header > strong { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.password-security-password { min-width: 0; color: var(--muted); font-size: 13px; font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.password-security-item-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 6px; }
.password-security-badges { display: flex; flex-wrap: wrap; gap: 6px; }
.risk-badge { display: inline-flex; align-items: center; min-height: 22px; padding: 2px 7px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.risk-badge.danger { color: #b42318; background: #fef0ef; }
.risk-badge.reused { color: #92400e; background: #fff7e6; }
.risk-badge.weak { color: #5b21b6; background: #f3e8ff; }
.risk-badge.muted { color: var(--muted); background: var(--panel-soft); }
.password-security-open { flex: 0 0 auto; }
.password-breach-inline { display: flex; align-items: center; gap: 7px; margin-top: 9px; padding: 9px 10px; border-radius: var(--radius-md); font-size: 13px; line-height: 1.35; }
.password-breach-inline.safe { color: #16704d; background: #ecfdf3; border: 1px solid #b7ebcd; }
.password-breach-inline.danger { color: #b42318; background: #fef0ef; border: 1px solid #fecdc9; }
.password-breach-inline.warning { color: #92400e; background: #fff7e6; border: 1px solid #fcd8a3; }
.spin { animation: password-security-spin 900ms linear infinite; }
@keyframes password-security-spin { to { transform: rotate(360deg); } }
@media (max-width: 760px) {
.password-security-page { width: 100%; padding: 0 0 18px; gap: 10px; }
.password-security-intro { align-items: flex-start; padding: 14px; }
.password-security-intro-icon { width: 38px; height: 38px; border-radius: 12px; }
.password-security-intro h2 { font-size: 16px; }
.password-security-intro p { font-size: 13px; }
.password-security-intro-actions { width: 100%; margin: 8px 0 0; grid-column: 1 / -1; }
.password-security-scan, .password-security-toggle-all { flex: 1 1 0; }
.password-security-intro { display: grid; grid-template-columns: auto minmax(0, 1fr); }
.password-security-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.password-security-metric { min-height: 74px; padding: 12px; gap: 8px; }
.password-security-metric > span { width: 32px; height: 32px; border-radius: 10px; }
.password-security-metric strong { font-size: 18px; }
.password-security-item { align-items: stretch; flex-direction: column; gap: 8px; }
.password-security-item-actions { width: 100%; }
.password-security-item-actions > * { flex: 1 1 0; min-height: 40px; }
}
:root[data-theme='dark'] .password-security-metric.warning > span,
:root[data-theme='dark'] .password-security-notice.warning,
:root[data-theme='dark'] .risk-badge.reused,
:root[data-theme='dark'] .password-breach-inline.warning { color: #fbbf24; background: rgba(180, 83, 9, .18); border-color: rgba(251, 191, 36, .25); }
:root[data-theme='dark'] .risk-badge.danger { color: #fca5a5; background: rgba(180, 35, 24, .2); }
:root[data-theme='dark'] .risk-badge.weak { color: #d8b4fe; background: rgba(91, 33, 182, .22); }
:root[data-theme='dark'] .password-breach-inline.safe { color: #6ee7b7; background: rgba(22, 112, 77, .2); border-color: rgba(110, 231, 183, .25); }
:root[data-theme='dark'] .password-breach-inline.danger { color: #fca5a5; background: rgba(180, 35, 24, .2); border-color: rgba(252, 165, 165, .25); }
+1 -1
View File
@@ -234,7 +234,7 @@
.mobile-tabbar { .mobile-tabbar {
@apply grid items-center gap-1.5; @apply grid items-center gap-1.5;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
min-height: var(--mobile-tabbar-height); min-height: var(--mobile-tabbar-height);
padding: 8px 10px calc(8px + env(safe-area-inset-bottom)); padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
+49 -59
View File
@@ -102,6 +102,28 @@ async function appShellNavigation(request) {
); );
} }
async function connectorNavigation(request) {
const runtimeCache = await caches.open(RUNTIME_CACHE);
try {
const response = await fetch(request);
if (isCacheableResponse(response)) {
await runtimeCache.put(request, response.clone());
await trimRuntimeCache(runtimeCache, 120);
}
return response;
} catch {
const shellCache = await caches.open(APP_SHELL_CACHE);
const cached =
(await shellCache.match(request, { ignoreSearch: true }))
|| (await runtimeCache.match(request, { ignoreSearch: true }))
|| (await matchLegacyRuntimeCache(request));
return cached || new Response('WebAuthn connector is unavailable while offline.', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=UTF-8' },
});
}
}
async function trimRuntimeCache(cache, maxEntries) { async function trimRuntimeCache(cache, maxEntries) {
const keys = await cache.keys(); const keys = await cache.keys();
if (keys.length <= maxEntries) return; if (keys.length <= maxEntries) return;
@@ -145,6 +167,13 @@ self.addEventListener('fetch', (event) => {
const url = new URL(request.url); const url = new URL(request.url);
if (NEVER_CACHE_PATH_RE.test(url.pathname)) return; if (NEVER_CACHE_PATH_RE.test(url.pathname)) return;
// Connector navigations are protocol pages, not application routes. They must
// never be replaced with the SPA shell, even when the device is offline.
if (url.pathname.endsWith('-connector.html')) {
event.respondWith(connectorNavigation(request));
return;
}
if (request.mode === 'navigate') { if (request.mode === 'navigate') {
event.respondWith(appShellNavigation(request)); event.respondWith(appShellNavigation(request));
if (navigator.onLine !== false) { if (navigator.onLine !== false) {
@@ -229,39 +258,12 @@ function searchIndexPolicyPlugin(isDemo: boolean): Plugin {
}; };
} }
function resourcePriorityPlugin(isDemo: boolean): Plugin {
return {
name: 'nodewarden-resource-priority',
enforce: 'post' as const,
transformIndexHtml(html: string) {
if (isDemo || !html.includes('/assets/app-suite-')) return html;
const scriptMatch = html.match(/^\s*<script type="module" crossorigin src="\/assets\/index-[^"]+\.js"><\/script>\s*$/m);
const appSuiteMatch = html.match(/^\s*<link rel="modulepreload" crossorigin href="\/assets\/app-suite-[^"]+\.js">\s*$/m);
const stylesheetMatch = html.match(/^\s*<link rel="stylesheet" crossorigin href="\/assets\/index-[^"]+\.css">\s*$/m);
if (!scriptMatch || !appSuiteMatch || !stylesheetMatch) return html;
const prioritizedTags = [
stylesheetMatch[0].replace('rel="stylesheet"', 'rel="stylesheet" fetchpriority="high"'),
appSuiteMatch[0].replace('rel="modulepreload"', 'rel="modulepreload" fetchpriority="high"'),
scriptMatch[0].replace('type="module"', 'type="module" fetchpriority="high"'),
].join('\n');
return html
.replace(scriptMatch[0], '')
.replace(appSuiteMatch[0], '')
.replace(stylesheetMatch[0], prioritizedTags);
},
};
}
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const isDemo = mode === 'demo'; const isDemo = mode === 'demo';
return { return {
root: rootDir, root: rootDir,
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)], plugins: [preact(), searchIndexPolicyPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
define: { define: {
__NODEWARDEN_DEMO__: JSON.stringify(isDemo), __NODEWARDEN_DEMO__: JSON.stringify(isDemo),
}, },
@@ -282,41 +284,29 @@ export default defineConfig(({ mode }) => {
sourcemap: false, sourcemap: false,
target: 'esnext', target: 'esnext',
chunkSizeWarningLimit: 800, chunkSizeWarningLimit: 800,
rollupOptions: { rolldownOptions: {
treeshake: { checks: {
preset: 'smallest', pluginTimings: false,
}, },
output: { output: {
manualChunks(id) { codeSplitting: {
groups: [
{
name: 'shared',
minShareCount: 2,
minSize: 50 * 1024,
priority: 10,
},
{
name(id) {
const normalized = id.replace(/\\/g, '/'); const normalized = id.replace(/\\/g, '/');
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/); const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
if (localeMatch) { return localeMatch && localeMatch[1] !== 'en' ? `i18n-${localeMatch[1]}` : null;
if (localeMatch[1] === 'en') return undefined; },
return `i18n-${localeMatch[1]}`; test: /[\\/]src[\\/]lib[\\/]i18n[\\/]locales[\\/]/,
} priority: 20,
},
if ( ],
!isDemo &&
(
normalized.includes('/src/components/VaultPage.tsx') ||
normalized.includes('/src/components/ImportPage.tsx') ||
normalized.includes('/src/lib/import-') ||
normalized.includes('/src/lib/export-formats.ts') ||
normalized.includes('/src/components/SendsPage.tsx') ||
normalized.includes('/src/components/TotpCodesPage.tsx') ||
normalized.includes('/src/components/DomainRulesPage.tsx') ||
normalized.includes('/src/components/BackupCenterPage.tsx') ||
normalized.includes('/src/components/backup-center/') ||
normalized.includes('/src/components/SettingsPage.tsx') ||
normalized.includes('/src/components/SecurityDevicesPage.tsx') ||
normalized.includes('/src/components/AdminPage.tsx')
)
) {
return 'app-suite';
}
return undefined;
}, },
}, },
}, },
+2 -1
View File
@@ -8,8 +8,9 @@ command = "npm run build"
[assets] [assets]
binding = "ASSETS" binding = "ASSETS"
directory = "./dist" directory = "./dist"
html_handling = "none"
not_found_handling = "single-page-application" not_found_handling = "single-page-application"
run_worker_first = false run_worker_first = true
[triggers] [triggers]
crons = [ "*/5 * * * *" ] crons = [ "*/5 * * * *" ]
+2 -1
View File
@@ -8,8 +8,9 @@ command = "npm run build"
[assets] [assets]
binding = "ASSETS" binding = "ASSETS"
directory = "./dist" directory = "./dist"
html_handling = "none"
not_found_handling = "single-page-application" not_found_handling = "single-page-application"
run_worker_first = false run_worker_first = true
[triggers] [triggers]
crons = [ "*/5 * * * *" ] crons = [ "*/5 * * * *" ]