mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-11-04 02:40:20 +00:00
Compare commits
36
Commits
v1.8.0
...
2e9561d365
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e9561d365 | ||
|
|
cb871095c1 | ||
|
|
a8d0e70263 | ||
|
|
470232d140 | ||
|
|
7a5c365dae | ||
|
|
e92c91e771 | ||
|
|
02081ea07f | ||
|
|
4c241aa64b | ||
|
|
293f5ce196 | ||
|
|
5f71cb388c | ||
|
|
588dce6a0a | ||
|
|
589557a757 | ||
|
|
99d404f7cb | ||
|
|
a72592e76e | ||
|
|
e63f9663e8 | ||
|
|
df6b0b9767 | ||
|
|
64037695b8 | ||
|
|
d43f21e8e0 | ||
|
|
e4368e6129 | ||
|
|
00433a7cda | ||
|
|
13bb0a0308 | ||
|
|
b3630bdaf5 | ||
|
|
38b0ff6263 | ||
|
|
ecc0d134ac | ||
|
|
f644baaf8d | ||
|
|
fb627f59f0 | ||
|
|
652952379b | ||
|
|
34fd2f0259 | ||
|
|
df493b4697 | ||
|
|
f6403b8cab | ||
|
|
a0128908e1 | ||
|
|
8e5d9e294b | ||
|
|
6ffdf05dc6 | ||
|
|
82d9f61163 | ||
|
|
f761fffd58 | ||
|
|
f5cc02b93f |
No files matched your search
@@ -18,6 +18,9 @@ updates:
|
||||
- dependency-name: "tailwindcss"
|
||||
update-types:
|
||||
- "version-update:semver-major"
|
||||
- dependency-name: "@tailwindcss/*"
|
||||
update-types:
|
||||
- "version-update:semver-major"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
|
||||
@@ -44,6 +44,7 @@ tmp/
|
||||
.tmp-bitwarden-clients/
|
||||
|
||||
nodewarden-wiki/
|
||||
nodewarden.wiki/
|
||||
wiki/
|
||||
AGENTS.md
|
||||
settings.json
|
||||
@@ -57,6 +58,7 @@ NodeWarden-compat/
|
||||
|
||||
.reasonix/
|
||||
.upstream/
|
||||
bitwarden-upstream/
|
||||
|
||||
# Compatibility analysis documents
|
||||
BITWARDEN_COMPATIBILITY_ANALYSIS.md
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
24.18.0
|
||||
@@ -98,6 +98,14 @@ Password hints are reminders, not recovery secrets. They must never contain the
|
||||
master password, recovery codes, API keys, or anything that directly unlocks the
|
||||
vault.
|
||||
|
||||
### Web Vault Styles
|
||||
|
||||
The web vault uses Tailwind CSS 4 through `@tailwindcss/vite`. Theme aliases,
|
||||
dark-mode variants, and source paths are defined in `webapp/src/tailwind.css`,
|
||||
which also imports `styles.css` so component `@apply` rules share the same theme.
|
||||
Keep runtime light/dark design tokens in `webapp/src/styles/tokens.css`.
|
||||
Tailwind 4's browser baseline is Safari 16.4+, Chrome 111+, and Firefox 128+.
|
||||
|
||||
### i18n
|
||||
|
||||
Locale files are complete standalone bundles. When adding or changing user-facing
|
||||
@@ -111,6 +119,15 @@ For new locales, update:
|
||||
|
||||
## Recommended Checks
|
||||
|
||||
Use the Node.js version in `.node-version` for local and Cloudflare builds.
|
||||
Cloudflare Pages and Workers Builds read this file automatically. If a project
|
||||
sets `NODE_VERSION` in its dashboard, keep it aligned with this file.
|
||||
|
||||
When regenerating the lockfile, use a directory without `node_modules` and
|
||||
verify `npm ci` with npm 10.9.2, the version used by Cloudflare's build image.
|
||||
Include optional dependencies so the lockfile also covers native and WASM
|
||||
dependencies on other platforms.
|
||||
|
||||
For most backend or shared changes:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -91,6 +91,25 @@
|
||||
> | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||
|
||||
|
||||
## FAQ
|
||||
|
||||
- **After forking the repository, why can't I see my repository when connecting GitHub to Cloudflare, or why do I get a 404 after selecting it?**
|
||||
This is usually related to how the GitHub fork is identified or how Cloudflare handles repository authorization and synchronization. If the fork keeps a repository name, description, or other information that is very similar to the upstream project, it may be more likely to trigger related restrictions or issues. It is recommended to rename the repository to something different from the upstream project when creating the fork, and change the repository description as well. For example, you can rename it to `2233warden`. If you have already created the fork, you can rename the repository and update its description in the GitHub repository settings, then try connecting it to Cloudflare again.
|
||||
|
||||
- **I deleted my deployment and redeployed it. Why does registration require an invite code again?**
|
||||
Deleting the Worker or redeploying it does not automatically delete the persistent data that was already created. The users, invite codes, and related configuration stored in the D1 database and KV namespace are still there, so the newly deployed Worker continues to read the existing data and enforce the invite-code requirement.
|
||||
If you want to start completely from scratch, you need to delete the corresponding **D1 database and KV namespace** as well.
|
||||
|
||||
- **I configured `JWT_SECRET`, but the page still says it is missing. Why?**
|
||||
Make sure `JWT_SECRET` is configured under **Workers → Settings → Variables and Secrets**, specifically as a **Runtime variable or Secret**, rather than under **Build variables**.
|
||||
Build-time variables are only available during the build process. They are not available to the Worker at runtime, so the build may succeed while the application still reports that `JWT_SECRET` is missing.
|
||||
|
||||
- **Why does `JWT_SECRET` seem to disappear after an upgrade or redeployment?**
|
||||
It is recommended to store `JWT_SECRET` as a **Secret** rather than as a plain-text variable. `JWT_SECRET` is a sensitive runtime credential and should not be committed to the repository.
|
||||
If your deployment process recreates or overwrites the Worker variable configuration, ordinary variables may be affected. Secrets are more appropriate for sensitive configuration that needs to remain available across multiple deployments. If the application still reports that `JWT_SECRET` is missing after a redeployment, check **Variables and Secrets** for the current Worker and make sure the Secret is still configured.
|
||||
|
||||
---
|
||||
|
||||
## How to update
|
||||
|
||||
- Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork** → **Update branch**
|
||||
|
||||
@@ -91,6 +91,24 @@
|
||||
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||
|
||||
|
||||
## 常见问题:
|
||||
- **Fork 完仓库后,在 Cloudflare 连接 GitHub 账户时看不到自己的仓库,或者选择仓库后返回 404?**
|
||||
这通常与 GitHub Fork 仓库的识别或 Cloudflare 对仓库的授权/同步有关。如果 Fork 后仓库名称、描述等信息与上游项目高度一致,可能更容易触发相关限制或异常。建议在 Fork 时就将仓库名称修改为与上游不同的名称,并同时修改仓库描述,例如改为 `2233warden`。如果已经完成 Fork,也可以直接在 GitHub 仓库设置中修改名称和描述,然后重新尝试在 Cloudflare 中连接。
|
||||
|
||||
- **我删掉部署后重新部署,为什么注册又开始要求邀请码了?**
|
||||
因为删除 Worker 或重新部署并不会自动删除已经创建的持久化数据。D1 数据库和 KV 命名空间中的用户、邀请码及相关配置仍然存在,因此重新部署后仍会读取原来的数据,并继续要求邀请码。
|
||||
如果希望完全重新开始,需要同时删除对应的 **D1 数据库和 KV 命名空间**。
|
||||
|
||||
- **我配置了 `JWT_SECRET`,为什么页面仍然提示缺少?**
|
||||
请将 `JWT_SECRET` 配置在 Cloudflare Workers 的 **Settings → Variables and Secrets** 中,并确保它属于 **Runtime variables and secrets**,而不是 **Build variables**。
|
||||
Build 阶段的变量只在项目构建过程中可用,Worker 实际运行时无法通过运行时环境读取,因此即使构建能够正常完成,页面仍可能提示 `JWT_SECRET` 缺失。
|
||||
|
||||
- **为什么升级或重新部署后,`JWT_SECRET` 好像消失了?**
|
||||
建议将 `JWT_SECRET` 配置为 **Secret**,而不是普通的明文变量。`JWT_SECRET` 属于敏感的运行时凭据,也不应该出现在代码仓库中。
|
||||
如果部署流程会重新生成或覆盖 Worker 的变量配置,普通变量可能受到影响;使用 Secret 更适合保存这类需要在多次部署之间持续存在的敏感配置。重新部署后如果仍提示缺失,请检查当前 Worker 的 **Variables and Secrets** 中是否仍存在该 Secret。
|
||||
|
||||
---
|
||||
|
||||
## 更新方法:
|
||||
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||
|
||||
|
||||
@@ -21,6 +21,70 @@ Release note writing rules:
|
||||
7. The English and Chinese versions should match in content and ordering, not be two different summaries.
|
||||
-->
|
||||
|
||||
# v1.8.1 - Expanded Generators, Safer Vault Editing, and Client Compatibility
|
||||
|
||||
### Added
|
||||
|
||||
1. **More local generation tools.** The generator now creates numeric PINs, memorable usernames, email aliases, and Ed25519 or RSA SSH key pairs alongside passwords and passphrases. Passwords support minimum counts for each character type and an option to avoid ambiguous characters, while passphrases and usernames can use custom word lists. SSH keys are generated on the device and are not retained by NodeWarden, so save the private key before leaving the page. Commit: [df493b4](https://github.com/shuaiplus/nodewarden/commit/df493b46972bf864e9dad11370ed5d48de06af2a).
|
||||
|
||||
### Improved
|
||||
|
||||
1. **Current Bitwarden client compatibility.** Configuration, prelogin, and account-key responses now include fields expected by Bitwarden 2026.7 clients while retaining older response formats. Supported desktop clients also show the official settings dialog with browser integration controls. Commits: [a012890](https://github.com/shuaiplus/nodewarden/commit/a0128908e18557511590bcb06e13c8799b427343), [6ffdf05](https://github.com/shuaiplus/nodewarden/commit/6ffdf05dc65a0fcd3f60cfe586a04a31e19d6de3).
|
||||
|
||||
1. **Stronger account authentication with automatic migration.** New registrations and password changes use randomly salted server-side password verifiers, and existing accounts upgrade after a successful password login and any required two-factor verification. Existing vault keys and sessions are preserved, and failed logins use more consistent responses and verification work to reduce account discovery through errors or timing. Commit: [5f71cb3](https://github.com/shuaiplus/nodewarden/commit/5f71cb388c061a640e5f2e372e79fa6059f0e26b).
|
||||
|
||||
1. **Clearer deployment and build guidance.** Builds now declare support for Node.js 22.19+ within the 22.x series or Node.js 24.11+, with `.node-version` selecting 24.18.0; align any Cloudflare dashboard `NODE_VERSION` override with that file when upgrading. The deployment FAQ also explains repository connection problems, persistent registration data, and where to configure runtime secrets. Commits: [02081ea](https://github.com/shuaiplus/nodewarden/commit/02081ea07f07501330ba9cfcf1ff4611466def2e), [99d404f](https://github.com/shuaiplus/nodewarden/commit/99d404f7cbf47fa094ea9ed153c16e8f231ded56).
|
||||
|
||||
### Fixed
|
||||
|
||||
1. **Vault edits preserve fields and detect conflicting saves.** Web edits now retain linked custom-field metadata and unrecognized encrypted properties in supported item types. Outdated and concurrent saves are rejected instead of overwriting newer changes, and the editor keeps your draft with instructions to reopen the item. Official bulk delete and restore methods are also supported alongside legacy routes, with restore returning the restored items. Commit: [293f5ce](https://github.com/shuaiplus/nodewarden/commit/293f5ce19685855f393226c8dceaadd9af8a8782).
|
||||
|
||||
1. **Cleared notes stay cleared.** Full item updates now treat omitted notes and custom fields as cleared values, matching clients that leave these properties out after deletion. Previously saved encrypted notes no longer reappear through fallback merging. Commit: [a72592e](https://github.com/shuaiplus/nodewarden/commit/a72592e76e80f1e31eebde0c0216d2a77004440f).
|
||||
|
||||
1. **Safe duplicate selection across large vaults.** Duplicate groups now keep distinct identities even when there are more than 64 groups. Cleanup selection no longer confuses later groups with earlier ones or selects every item in an affected group. Commit: [df6b0b9](https://github.com/shuaiplus/nodewarden/commit/df6b0b97675ce69efba68f7895291ba67436208e).
|
||||
|
||||
1. **WebAuthn verification in official clients.** Desktop, browser-extension, and mobile connectors now follow Bitwarden's request and response formats, including mobile callbacks and supported desktop origins. Exact connector URLs are preserved in Worker deployments, avoiding redirects and framing restrictions that could interrupt security-key verification. Commit: [f761fff](https://github.com/shuaiplus/nodewarden/commit/f761fffd58ff7b6fd510ae57a1d409d247728a1b).
|
||||
|
||||
1. **Helpful registration errors when browser cryptography is unavailable.** Registration now detects insecure or unsupported browser contexts before creating account keys and shows localized guidance to use HTTPS in a supported browser. This replaces an unreadable cryptography runtime error with an actionable message. Commit: [82d9f61](https://github.com/shuaiplus/nodewarden/commit/82d9f61163f6d29803e3a3264b8f986dfa992ebc).
|
||||
|
||||
1. **Visible controls and values in narrow panels.** Remote backup rows now reflow according to the panel's available width, vault toolbars and authorized-device actions wrap, and item-detail values retain usable space. These changes prevent buttons and values from being clipped, while links, backup controls, and TOTP indicators follow the selected theme color more consistently. Commits: [589557a](https://github.com/shuaiplus/nodewarden/commit/589557a75737134574861f568b44703c4881795d), [38b0ff6](https://github.com/shuaiplus/nodewarden/commit/38b0ff6263e54e54e2fcc7f6c7ea3e6f34bb8c08), [ecc0d13](https://github.com/shuaiplus/nodewarden/commit/ecc0d134ac732d29240e6d5ba84e9798be222f17), [f644baa](https://github.com/shuaiplus/nodewarden/commit/f644baaf8d23df2b646d2c1c815bd6d7e1e60e26), [fb627f5](https://github.com/shuaiplus/nodewarden/commit/fb627f59f0ee6cfc396634b3f1674cc09a22e186).
|
||||
|
||||
1. **Credentials no longer accepted in sensitive request URLs.** Notification connections now use short-lived, single-use tickets obtained through authenticated negotiation instead of accepting access JWTs from URL query parameters. Admin backup attachment downloads require POST with a JSON body; integrations using the old GET endpoint must switch to POST so master-password verification credentials do not enter URL logs or browser history. Commits: [e63f966](https://github.com/shuaiplus/nodewarden/commit/e63f9663e845817268f842fff6c628ef921652db), [6529523](https://github.com/shuaiplus/nodewarden/commit/652952379b5d1d8350dc17096b3125919add4884).
|
||||
|
||||
1. **Updated dependencies and removal of vulnerable build tooling.** Dependency updates include Nano ID 3.3.18, Undici 8.10.2, Sharp 0.35.4, and a PostCSS path-traversal fix. The Web Vault also moves to Tailwind CSS 4, removing the vulnerable `braces` dependency from its previous build chain. Commits: [d43f21e](https://github.com/shuaiplus/nodewarden/commit/d43f21e8e03847df1ce81f2dc9534c23f82f54cb), [293f5ce](https://github.com/shuaiplus/nodewarden/commit/293f5ce19685855f393226c8dceaadd9af8a8782), [588dce6](https://github.com/shuaiplus/nodewarden/commit/588dce6a0a3ec67bd48fbbca7f250e181f4bc45f), [f6403b8](https://github.com/shuaiplus/nodewarden/commit/f6403b8cab98abfb456db66e343039c5a803c01b), [4c241aa](https://github.com/shuaiplus/nodewarden/commit/4c241aa64bff3c2833468f8dd5974cc856089aed).
|
||||
|
||||
---
|
||||
|
||||
### 新增
|
||||
|
||||
1. **更多本地生成工具。** 生成器在密码和口令短语之外,新增数字 PIN、易记用户名、邮箱别名以及 Ed25519 或 RSA SSH 密钥对。密码可设置各类字符的最少数量并排除易混淆字符,口令短语和用户名可使用自定义词表。SSH 密钥在设备本地生成,NodeWarden 不会保留,请在离开页面前保存私钥。提交:[df493b4](https://github.com/shuaiplus/nodewarden/commit/df493b46972bf864e9dad11370ed5d48de06af2a)。
|
||||
|
||||
### 改进
|
||||
|
||||
1. **兼容新版 Bitwarden 客户端。** 配置、预登录和账户密钥响应补齐了 Bitwarden 2026.7 客户端所需字段,同时保留旧版响应格式。受支持的桌面客户端也会显示包含浏览器集成选项的官方设置对话框。提交:[a012890](https://github.com/shuaiplus/nodewarden/commit/a0128908e18557511590bcb06e13c8799b427343)、[6ffdf05](https://github.com/shuaiplus/nodewarden/commit/6ffdf05dc65a0fcd3f60cfe586a04a31e19d6de3)。
|
||||
|
||||
1. **更安全的账户认证与自动迁移。** 新注册和修改密码使用带随机盐的服务端密码校验数据,已有账户在密码登录及所需双因素验证成功后自动升级。升级保留原有保险库密钥和会话,失败登录也采用更一致的响应与验证计算,减少通过错误信息或响应耗时探测账户的机会。提交:[5f71cb3](https://github.com/shuaiplus/nodewarden/commit/5f71cb388c061a640e5f2e372e79fa6059f0e26b)。
|
||||
|
||||
1. **更清晰的部署与构建说明。** 构建现已声明支持 Node.js 22.x 系列中的 22.19 及以上版本,或 Node.js 24.11 及以上版本;`.node-version` 指定使用 24.18.0,升级时请同步调整 Cloudflare 控制台中覆盖该文件的 `NODE_VERSION` 设置。部署常见问题也补充了仓库连接异常、注册数据持久化和运行时 Secret 配置位置的说明。提交:[02081ea](https://github.com/shuaiplus/nodewarden/commit/02081ea07f07501330ba9cfcf1ff4611466def2e)、[99d404f](https://github.com/shuaiplus/nodewarden/commit/99d404f7cbf47fa094ea9ed153c16e8f231ded56)。
|
||||
|
||||
### 修复
|
||||
|
||||
1. **编辑条目保留字段并识别保存冲突。** Web 编辑现在会保留关联自定义字段的元数据,以及受支持条目类型中尚未识别的加密属性。过期或并发保存会被拒绝,避免覆盖其他客户端的新修改;编辑器保留草稿,并提示重新打开条目。同时兼容官方批量删除、恢复方法和旧版路由,恢复操作会返回已恢复的条目。提交:[293f5ce](https://github.com/shuaiplus/nodewarden/commit/293f5ce19685855f393226c8dceaadd9af8a8782)。
|
||||
|
||||
1. **已清空的备注不再重新出现。** 完整更新条目时,省略的备注和自定义字段现在会按已清空处理,兼容删除后不再发送这些属性的客户端。旧的加密备注不会再被回退合并逻辑恢复。提交:[a72592e](https://github.com/shuaiplus/nodewarden/commit/a72592e76e80f1e31eebde0c0216d2a77004440f)。
|
||||
|
||||
1. **大量重复分组下正确选择待清理条目。** 重复分组超过 64 组时,各组仍会保留独立标识。清理选择不再混淆前后的分组,也不会误选受影响分组中的全部条目。提交:[df6b0b9](https://github.com/shuaiplus/nodewarden/commit/df6b0b97675ce69efba68f7895291ba67436208e)。
|
||||
|
||||
1. **官方客户端的 WebAuthn 验证。** 桌面端、浏览器扩展和移动端连接页面现已遵循 Bitwarden 的请求与响应格式,包括移动端回调和受支持的桌面端来源。Worker 部署保留连接页面的精确 URL,避免重定向和嵌入限制中断安全密钥验证。提交:[f761fff](https://github.com/shuaiplus/nodewarden/commit/f761fffd58ff7b6fd510ae57a1d409d247728a1b)。
|
||||
|
||||
1. **浏览器加密能力不可用时提供明确注册提示。** 注册现在会在创建账户密钥前检查不安全或不受支持的浏览器环境,并通过本地化提示引导用户使用受支持的浏览器和 HTTPS。原先难以理解的加密运行时错误改为可直接处理的提示。提交:[82d9f61](https://github.com/shuaiplus/nodewarden/commit/82d9f61163f6d29803e3a3264b8f986dfa992ebc)。
|
||||
|
||||
1. **窄面板中的按钮和内容保持可见。** 远端备份列表现在按面板可用宽度重新排版,保险库工具栏和已授权设备操作按钮可自动换行,条目详情的值也保留可读空间,避免按钮或内容被裁切。链接、备份控件和 TOTP 指示器也会更一致地使用所选主题颜色。提交:[589557a](https://github.com/shuaiplus/nodewarden/commit/589557a75737134574861f568b44703c4881795d)、[38b0ff6](https://github.com/shuaiplus/nodewarden/commit/38b0ff6263e54e54e2fcc7f6c7ea3e6f34bb8c08)、[ecc0d13](https://github.com/shuaiplus/nodewarden/commit/ecc0d134ac732d29240e6d5ba84e9798be222f17)、[f644baa](https://github.com/shuaiplus/nodewarden/commit/f644baaf8d23df2b646d2c1c815bd6d7e1e60e26)、[fb627f5](https://github.com/shuaiplus/nodewarden/commit/fb627f59f0ee6cfc396634b3f1674cc09a22e186)。
|
||||
|
||||
1. **敏感请求不再接受 URL 中的认证凭据。** 通知连接现在使用认证协商后取得的短期一次性票据,不再接受 URL 查询参数中的访问 JWT。管理员备份附件下载仅接受带 JSON 请求体的 POST;使用旧 GET 接口的集成需要改为 POST,避免主密码验证凭据进入 URL 日志或浏览器历史。提交:[e63f966](https://github.com/shuaiplus/nodewarden/commit/e63f9663e845817268f842fff6c628ef921652db)、[6529523](https://github.com/shuaiplus/nodewarden/commit/652952379b5d1d8350dc17096b3125919add4884)。
|
||||
|
||||
1. **更新依赖并移除存在漏洞的构建工具依赖。** 依赖更新包括 Nano ID 3.3.18、Undici 8.10.2、Sharp 0.35.4,以及 PostCSS 路径穿越问题的修复。Web Vault 同时迁移至 Tailwind CSS 4,移除了旧构建链中存在漏洞的 `braces` 依赖。提交:[d43f21e](https://github.com/shuaiplus/nodewarden/commit/d43f21e8e03847df1ce81f2dc9534c23f82f54cb)、[293f5ce](https://github.com/shuaiplus/nodewarden/commit/293f5ce19685855f393226c8dceaadd9af8a8782)、[588dce6](https://github.com/shuaiplus/nodewarden/commit/588dce6a0a3ec67bd48fbbca7f250e181f4bc45f)、[f6403b8](https://github.com/shuaiplus/nodewarden/commit/f6403b8cab98abfb456db66e343039c5a803c01b)、[4c241aa](https://github.com/shuaiplus/nodewarden/commit/4c241aa64bff3c2833468f8dd5974cc856089aed)。
|
||||
|
||||
# v1.8.0 - Deployment Control, Session Reliability, and Compatibility Fixes
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+1081
-1387
File diff suppressed because it is too large.
Load diff
+31
-18
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||
"author": "shuaiplus",
|
||||
"license": "LGPL-3.0",
|
||||
"main": "src/index.ts",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^22.19.0 || >=24.11.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "wrangler dev -c wrangler.toml",
|
||||
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
||||
@@ -15,6 +18,13 @@
|
||||
"domains:sync": "node scripts/sync-global-domains.mjs",
|
||||
"i18n": "node scripts/i18n-validate.cjs",
|
||||
"i18n:validate": "node scripts/i18n-validate.cjs",
|
||||
"test:config-compatibility": "tsx --test scripts/config-compatibility.test.ts",
|
||||
"test:vault-compatibility": "tsx --tsconfig webapp/tsconfig.json --import ./scripts/offline-workers-loader.mjs --test scripts/vault-compatibility.test.ts",
|
||||
"test:web-crypto": "tsx --test scripts/web-crypto-availability.test.ts",
|
||||
"test:notifications-security": "tsx --test scripts/notifications-security.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: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"
|
||||
@@ -43,34 +53,37 @@
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"undici": ">=7.28.0",
|
||||
"nanoid": "3.3.18",
|
||||
"undici": "8.10.2",
|
||||
"@babel/core": ">=7.29.6",
|
||||
"esbuild": ">=0.28.1",
|
||||
"ws": "8.21.0"
|
||||
"ws": "8.21.0",
|
||||
"sharp": "0.35.4",
|
||||
"browserslist": "4.28.7",
|
||||
"baseline-browser-mapping": "2.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260630.1",
|
||||
"@preact/preset-vite": "^2.10.5",
|
||||
"@types/node": "^26.0.1",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"opencc-js": "^1.3.2",
|
||||
"postcss": "^8.5.16",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tsx": "^4.22.4",
|
||||
"@preact/preset-vite": "^2.10.6",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.6.4",
|
||||
"opencc-js": "^1.4.2",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.3",
|
||||
"wrangler": "^4.105.0"
|
||||
"vite": "^8.3.2",
|
||||
"wrangler": "^4.146.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@zip.js/zip.js": "^2.8.26",
|
||||
"@tanstack/react-query": "^5.104.0",
|
||||
"@zip.js/zip.js": "^2.22.0",
|
||||
"fflate": "^0.8.3",
|
||||
"jsqr": "1.4.0",
|
||||
"lucide-preact": "^1.22.0",
|
||||
"preact": "^10.29.3",
|
||||
"lucide-preact": "^1.49.0",
|
||||
"preact": "^11.0.1",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"wouter": "^3.10.0"
|
||||
"wouter": "^3.13.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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,183 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { handleNotificationsHub, handleNotificationsNegotiate } from '../src/handlers/notifications';
|
||||
import type { Env } from '../src/types';
|
||||
import { createJWT } from '../src/utils/jwt';
|
||||
|
||||
const secret = 'notification-security-test-secret-32-bytes';
|
||||
const userId = 'd75020e1-2de4-46e8-b8f1-475d127b51f2';
|
||||
const securityStamp = 'security-stamp';
|
||||
|
||||
function createTestEnv() {
|
||||
const connectionTokens = new Map<string, { userId: string; deviceIdentifier: string | null; expiresAt: number }>();
|
||||
const forwardedHubUrls: string[] = [];
|
||||
const durableObjectNames: string[] = [];
|
||||
const userRow = {
|
||||
id: userId,
|
||||
email: 'user@example.test',
|
||||
name: 'Test User',
|
||||
master_password_hint: null,
|
||||
master_password_hash: 'hash',
|
||||
key: 'key',
|
||||
private_key: null,
|
||||
public_key: null,
|
||||
kdf_type: 0,
|
||||
kdf_iterations: 600000,
|
||||
kdf_memory: null,
|
||||
kdf_parallelism: null,
|
||||
security_stamp: securityStamp,
|
||||
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: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const db = {
|
||||
prepare() {
|
||||
return {
|
||||
bind() {
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
return userRow;
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as D1Database;
|
||||
|
||||
const stub = {
|
||||
async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const request = new Request(input, init);
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === '/internal/ws-token') {
|
||||
const body = await request.json() as {
|
||||
token: string;
|
||||
userId: string;
|
||||
deviceIdentifier: string | null;
|
||||
expiresAt: number;
|
||||
};
|
||||
connectionTokens.set(body.token, body);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (url.pathname === '/internal/ws-token/consume') {
|
||||
const { token } = await request.json() as { token: string };
|
||||
const connection = connectionTokens.get(token);
|
||||
connectionTokens.delete(token);
|
||||
if (!connection || connection.expiresAt <= Date.now()) return new Response(null, { status: 401 });
|
||||
return Response.json(connection);
|
||||
}
|
||||
forwardedHubUrls.push(request.url);
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
};
|
||||
|
||||
const env = {
|
||||
DB: db,
|
||||
JWT_SECRET: secret,
|
||||
NOTIFICATIONS_HUB: {
|
||||
idFromName(name: string) {
|
||||
durableObjectNames.push(name);
|
||||
return name;
|
||||
},
|
||||
get() {
|
||||
return stub;
|
||||
},
|
||||
},
|
||||
} as unknown as Env;
|
||||
|
||||
return { env, connectionTokens, durableObjectNames, forwardedHubUrls };
|
||||
}
|
||||
|
||||
async function validAccessToken(): Promise<string> {
|
||||
return createJWT({
|
||||
sub: userId,
|
||||
email: 'user@example.test',
|
||||
name: 'Test User',
|
||||
sstamp: securityStamp,
|
||||
}, secret);
|
||||
}
|
||||
|
||||
test('query access_token cannot authenticate a websocket', async () => {
|
||||
const { env, forwardedHubUrls } = createTestEnv();
|
||||
const token = await validAccessToken();
|
||||
const response = await handleNotificationsHub(new Request(
|
||||
`https://vault.example.test/notifications/hub?access_token=${encodeURIComponent(token)}`,
|
||||
{ headers: { Upgrade: 'websocket' } }
|
||||
), env);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(forwardedHubUrls, []);
|
||||
});
|
||||
|
||||
test('Authorization bearer token still authenticates notifications', async () => {
|
||||
const { env, forwardedHubUrls } = createTestEnv();
|
||||
const token = await validAccessToken();
|
||||
const response = await handleNotificationsHub(new Request(
|
||||
'https://vault.example.test/notifications/hub',
|
||||
{ headers: { Authorization: `Bearer ${token}`, Upgrade: 'websocket' } }
|
||||
), env);
|
||||
|
||||
assert.equal(response.status, 204);
|
||||
assert.equal(new URL(forwardedHubUrls[0]).searchParams.get('nw_uid'), userId);
|
||||
});
|
||||
|
||||
test('negotiate issues a short-lived one-time websocket connection token', async () => {
|
||||
const { env, connectionTokens, forwardedHubUrls } = createTestEnv();
|
||||
const accessToken = await validAccessToken();
|
||||
const negotiate = await handleNotificationsNegotiate(new Request(
|
||||
'https://vault.example.test/notifications/hub/negotiate',
|
||||
{ method: 'POST', headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
), env);
|
||||
const body = await negotiate.json() as { connectionToken: string };
|
||||
const stored = connectionTokens.get(body.connectionToken);
|
||||
|
||||
assert.equal(negotiate.status, 200);
|
||||
assert.ok(stored);
|
||||
assert.ok(stored.expiresAt > Date.now());
|
||||
assert.ok(stored.expiresAt <= Date.now() + 60_000);
|
||||
|
||||
const request = () => new Request(
|
||||
`https://vault.example.test/notifications/hub?id=${encodeURIComponent(body.connectionToken)}`,
|
||||
{ headers: { Upgrade: 'websocket' } }
|
||||
);
|
||||
assert.equal((await handleNotificationsHub(request(), env)).status, 204);
|
||||
assert.equal((await handleNotificationsHub(request(), env)).status, 401);
|
||||
assert.equal(forwardedHubUrls.length, 1);
|
||||
});
|
||||
|
||||
test('a non-upgrade request does not consume a websocket connection token', async () => {
|
||||
const { env, connectionTokens } = createTestEnv();
|
||||
const accessToken = await validAccessToken();
|
||||
const negotiate = await handleNotificationsNegotiate(new Request(
|
||||
'https://vault.example.test/notifications/hub/negotiate',
|
||||
{ method: 'POST', headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
), env);
|
||||
const { connectionToken } = await negotiate.json() as { connectionToken: string };
|
||||
const url = `https://vault.example.test/notifications/hub?id=${encodeURIComponent(connectionToken)}`;
|
||||
|
||||
assert.equal((await handleNotificationsHub(new Request(url), env)).status, 426);
|
||||
assert.ok(connectionTokens.has(connectionToken));
|
||||
assert.equal((await handleNotificationsHub(new Request(url, { headers: { Upgrade: 'websocket' } }), env)).status, 204);
|
||||
});
|
||||
|
||||
test('a forged ticket cannot select or activate a Durable Object', async () => {
|
||||
const { env, durableObjectNames } = createTestEnv();
|
||||
const response = await handleNotificationsHub(new Request(
|
||||
'https://vault.example.test/notifications/hub?id=attacker-controlled.invalid-signature',
|
||||
{ headers: { Upgrade: 'websocket' } }
|
||||
), env);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(durableObjectNames, []);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { registerHooks } from 'node:module';
|
||||
|
||||
// Vite injects this build constant into modules shared with Web draft helpers.
|
||||
globalThis.__NODEWARDEN_DEMO__ = false;
|
||||
|
||||
// Tests exercise routes and storage without running Cloudflare Durable Objects.
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier === 'cloudflare:workers') {
|
||||
const source = 'export class DurableObject {} export function waitUntil(p) { p.catch(() => {}); }';
|
||||
return { url: 'data:text/javascript,' + encodeURIComponent(source), shortCircuit: true };
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { type TestContext } from 'node:test';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { StorageService } from '../src/services/storage';
|
||||
import { handleAuthenticatedRoute } from '../src/router-authenticated';
|
||||
import { cipherToResponse } from '../src/handlers/ciphers';
|
||||
import { bytesToBase64, encryptBw, decryptStr } from '../webapp/src/lib/crypto';
|
||||
import { decryptSingleCipher } from '../webapp/src/lib/decrypt-cipher';
|
||||
import { updateCipher, buildCipherImportPayload, repairCipherKeyMismatches } from '../webapp/src/lib/api/vault';
|
||||
import { importCipherToDraft } from '../webapp/src/lib/app-support';
|
||||
import { draftFromCipher } from '../webapp/src/components/vault/vault-page-helpers';
|
||||
import { t as translate } from '../webapp/src/lib/i18n';
|
||||
|
||||
const owner = '00000000-0000-4000-8000-000000000001';
|
||||
const otherOwner = '00000000-0000-4000-8000-000000000002';
|
||||
const id = '00000000-0000-4000-8000-000000000003';
|
||||
const otherId = '00000000-0000-4000-8000-000000000004';
|
||||
const attachmentId = '00000000-0000-4000-8000-000000000005';
|
||||
const revision = '2026-10-04T00:00:00.000Z';
|
||||
const enc = new Uint8Array(32).fill(1);
|
||||
const mac = new Uint8Array(32).fill(2);
|
||||
const session: any = { symEncKey: bytesToBase64(enc), symMacKey: bytesToBase64(mac) };
|
||||
const encrypt = (value: string) => encryptBw(new TextEncoder().encode(value), enc, mac);
|
||||
|
||||
async function fixture(ctx: TestContext) {
|
||||
// Execute production SQL in a fresh in-memory SQLite database. Only the D1
|
||||
// result wrapper and unrelated audit/notification integrations are substituted.
|
||||
const sqlite = new DatabaseSync(':memory:');
|
||||
ctx.after(async () => { await new Promise((resolve) => setImmediate(resolve)); sqlite.close(); });
|
||||
sqlite.exec(`
|
||||
CREATE TABLE ciphers(id TEXT PRIMARY KEY, user_id TEXT, type INTEGER,
|
||||
folder_id TEXT, name TEXT, notes TEXT, favorite INTEGER, data TEXT,
|
||||
reprompt INTEGER, key TEXT, created_at TEXT, updated_at TEXT,
|
||||
archived_at TEXT, deleted_at TEXT);
|
||||
CREATE TABLE attachments(id TEXT PRIMARY KEY, cipher_id TEXT,
|
||||
file_name TEXT, size INTEGER, size_name TEXT, key TEXT);
|
||||
CREATE TABLE devices(user_id TEXT, push_token TEXT);
|
||||
`);
|
||||
const db: any = {
|
||||
prepare(sql: string) {
|
||||
let values: any[] = [];
|
||||
const statement = {
|
||||
bind(...bound: any[]) { values = bound; return statement; },
|
||||
async run() { const result = sqlite.prepare(sql).run(...values); return { success: true, meta: { changes: Number(result.changes) } }; },
|
||||
async all() { return { success: true, results: sqlite.prepare(sql).all(...values) }; },
|
||||
async first() { return sqlite.prepare(sql).get(...values) ?? null; },
|
||||
};
|
||||
return statement;
|
||||
},
|
||||
};
|
||||
ctx.mock.method(StorageService.prototype, 'updateRevisionDate', async () => revision);
|
||||
ctx.mock.method(StorageService.prototype, 'createAuditLog', async () => {});
|
||||
ctx.mock.method(StorageService.prototype, 'getConfigValue', async () => null);
|
||||
ctx.mock.method(StorageService.prototype, 'pruneAuditLogs', async () => {});
|
||||
const deletedBlobs: string[] = [];
|
||||
const notifications: any[] = [];
|
||||
const env: any = {
|
||||
DB: db,
|
||||
ATTACHMENTS: { delete: async (key: string) => { deletedBlobs.push(key); } },
|
||||
NOTIFICATIONS_HUB: {
|
||||
idFromName: (name: string) => name,
|
||||
get: () => ({ fetch: async (_url: string, init: RequestInit) => { notifications.push(JSON.parse(init.body as string)); return new Response(null, { status: 204 }); } }),
|
||||
},
|
||||
};
|
||||
const storage = new StorageService(db);
|
||||
const base: any = {
|
||||
id, userId: owner, type: 1, name: await encrypt('Initial title'),
|
||||
notes: await encrypt('Notes'), favorite: false, folderId: null, key: null,
|
||||
login: { username: await encrypt('Username'), password: await encrypt('Password') },
|
||||
createdAt: revision, updatedAt: revision, deletedAt: null,
|
||||
};
|
||||
await storage.saveCipher(base);
|
||||
await storage.saveCipher({ ...base, id: otherId, userId: otherOwner });
|
||||
const route = async (path: string, method: string, body?: unknown, headers: Record<string, string> = {}) => {
|
||||
const request = new Request(`https://vault.example.test${path}`, {
|
||||
method, headers: { 'Content-Type': 'application/json', ...headers },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
const response = await handleAuthenticatedRoute(request, env, owner, { id: owner } as any, path, method);
|
||||
assert.ok(response, `${method} ${path} must be handled`);
|
||||
return response;
|
||||
};
|
||||
const attachment = { id: attachmentId, cipherId: id, fileName: await encrypt('file.txt'), key: null, size: 7, sizeName: '7 B' };
|
||||
return { storage, base, env, sqlite, route, deletedBlobs, notifications, attachment };
|
||||
}
|
||||
|
||||
for (const method of ['PUT', 'POST']) {
|
||||
test(`${method} bulk soft delete and restore retain ownership and return restored items`, async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
await f.storage.saveAttachment(f.attachment);
|
||||
const ids = [id, otherId, id, '00000000-0000-4000-8000-000000000099'];
|
||||
assert.equal((await f.route('/api/ciphers/delete', method, { ids })).status, 204);
|
||||
assert.ok((await f.storage.getCipherForUser(id, owner))?.deletedAt);
|
||||
assert.equal((await f.storage.getCipherForUser(otherId, otherOwner))?.deletedAt, null);
|
||||
const response = await f.route('/api/ciphers/restore', method, { ids });
|
||||
assert.equal(response.status, 200);
|
||||
const body: any = await response.json();
|
||||
assert.equal(body.object, 'list');
|
||||
assert.equal(body.continuationToken, null);
|
||||
assert.equal(body.data.length, 1);
|
||||
assert.equal(body.data[0].id, id);
|
||||
assert.equal(body.data[0].deletedDate, null);
|
||||
assert.equal(body.data[0].attachments[0].id, attachmentId);
|
||||
assert.deepEqual(f.deletedBlobs, []);
|
||||
const empty: any = await (await f.route('/api/ciphers/restore', method, { ids: [] })).json();
|
||||
assert.deepEqual(empty.data, []);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [path, method] of [['/api/ciphers', 'DELETE'], ['/api/ciphers/delete-permanent', 'POST']]) {
|
||||
test(`${method} ${path} permanently deletes only owned items and their attachments`, async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
await f.storage.saveAttachment(f.attachment);
|
||||
await f.storage.saveAttachment({ ...f.attachment, id: otherId, cipherId: otherId });
|
||||
assert.equal((await f.route(path, method, { ids: [id, otherId, id] })).status, 204);
|
||||
assert.equal(await f.storage.getCipherForUser(id, owner), null);
|
||||
assert.ok(await f.storage.getCipherForUser(otherId, otherOwner));
|
||||
assert.equal(await f.storage.getAttachment(attachmentId), null);
|
||||
assert.ok(await f.storage.getAttachment(otherId));
|
||||
assert.deepEqual(f.deletedBlobs, [`${id}/${attachmentId}`]);
|
||||
});
|
||||
}
|
||||
|
||||
test('single POST delete alias is permanent; existing root DELETE still moves an active item to trash', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
assert.equal((await f.route(`/api/ciphers/${id}`, 'DELETE')).status, 200);
|
||||
assert.ok((await f.storage.getCipherForUser(id, owner))?.deletedAt);
|
||||
assert.equal((await f.route(`/api/ciphers/${id}/delete`, 'POST')).status, 204);
|
||||
assert.equal(await f.storage.getCipherForUser(id, owner), null);
|
||||
assert.equal((await f.route(`/api/ciphers/${otherId}/delete`, 'POST')).status, 404);
|
||||
});
|
||||
|
||||
test('invalid bulk requests are rejected without changing vault items', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
for (const [path, method] of [['/api/ciphers', 'DELETE'], ['/api/ciphers/delete', 'PUT'], ['/api/ciphers/restore', 'PUT']]) {
|
||||
assert.equal((await f.route(path, method, {})).status, 400);
|
||||
assert.equal((await f.route(path, method, { ids: 'invalid' })).status, 400);
|
||||
}
|
||||
assert.deepEqual(await f.storage.getCipherForUser(id, owner), await f.storage.getCipher(id));
|
||||
assert.equal((await f.storage.getCipherForUser(id, owner))?.deletedAt, null);
|
||||
});
|
||||
|
||||
test('stale updates less than a second apart cannot bypass checks with attachment metadata', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
await f.storage.saveAttachment(f.attachment);
|
||||
await f.storage.saveCipher({ ...f.base, updatedAt: '2026-10-04T00:00:00.001Z' });
|
||||
const response = await f.route(`/api/ciphers/${id}`, 'PUT', {
|
||||
name: await encrypt('Stale title'), lastKnownRevisionDate: revision,
|
||||
attachments2: { [attachmentId]: { fileName: await encrypt('Stale file') } },
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal((await f.storage.getCipherForUser(id, owner))?.name, f.base.name);
|
||||
assert.equal((await f.storage.getAttachment(attachmentId))?.fileName, f.attachment.fileName);
|
||||
assert.equal(f.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('a write conflict detected after reading leaves attachment metadata untouched', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
await f.storage.saveAttachment(f.attachment);
|
||||
const update = StorageService.prototype.updateCipherIfUnchanged;
|
||||
ctx.mock.method(StorageService.prototype, 'updateCipherIfUnchanged', async function (this: StorageService, cipher: any, expected: string) {
|
||||
await f.storage.saveCipher({ ...f.base, updatedAt: '2026-10-04T00:00:01.000Z' });
|
||||
return update.call(this, cipher, expected);
|
||||
});
|
||||
assert.equal((await f.route(`/api/ciphers/${id}`, 'PUT', {
|
||||
name: await encrypt('Stale title'), lastKnownRevisionDate: revision,
|
||||
attachments2: { [attachmentId]: { fileName: await encrypt('Stale file') } },
|
||||
})).status, 400);
|
||||
assert.equal((await f.storage.getAttachment(attachmentId))?.fileName, f.attachment.fileName);
|
||||
assert.equal(f.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('concurrent saves from the same revision accept only one, even in the same millisecond', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
ctx.mock.method(Date, 'now', () => Date.parse(revision));
|
||||
const response = await Promise.all(['First title', 'Second title'].map(async (title) =>
|
||||
f.route(`/api/ciphers/${id}`, 'PUT', { name: await encrypt(title), lastKnownRevisionDate: revision })
|
||||
));
|
||||
assert.deepEqual(response.map((r) => r.status).sort(), [200, 400]);
|
||||
assert.equal((await f.storage.getCipherForUser(id, owner))?.updatedAt, '2026-10-04T00:00:00.001Z');
|
||||
});
|
||||
|
||||
test('a competing deletion cannot be resurrected by a delayed update', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
const get = StorageService.prototype.getCipherForUser;
|
||||
ctx.mock.method(StorageService.prototype, 'getCipherForUser', async function (this: StorageService, ...args: [string, string]) {
|
||||
const result = await get.apply(this, args);
|
||||
await f.storage.deleteCipher(id, owner);
|
||||
return result;
|
||||
});
|
||||
assert.equal((await f.route(`/api/ciphers/${id}`, 'PUT', { name: await encrypt('Delayed title') })).status, 400);
|
||||
assert.equal(await f.storage.getCipher(id), null);
|
||||
});
|
||||
|
||||
test('normal legacy saves without a revision still work and nullable fields can be cleared', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
assert.equal((await f.route(`/api/ciphers/${id}`, 'PUT', { name: await encrypt('Legacy title'), notes: null, fields: [] })).status, 200);
|
||||
const saved = await f.storage.getCipherForUser(id, owner);
|
||||
assert.equal(saved?.notes, null);
|
||||
assert.deepEqual(saved?.fields, []);
|
||||
});
|
||||
|
||||
test('storage conditional updates cannot change another owner or recreate missing items', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
assert.equal(await f.storage.updateCipherIfUnchanged({ ...f.base, userId: otherOwner }, revision), false);
|
||||
await f.storage.deleteCipher(id, owner);
|
||||
assert.equal(await f.storage.updateCipherIfUnchanged(f.base, revision), false);
|
||||
assert.equal(await f.storage.getCipher(id), null);
|
||||
});
|
||||
|
||||
test('Web editing preserves linked IDs and field metadata through reorder, removal, encryption, storage and response', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
const extra = await encrypt('Future encrypted field');
|
||||
const cipher = {
|
||||
...f.base,
|
||||
fields: [
|
||||
{ type: 3, name: await encrypt('Linked username'), value: null, linkedId: 100, futureValue: extra },
|
||||
{ type: 0, name: await encrypt('Remove me'), value: await encrypt('Remove value') },
|
||||
{ type: 1, name: await encrypt('Keep me'), value: await encrypt('Secret'), futureValue: extra },
|
||||
],
|
||||
};
|
||||
await f.storage.saveCipher(cipher);
|
||||
const decrypted = await decryptSingleCipher(cipherToResponse(cipher) as any, enc, mac);
|
||||
const draft = draftFromCipher(decrypted);
|
||||
draft.customFields = [draft.customFields[2], draft.customFields[0]];
|
||||
draft.name = 'Edited title';
|
||||
const updated = await updateCipher((path, init) => f.route(path, init!.method!, JSON.parse(init!.body as string)), session, decrypted, draft);
|
||||
assert.equal(updated.fields?.length, 2);
|
||||
assert.equal('lastKnownRevisionDate' in updated, false);
|
||||
assert.equal(updated.fields?.[1].linkedId, 100);
|
||||
for (const field of updated.fields || []) {
|
||||
assert.equal((field as any).futureValue, extra);
|
||||
assert.equal('decName' in field, false);
|
||||
assert.equal('decValue' in field, false);
|
||||
}
|
||||
const roundtrip = await decryptSingleCipher(updated, enc, mac);
|
||||
assert.equal(roundtrip.decName, 'Edited title');
|
||||
assert.equal(roundtrip.fields?.[0].decValue, 'Secret');
|
||||
assert.equal((await f.storage.getCipherForUser(id, owner))?.fields?.[1].linkedId, 100);
|
||||
});
|
||||
|
||||
for (const [type, property] of [[3, 'card'], [4, 'identity'], [2, 'secureNote']] as const) {
|
||||
test(`Web editing ${property} keeps unknown encrypted values without sending decoded properties`, async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
const extra = await encrypt('Future secret');
|
||||
const source = { ...f.base, type, login: null, [property]: { futureEncrypted: extra, decFutureEncrypted: 'Decoded secret', type: 0 } };
|
||||
await f.storage.saveCipher(source);
|
||||
const decrypted = await decryptSingleCipher(cipherToResponse(source) as any, enc, mac);
|
||||
const draft = draftFromCipher(decrypted);
|
||||
draft.name = 'Changed';
|
||||
let payload: any;
|
||||
const updated = await updateCipher((path, init) => { payload = JSON.parse(init!.body as string); return f.route(path, init!.method!, payload); }, session, decrypted, draft);
|
||||
assert.equal(payload[property].futureEncrypted, extra);
|
||||
assert.equal('decFutureEncrypted' in payload[property], false);
|
||||
assert.equal((updated as any)[property].futureEncrypted, extra);
|
||||
});
|
||||
}
|
||||
|
||||
test('Web sends the revision captured at edit start and exposes a localized conflict while preserving the draft', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
const initial = await decryptSingleCipher(cipherToResponse(f.base) as any, enc, mac);
|
||||
const draft = draftFromCipher(initial);
|
||||
draft.name = 'Unsaved edit';
|
||||
const newer = { ...f.base, name: await encrypt('New remote title'), updatedAt: '2026-10-04T00:00:00.500Z' };
|
||||
await f.storage.saveCipher(newer);
|
||||
const synced = await decryptSingleCipher(cipherToResponse(newer) as any, enc, mac);
|
||||
let submitted: any;
|
||||
await assert.rejects(updateCipher((path, init) => {
|
||||
submitted = JSON.parse(init!.body as string);
|
||||
return f.route(path, init!.method!, submitted);
|
||||
}, session, synced, draft), (error: any) => {
|
||||
assert.equal(error.status, 400);
|
||||
assert.equal(error.message, translate('txt_item_changed_elsewhere'));
|
||||
return true;
|
||||
});
|
||||
assert.equal(submitted.lastKnownRevisionDate, revision);
|
||||
assert.equal(draft.name, 'Unsaved edit');
|
||||
assert.equal((await f.storage.getCipherForUser(id, owner))?.name, newer.name);
|
||||
});
|
||||
|
||||
test('plaintext import keeps linked field IDs attached to the encrypted field', async () => {
|
||||
const draft = importCipherToDraft({ type: 1, name: 'Imported', fields: [{ type: 3, name: 'Linked', value: null, linkedId: 100 }] }, '');
|
||||
assert.equal(draft.customFields[0].linkedId, 100);
|
||||
const payload: any = await buildCipherImportPayload(session, draft);
|
||||
assert.equal(payload.fields[0].linkedId, 100);
|
||||
assert.equal(await decryptStr(payload.fields[0].name, enc, mac), 'Linked');
|
||||
assert.equal('lastKnownRevisionDate' in payload, false);
|
||||
});
|
||||
|
||||
test('a draft cannot be saved into a different item after the selection changes', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
const cipher = await decryptSingleCipher(cipherToResponse(f.base) as any, enc, mac);
|
||||
const draft = draftFromCipher(cipher);
|
||||
let requested = false;
|
||||
await assert.rejects(updateCipher(async () => { requested = true; return new Response(); }, session, { ...cipher, id: otherId }, draft));
|
||||
assert.equal(requested, false);
|
||||
});
|
||||
|
||||
test('automatic item-key repair preserves linked metadata and the intentional revision-date behavior', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
const itemEnc = new Uint8Array(32).fill(3);
|
||||
const itemMac = new Uint8Array(32).fill(4);
|
||||
const itemKey = new Uint8Array([...itemEnc, ...itemMac]);
|
||||
const extra = await encryptBw(new TextEncoder().encode('Future secret'), itemEnc, itemMac);
|
||||
const mixed = { ...f.base, key: await encryptBw(itemKey, enc, mac), fields: [
|
||||
{ type: 3, name: await encrypt('Linked username'), value: null, linkedId: 100, futureEncrypted: extra },
|
||||
] };
|
||||
await f.storage.saveCipher(mixed);
|
||||
const decrypted = await decryptSingleCipher(cipherToResponse(mixed) as any, enc, mac);
|
||||
const repaired = await repairCipherKeyMismatches((path, init) =>
|
||||
f.route(path, init!.method!, JSON.parse(init!.body as string), init!.headers as Record<string, string>), session, [decrypted]);
|
||||
assert.equal(repaired, 1);
|
||||
const saved = (await f.storage.getCipherForUser(id, owner))!;
|
||||
assert.equal(saved.updatedAt, revision);
|
||||
assert.equal(saved.fields?.[0].linkedId, 100);
|
||||
assert.equal((saved.fields?.[0] as any).futureEncrypted, extra);
|
||||
assert.equal(await decryptStr(saved.name, itemEnc, itemMac), 'Initial title');
|
||||
assert.equal(await decryptStr(saved.fields![0].name!, itemEnc, itemMac), 'Linked username');
|
||||
assert.equal('lastKnownRevisionDate' in saved, false);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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'\)/);
|
||||
});
|
||||
@@ -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 @@
|
||||
export const APP_VERSION = '1.8.0';
|
||||
export const APP_VERSION = '1.8.1';
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -156,7 +156,9 @@
|
||||
compatibility: {
|
||||
// Single source of truth for /config.version and /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.
|
||||
// Hiding existing item keys makes item-key encrypted vault data unreadable.
|
||||
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
|
||||
|
||||
@@ -19,6 +19,8 @@ const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14;
|
||||
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
|
||||
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
|
||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
|
||||
const WEBSOCKET_CONNECTION_TOKEN_PREFIX = 'ws-token:';
|
||||
const WEBSOCKET_CONNECTION_TOKEN_TTL_MS = 60 * 1000;
|
||||
|
||||
type HubProtocol = 'json' | 'messagepack';
|
||||
type HubKind = 'user' | 'anonymous-auth-request';
|
||||
@@ -32,6 +34,12 @@ interface WsAttachment {
|
||||
deviceIdentifier: string | null;
|
||||
}
|
||||
|
||||
interface WebSocketConnectionToken {
|
||||
userId: string;
|
||||
deviceIdentifier: string | null;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
function concatBytes(chunks: Uint8Array[]): Uint8Array {
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
@@ -207,6 +215,52 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === '/internal/ws-token' && request.method === 'POST') {
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
token?: string;
|
||||
userId?: string;
|
||||
deviceIdentifier?: string | null;
|
||||
expiresAt?: number;
|
||||
} | null;
|
||||
const token = String(body?.token || '').trim();
|
||||
const userId = String(body?.userId || '').trim();
|
||||
const expiresAt = Number(body?.expiresAt || 0);
|
||||
if (!token || !userId || expiresAt <= Date.now() || expiresAt > Date.now() + WEBSOCKET_CONNECTION_TOKEN_TTL_MS) {
|
||||
return new Response('Invalid websocket connection token', { status: 400 });
|
||||
}
|
||||
await this.ctx.storage.put(`${WEBSOCKET_CONNECTION_TOKEN_PREFIX}${token}`, {
|
||||
userId,
|
||||
deviceIdentifier: String(body?.deviceIdentifier || '').trim() || null,
|
||||
expiresAt,
|
||||
} satisfies WebSocketConnectionToken);
|
||||
const currentAlarm = await this.ctx.storage.getAlarm();
|
||||
if (currentAlarm === null || expiresAt < currentAlarm) {
|
||||
await this.ctx.storage.setAlarm(expiresAt);
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/ws-token/consume' && request.method === 'POST') {
|
||||
const body = (await request.json().catch(() => null)) as { token?: string } | null;
|
||||
const token = String(body?.token || '').trim();
|
||||
if (!token) return new Response('Invalid websocket connection token', { status: 400 });
|
||||
|
||||
// Delete inside a transaction so a connection ticket cannot win two concurrent upgrades.
|
||||
const connection = await this.ctx.storage.transaction(async (txn) => {
|
||||
const key = `${WEBSOCKET_CONNECTION_TOKEN_PREFIX}${token}`;
|
||||
const stored = await txn.get<WebSocketConnectionToken>(key);
|
||||
if (stored) await txn.delete(key);
|
||||
return stored || null;
|
||||
});
|
||||
if (!connection || connection.expiresAt <= Date.now()) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
return new Response(JSON.stringify(connection), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/notify' && request.method === 'POST') {
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
revisionDate?: string;
|
||||
@@ -301,6 +355,27 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
});
|
||||
}
|
||||
|
||||
async alarm(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const tokens = await this.ctx.storage.list<WebSocketConnectionToken>({
|
||||
prefix: WEBSOCKET_CONNECTION_TOKEN_PREFIX,
|
||||
});
|
||||
const expiredKeys: string[] = [];
|
||||
let nextExpiration: number | null = null;
|
||||
|
||||
for (const [key, token] of tokens) {
|
||||
if (token.expiresAt <= now) {
|
||||
expiredKeys.push(key);
|
||||
} else if (nextExpiration === null || token.expiresAt < nextExpiration) {
|
||||
nextExpiration = token.expiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
// Negotiated tickets that never reach an upgrade must not remain in DO storage indefinitely.
|
||||
if (expiredKeys.length > 0) await this.ctx.storage.delete(expiredKeys);
|
||||
if (nextExpiration !== null) await this.ctx.storage.setAlarm(nextExpiration);
|
||||
}
|
||||
|
||||
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
if (!attachment) return;
|
||||
|
||||
@@ -316,7 +316,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const auth = new AuthService(env);
|
||||
const serverHash = await auth.hashPasswordServer(masterPasswordHash, email);
|
||||
const serverHash = await auth.hashPasswordServer(masterPasswordHash);
|
||||
|
||||
const user: User = {
|
||||
id: generateUUID(),
|
||||
@@ -764,7 +764,7 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
|
||||
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
|
||||
}
|
||||
|
||||
user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email);
|
||||
user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash);
|
||||
user.key = nextKey;
|
||||
if (nextPrivateKey) user.privateKey = nextPrivateKey;
|
||||
if (nextPublicKey) user.publicKey = nextPublicKey;
|
||||
|
||||
+8
-13
@@ -1247,19 +1247,14 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
|
||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
let input: { blobName?: unknown; masterPasswordHash?: unknown } = {};
|
||||
if (request.method === 'POST') {
|
||||
try {
|
||||
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
|
||||
} catch {
|
||||
return errorResponse('Backup attachment download payload is invalid', 400);
|
||||
}
|
||||
} else {
|
||||
input = {
|
||||
blobName: url.searchParams.get('blobName') || '',
|
||||
masterPasswordHash: url.searchParams.get('masterPasswordHash') || '',
|
||||
};
|
||||
// Read the request body only. Accepting these fields from the query string
|
||||
// would put the master-password authentication hash in the URL, where it is
|
||||
// captured by request logs, browser history and Referer headers.
|
||||
let input: { blobName?: unknown; masterPasswordHash?: unknown };
|
||||
try {
|
||||
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
|
||||
} catch {
|
||||
return errorResponse('Backup attachment download payload is invalid', 400);
|
||||
}
|
||||
|
||||
const verificationError = await requireBackupUserVerification(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
interface AliasedValue<T> {
|
||||
present: boolean;
|
||||
value: T | null | undefined;
|
||||
}
|
||||
|
||||
function readOwnAliasedValue<T>(source: unknown, aliases: readonly string[]): AliasedValue<T> {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return { present: false, value: undefined };
|
||||
}
|
||||
|
||||
const record = source as Record<string, unknown>;
|
||||
for (const alias of aliases) {
|
||||
if (Object.prototype.hasOwnProperty.call(record, alias)) {
|
||||
return { present: true, value: record[alias] as T | null | undefined };
|
||||
}
|
||||
}
|
||||
|
||||
return { present: false, value: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Full cipher updates use replacement semantics for nullable fields.
|
||||
* Bitwarden clients may omit a property after its value is cleared, so an
|
||||
* absent property must become null instead of falling back to stored data.
|
||||
*/
|
||||
export function readNullableFullUpdateField<T>(
|
||||
source: unknown,
|
||||
aliases: readonly string[]
|
||||
): T | null {
|
||||
const incoming = readOwnAliasedValue<T>(source, aliases);
|
||||
return incoming.present ? incoming.value ?? null : null;
|
||||
}
|
||||
+22
-22
@@ -27,6 +27,7 @@ import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from '.
|
||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
import { readNullableFullUpdateField } from './cipher-full-update';
|
||||
|
||||
// CONTRACT:
|
||||
// Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve
|
||||
@@ -173,7 +174,7 @@ function isStaleCipherUpdate(existingUpdatedAt: string, clientRevisionDate: stri
|
||||
const existingTs = Date.parse(existingUpdatedAt);
|
||||
const clientTs = Date.parse(clientRevisionDate);
|
||||
if (Number.isNaN(existingTs) || Number.isNaN(clientTs)) return false;
|
||||
return existingTs - clientTs > 1000;
|
||||
return existingTs > clientTs;
|
||||
}
|
||||
|
||||
function syncCipherComputedAliases(cipher: Cipher): Cipher {
|
||||
@@ -521,6 +522,7 @@ function normalizeCipherSecureNoteForCompatibility(secureNote: any): CipherSecur
|
||||
if (!secureNote || typeof secureNote !== 'object') return null;
|
||||
const type = Number(secureNote?.type ?? secureNote?.Type ?? 0);
|
||||
return {
|
||||
...secureNote,
|
||||
type: Number.isFinite(type) ? type : 0,
|
||||
};
|
||||
}
|
||||
@@ -654,10 +656,6 @@ function readIncomingAttachmentMetadata(source: any): IncomingAttachmentMetadata
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
function hasIncomingAttachmentMetadata(source: any): boolean {
|
||||
return readIncomingAttachmentMetadata(source).length > 0;
|
||||
}
|
||||
|
||||
async function syncIncomingAttachmentMetadata(
|
||||
storage: StorageService,
|
||||
cipherId: string,
|
||||
@@ -1047,7 +1045,6 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
const incomingPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
|
||||
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
||||
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
|
||||
const preserveRevisionDate =
|
||||
shouldPreserveRepairableCipherUris(request)
|
||||
&& (body.preserveRevisionDate === true || cipherData.preserveRevisionDate === true);
|
||||
@@ -1056,7 +1053,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400);
|
||||
}
|
||||
|
||||
if (!hasAttachmentMigrationMetadata && isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
|
||||
if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
|
||||
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
|
||||
}
|
||||
|
||||
@@ -1064,7 +1061,13 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
|
||||
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
|
||||
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
|
||||
const { preserveRevisionDate: _preserveRevisionDate, PreserveRevisionDate: _pascalPreserveRevisionDate, ...cipherDataWithoutFlags } = cipherData;
|
||||
const {
|
||||
preserveRevisionDate: _preserveRevisionDate,
|
||||
PreserveRevisionDate: _pascalPreserveRevisionDate,
|
||||
lastKnownRevisionDate: _lastKnownRevisionDate,
|
||||
LastKnownRevisionDate: _pascalLastKnownRevisionDate,
|
||||
...cipherDataWithoutFlags
|
||||
} = cipherData;
|
||||
const cipher: Cipher = {
|
||||
...existingCipher, // start with all existing stored data (including unknowns)
|
||||
...cipherDataWithoutFlags, // overlay all client data (including new/unknown fields)
|
||||
@@ -1075,7 +1078,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
favorite: cipherData.favorite ?? existingCipher.favorite,
|
||||
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||
createdAt: existingCipher.createdAt,
|
||||
updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date().toISOString(),
|
||||
updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date(Math.max(Date.now(), Date.parse(existingCipher.updatedAt) + 1)).toISOString(),
|
||||
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||
deletedAt: existingCipher.deletedAt,
|
||||
};
|
||||
@@ -1100,16 +1103,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
|
||||
}
|
||||
|
||||
// Custom fields deletion compatibility:
|
||||
// - Accept both camelCase "fields" and PascalCase "Fields".
|
||||
// - For full update (PUT/POST on this endpoint), missing fields means cleared fields.
|
||||
// This prevents stale custom fields from being resurrected by merge fallback.
|
||||
const incomingFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||
if (incomingFields.present) {
|
||||
cipher.fields = incomingFields.value ?? null;
|
||||
} else if (request.method === 'PUT' || request.method === 'POST') {
|
||||
cipher.fields = null;
|
||||
}
|
||||
// Nullable fields use replacement semantics on this full-update endpoint.
|
||||
// Some clients omit cleared values, so merge fallback must not resurrect them.
|
||||
cipher.notes = readNullableFullUpdateField<string>(cipherData, ['notes', 'Notes']);
|
||||
cipher.fields = readNullableFullUpdateField<Cipher['fields']>(cipherData, ['fields', 'Fields']);
|
||||
normalizeCipherForStorage(cipher);
|
||||
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
|
||||
if (compatibilityError) return errorResponse(compatibilityError, 400);
|
||||
@@ -1120,8 +1117,11 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||
}
|
||||
|
||||
if (!(await storage.updateCipherIfUnchanged(cipher, existingCipher.updatedAt))) {
|
||||
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
|
||||
}
|
||||
// Rejected updates must not modify attachment metadata either.
|
||||
await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData);
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
|
||||
@@ -1456,7 +1456,7 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
// POST /api/ciphers/restore - Bulk restore
|
||||
// PUT /api/ciphers/restore (POST retained for older NodeWarden clients)
|
||||
export async function handleBulkRestoreCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
@@ -1477,7 +1477,7 @@ export async function handleBulkRestoreCiphers(request: Request, env: Env, userI
|
||||
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
return buildCipherListResponse(request, storage, userId, body.ids);
|
||||
}
|
||||
|
||||
// POST /api/ciphers/delete-permanent - Bulk permanent delete
|
||||
|
||||
+28
-28
@@ -206,6 +206,16 @@ function buildPreloginResponse(
|
||||
kdfIterations,
|
||||
kdfMemory,
|
||||
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: {
|
||||
KdfType: kdfType,
|
||||
Iterations: kdfIterations,
|
||||
@@ -381,35 +391,18 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
}
|
||||
|
||||
const user = await storage.getUser(email);
|
||||
if (!user) {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: 'auth.login.failed.user_inactive',
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
let validatedAuthRequestId: string | null = null;
|
||||
let authRequestLoginKey: string | null = null;
|
||||
let valid = false;
|
||||
const normalizedAuthRequestId = String(authRequestId || '').trim();
|
||||
if (normalizedAuthRequestId) {
|
||||
const authRequest = await storage.getAuthRequestByIdForUser(normalizedAuthRequestId, user.id);
|
||||
// Auth-request grants carry an access code, not a password hash. Pay the
|
||||
// same KDF cost and perform the lookup even when the account is missing.
|
||||
await auth.performDummyPasswordWork(passwordHash);
|
||||
const authRequest = await storage.getAuthRequestByIdForUser(normalizedAuthRequestId, user?.id ?? '');
|
||||
valid = !!(
|
||||
user &&
|
||||
user.status === 'active' &&
|
||||
authRequest &&
|
||||
authRequest.userId === user.id &&
|
||||
authRequest.type === 0 &&
|
||||
@@ -425,16 +418,19 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
authRequestLoginKey = authRequest!.key;
|
||||
}
|
||||
} else {
|
||||
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
|
||||
const passwordValid = await auth.verifyPassword(passwordHash, user?.masterPasswordHash ?? null, user?.email ?? email);
|
||||
valid = !!user && user.status === 'active' && passwordValid;
|
||||
}
|
||||
if (!valid) {
|
||||
if (!valid || !user) {
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: normalizedAuthRequestId ? 'auth.login.failed.bad_auth_request' : 'auth.login.failed.bad_password',
|
||||
actorUserId: user?.id,
|
||||
action: user && user.status !== 'active'
|
||||
? 'auth.login.failed.user_inactive'
|
||||
: normalizedAuthRequestId ? 'auth.login.failed.bad_auth_request' : 'auth.login.failed.bad_password',
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
targetId: user?.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
@@ -567,6 +563,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
}
|
||||
}
|
||||
|
||||
if (!normalizedAuthRequestId) {
|
||||
await auth.upgradePasswordVerifier(user, passwordHash);
|
||||
}
|
||||
|
||||
// Persist device only after successful password + (optional) 2FA verification.
|
||||
const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
|
||||
if (deviceSession) {
|
||||
|
||||
@@ -4,18 +4,20 @@ import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||
import type { Env, JWTPayload } from '../types';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import {
|
||||
createWebSocketConnectionToken,
|
||||
verifyWebSocketConnectionToken,
|
||||
} from '../utils/websocket-connection-token';
|
||||
|
||||
const WEBSOCKET_CONNECTION_TOKEN_TTL_MS = 60 * 1000;
|
||||
|
||||
function extractAccessToken(request: Request): string | null {
|
||||
const url = new URL(request.url);
|
||||
const queryToken = String(url.searchParams.get('access_token') || '').trim();
|
||||
if (queryToken) return queryToken;
|
||||
|
||||
const authHeader = String(request.headers.get('Authorization') || '').trim();
|
||||
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
async function authenticateNotificationsRequest(request: Request, env: Env): Promise<JWTPayload | null> {
|
||||
async function authenticateAccessToken(request: Request, env: Env): Promise<JWTPayload | null> {
|
||||
const accessToken = extractAccessToken(request);
|
||||
if (!accessToken) return null;
|
||||
|
||||
@@ -23,30 +25,90 @@ async function authenticateNotificationsRequest(request: Request, env: Env): Pro
|
||||
return auth.verifyAccessToken(`Bearer ${accessToken}`);
|
||||
}
|
||||
|
||||
async function issueWebSocketConnectionToken(payload: JWTPayload, env: Env): Promise<string> {
|
||||
const expiresAt = Date.now() + WEBSOCKET_CONNECTION_TOKEN_TTL_MS;
|
||||
const token = await createWebSocketConnectionToken(payload.sub, expiresAt, env.JWT_SECRET);
|
||||
const id = env.NOTIFICATIONS_HUB.idFromName(payload.sub);
|
||||
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||
const response = await stub.fetch('https://notifications/internal/ws-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
userId: payload.sub,
|
||||
deviceIdentifier: payload.did || null,
|
||||
expiresAt,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to issue websocket connection token');
|
||||
return token;
|
||||
}
|
||||
|
||||
async function consumeWebSocketConnectionToken(request: Request, env: Env): Promise<JWTPayload | null> {
|
||||
const token = String(new URL(request.url).searchParams.get('id') || '').trim();
|
||||
const claims = await verifyWebSocketConnectionToken(token, env.JWT_SECRET);
|
||||
if (!claims) return null;
|
||||
|
||||
// Verify the signed routing claim before selecting a Durable Object. Otherwise an
|
||||
// attacker could activate arbitrary object names with forged token prefixes.
|
||||
const id = env.NOTIFICATIONS_HUB.idFromName(claims.userId);
|
||||
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||
const response = await stub.fetch('https://notifications/internal/ws-token/consume', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
|
||||
const connection = (await response.json().catch(() => null)) as {
|
||||
userId?: string;
|
||||
deviceIdentifier?: string | null;
|
||||
} | null;
|
||||
if (connection?.userId !== claims.userId) return null;
|
||||
|
||||
return {
|
||||
sub: claims.userId,
|
||||
did: String(connection.deviceIdentifier || '').trim() || undefined,
|
||||
} as JWTPayload;
|
||||
}
|
||||
|
||||
async function authenticateNotificationsHub(request: Request, env: Env): Promise<JWTPayload | null> {
|
||||
// Never accept an access JWT from the URL: URLs are routinely retained by logs,
|
||||
// browser history, proxies, monitoring, and error tracking systems.
|
||||
if (request.headers.has('Authorization')) {
|
||||
return authenticateAccessToken(request, env);
|
||||
}
|
||||
return consumeWebSocketConnectionToken(request, env);
|
||||
}
|
||||
|
||||
export async function handleNotificationsNegotiate(request: Request, env: Env): Promise<Response> {
|
||||
const payload = await authenticateNotificationsRequest(request, env);
|
||||
const payload = await authenticateAccessToken(request, env);
|
||||
if (!payload?.sub) return errorResponse('Unauthorized', 401);
|
||||
|
||||
const connectionId = generateUUID();
|
||||
return jsonResponse({
|
||||
connectionId,
|
||||
connectionToken: connectionId,
|
||||
negotiateVersion: 1,
|
||||
availableTransports: [
|
||||
{
|
||||
transport: 'WebSockets',
|
||||
transferFormats: ['Text', 'Binary'],
|
||||
},
|
||||
],
|
||||
});
|
||||
const connectionToken = await issueWebSocketConnectionToken(payload, env);
|
||||
return jsonResponse(
|
||||
{
|
||||
connectionId: generateUUID(),
|
||||
connectionToken,
|
||||
negotiateVersion: 1,
|
||||
availableTransports: [
|
||||
{
|
||||
transport: 'WebSockets',
|
||||
transferFormats: ['Text', 'Binary'],
|
||||
},
|
||||
],
|
||||
},
|
||||
200,
|
||||
{ 'Cache-Control': 'no-store' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleNotificationsHub(request: Request, env: Env): Promise<Response> {
|
||||
const payload = await authenticateNotificationsRequest(request, env);
|
||||
if (!payload?.sub) return errorResponse('Unauthorized', 401);
|
||||
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
|
||||
return errorResponse('Expected websocket', 426);
|
||||
}
|
||||
const payload = await authenticateNotificationsHub(request, env);
|
||||
if (!payload?.sub) return errorResponse('Unauthorized', 401);
|
||||
|
||||
const userId = payload.sub;
|
||||
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
handleRunAdminConfiguredBackup,
|
||||
handleUpdateAdminBackupSettings,
|
||||
} from './handlers/backup';
|
||||
import { errorResponse } from './utils/response';
|
||||
|
||||
export async function handleAdminBackupRoute(
|
||||
request: Request,
|
||||
@@ -26,8 +27,22 @@ export async function handleAdminBackupRoute(
|
||||
return handleAdminExportBackup(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/blob' && (method === 'GET' || method === 'POST')) {
|
||||
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
||||
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);
|
||||
}
|
||||
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') {
|
||||
|
||||
@@ -290,6 +290,7 @@ export async function handleAuthenticatedRoute(
|
||||
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
|
||||
if (method === 'GET') return handleGetCiphers(request, env, userId);
|
||||
if (method === 'POST') return handleCreateCipher(request, env, userId);
|
||||
if (path === '/api/ciphers' && method === 'DELETE') return handleBulkPermanentDeleteCiphers(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -297,7 +298,7 @@ export async function handleAuthenticatedRoute(
|
||||
return handleCiphersImport(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/delete' && method === 'POST') {
|
||||
if (path === '/api/ciphers/delete' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleBulkDeleteCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
@@ -305,7 +306,7 @@ export async function handleAuthenticatedRoute(
|
||||
return handleBulkPermanentDeleteCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/restore' && method === 'POST') {
|
||||
if (path === '/api/ciphers/restore' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleBulkRestoreCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
@@ -333,7 +334,7 @@ export async function handleAuthenticatedRoute(
|
||||
}
|
||||
|
||||
if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/delete' && method === 'DELETE') return handlePermanentDeleteCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/delete' && (method === 'DELETE' || method === 'POST')) return handlePermanentDeleteCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/archive' && (method === 'PUT' || method === 'POST')) return handleArchiveCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/unarchive' && (method === 'PUT' || method === 'POST')) return handleUnarchiveCipher(request, env, userId, cipherId);
|
||||
|
||||
+1
-54
@@ -36,6 +36,7 @@ import { jsonResponse, unsupportedResponse } from './utils/response';
|
||||
import { StorageService } from './services/storage';
|
||||
import type { Env } from './types';
|
||||
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
|
||||
import { buildConfigResponse } from './config-response';
|
||||
|
||||
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | 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 {
|
||||
let decoded: string;
|
||||
try {
|
||||
|
||||
+28
-41
@@ -2,12 +2,13 @@ import { Env, JWTPayload, User } from '../types';
|
||||
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
|
||||
import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
|
||||
import { StorageService } from './storage';
|
||||
import {
|
||||
createPasswordVerifier,
|
||||
needsPasswordVerifierUpgrade,
|
||||
performDummyPasswordWork,
|
||||
verifyPasswordVerifier,
|
||||
} from './password-verifier';
|
||||
|
||||
// Server-side iterations for second-layer hashing.
|
||||
// The client already does heavy PBKDF2 (600k iterations).
|
||||
// This second layer only needs to be non-trivial, not expensive.
|
||||
const SERVER_HASH_ITERATIONS = 100_000;
|
||||
const SERVER_HASH_PREFIX = '$s$';
|
||||
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
|
||||
|
||||
interface CachedUserEntry {
|
||||
@@ -134,47 +135,33 @@ export class AuthService {
|
||||
return device;
|
||||
}
|
||||
|
||||
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
||||
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
||||
// Result is prefixed to distinguish server-hashed credentials from invalid legacy rows.
|
||||
async hashPasswordServer(clientHash: string, email: string): Promise<string> {
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(clientHash),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits']
|
||||
);
|
||||
const salt = new TextEncoder().encode(email.toLowerCase().trim());
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: SERVER_HASH_ITERATIONS },
|
||||
keyMaterial,
|
||||
256
|
||||
);
|
||||
const bytes = new Uint8Array(bits);
|
||||
let binary = '';
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return SERVER_HASH_PREFIX + btoa(binary);
|
||||
async hashPasswordServer(clientHash: string): Promise<string> {
|
||||
return createPasswordVerifier(clientHash);
|
||||
}
|
||||
|
||||
// Verify password: new rows use server-side hashing; legacy rows store the raw client hash.
|
||||
async verifyPassword(inputHash: string, storedHash: string, email: string): Promise<boolean> {
|
||||
if (!storedHash.startsWith(SERVER_HASH_PREFIX)) {
|
||||
return this.constantTimeEquals(inputHash, storedHash);
|
||||
}
|
||||
const serverHash = await this.hashPasswordServer(inputHash, email);
|
||||
return this.constantTimeEquals(serverHash, storedHash);
|
||||
async verifyPassword(inputHash: string, storedHash: string | null, email: string): Promise<boolean> {
|
||||
return verifyPasswordVerifier(inputHash, storedHash, email);
|
||||
}
|
||||
|
||||
private constantTimeEquals(a: string, b: string): boolean {
|
||||
const encA = new TextEncoder().encode(a);
|
||||
const encB = new TextEncoder().encode(b);
|
||||
if (encA.length !== encB.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < encA.length; i++) {
|
||||
diff |= encA[i] ^ encB[i];
|
||||
async performDummyPasswordWork(inputHash: string): Promise<void> {
|
||||
await performDummyPasswordWork(inputHash);
|
||||
}
|
||||
|
||||
// Call only after a password login and all required second factors succeed.
|
||||
// Auth-request access codes are not password hashes and must never migrate rows.
|
||||
async upgradePasswordVerifier(user: User, inputHash: string): Promise<void> {
|
||||
if (!needsPasswordVerifierUpgrade(user.masterPasswordHash)) return;
|
||||
try {
|
||||
const upgraded = await this.hashPasswordServer(inputHash);
|
||||
if (await this.storage.upgradePasswordVerifier(user.id, user.masterPasswordHash, user.securityStamp, upgraded)) {
|
||||
user.masterPasswordHash = upgraded;
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
}
|
||||
} catch {
|
||||
// Opportunistic migration must not prevent an existing user from logging in.
|
||||
// Retry at the next fully authenticated password login; never log credentials.
|
||||
console.warn('Password verifier upgrade deferred');
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
// Generate access token
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { constantTimeEquals } from '../utils/api-key';
|
||||
|
||||
// This is the server's second layer; the client performs its own KDF first.
|
||||
const ITERATIONS = 100_000;
|
||||
const LEGACY_PREFIX = '$s$';
|
||||
const PREFIX = '$s2$';
|
||||
const SALT_BYTES = 32;
|
||||
const DUMMY_SALT = new Uint8Array(SALT_BYTES);
|
||||
const BASE64_32_BYTES = /^[A-Za-z0-9+/]{43}=$/;
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
function decode32Bytes(value: string): Uint8Array | null {
|
||||
if (!BASE64_32_BYTES.test(value)) return null;
|
||||
const bytes = Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
|
||||
return bytes.length === SALT_BYTES && toBase64(bytes) === value ? bytes : null;
|
||||
}
|
||||
|
||||
function parseVerifier(stored: string): { salt: Uint8Array; digest: string } | null {
|
||||
const parts = stored.split('$');
|
||||
// Accept only the work factor this version writes, keeping verification work
|
||||
// equivalent and preventing corrupt/imported rows from requesting arbitrary work.
|
||||
if (parts.length !== 5 || parts[0] !== '' || parts[1] !== 's2' || parts[2] !== String(ITERATIONS)) return null;
|
||||
const salt = decode32Bytes(parts[3]);
|
||||
if (!salt || !decode32Bytes(parts[4])) return null;
|
||||
return { salt, digest: parts[4] };
|
||||
}
|
||||
|
||||
async function deriveDigest(clientHash: string, salt: Uint8Array): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw', new TextEncoder().encode(clientHash), 'PBKDF2', false, ['deriveBits']
|
||||
);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: ITERATIONS }, key, 256
|
||||
);
|
||||
return toBase64(new Uint8Array(bits));
|
||||
}
|
||||
|
||||
export async function createPasswordVerifier(clientHash: string): Promise<string> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
|
||||
const digest = await deriveDigest(clientHash, salt);
|
||||
// Self-contained version, work factor, salt and digest; no schema change.
|
||||
return `${PREFIX}${ITERATIONS}$${toBase64(salt)}$${digest}`;
|
||||
}
|
||||
|
||||
export async function performDummyPasswordWork(clientHash: string): Promise<void> {
|
||||
// The result is discarded and can never authenticate anyone. The salt need
|
||||
// not be random here; no password verifier is being stored.
|
||||
await deriveDigest(clientHash, DUMMY_SALT);
|
||||
}
|
||||
|
||||
export async function verifyPasswordVerifier(
|
||||
clientHash: string,
|
||||
stored: string | null,
|
||||
email: string
|
||||
): Promise<boolean> {
|
||||
const verifier = stored?.startsWith(PREFIX) ? parseVerifier(stored) : null;
|
||||
if (verifier) {
|
||||
return constantTimeEquals(await deriveDigest(clientHash, verifier.salt), verifier.digest);
|
||||
}
|
||||
if (stored?.startsWith(LEGACY_PREFIX) && decode32Bytes(stored.slice(LEGACY_PREFIX.length))) {
|
||||
const digest = await deriveDigest(clientHash, new TextEncoder().encode(email.toLowerCase().trim()));
|
||||
return constantTimeEquals(`${LEGACY_PREFIX}${digest}`, stored);
|
||||
}
|
||||
|
||||
// Missing users, raw legacy verifiers and malformed/unknown formats all pay
|
||||
// the same PBKDF2 cost. Reserved formats must never fall back to raw equality.
|
||||
await performDummyPasswordWork(clientHash);
|
||||
return !!stored && !stored.startsWith('$') && constantTimeEquals(clientHash, stored);
|
||||
}
|
||||
|
||||
export function needsPasswordVerifierUpgrade(stored: string): boolean {
|
||||
return stored.startsWith(LEGACY_PREFIX) || !stored.startsWith('$');
|
||||
}
|
||||
@@ -49,6 +49,8 @@ const CIPHER_SCALAR_DATA_KEYS = new Set([
|
||||
'updatedAt',
|
||||
'updated_at',
|
||||
'revisionDate',
|
||||
'lastKnownRevisionDate',
|
||||
'LastKnownRevisionDate',
|
||||
'archivedAt',
|
||||
'archived_at',
|
||||
'archivedDate',
|
||||
@@ -144,6 +146,27 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
|
||||
).run();
|
||||
}
|
||||
|
||||
// A single conditional UPDATE prevents a competing save or deletion between
|
||||
// the handler's read and write from being overwritten (or resurrected).
|
||||
export async function updateCipherIfUnchanged(
|
||||
db: D1Database,
|
||||
safeBind: SafeBind,
|
||||
cipher: Cipher,
|
||||
expectedUpdatedAt: string
|
||||
): Promise<boolean> {
|
||||
const folderId = normalizeOptionalId(cipher.folderId);
|
||||
const result = await safeBind(
|
||||
db.prepare(
|
||||
'UPDATE ciphers SET type=?, folder_id=?, name=?, notes=?, favorite=?, data=?, reprompt=?, key=?, updated_at=?, archived_at=?, deleted_at=? ' +
|
||||
'WHERE id=? AND user_id=? AND updated_at=?'
|
||||
),
|
||||
Number(cipher.type) || 1, folderId, cipher.name, cipher.notes, cipher.favorite ? 1 : 0,
|
||||
buildCipherData(cipher, folderId), cipher.reprompt ?? 0, cipher.key, cipher.updatedAt,
|
||||
cipher.archivedAt ?? null, cipher.deletedAt, cipher.id, cipher.userId, expectedUpdatedAt
|
||||
).run();
|
||||
return result.meta.changes === 1;
|
||||
}
|
||||
|
||||
function sanitizeIds(ids: string[]): string[] {
|
||||
return Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
@@ -113,6 +113,21 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
|
||||
await saveUser(db, safeBind, user);
|
||||
}
|
||||
|
||||
export async function upgradePasswordVerifier(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
expectedVerifier: string,
|
||||
securityStamp: string,
|
||||
newVerifier: string
|
||||
): Promise<boolean> {
|
||||
// Update only the verifier, preserving vault keys, timestamps and sessions.
|
||||
// A concurrent password change, disable or delete must not be overwritten.
|
||||
const result = await db.prepare(
|
||||
"UPDATE users SET master_password_hash = ? WHERE id = ? AND master_password_hash = ? AND security_stamp = ? AND status = 'active'"
|
||||
).bind(newVerifier, userId, expectedVerifier, securityStamp).run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getUserById as findStoredUserById,
|
||||
getUserCount as countStoredUsers,
|
||||
saveUser as saveStoredUser,
|
||||
upgradePasswordVerifier as upgradeStoredPasswordVerifier,
|
||||
} from './storage-user-repo';
|
||||
import {
|
||||
type AuditLogListOptions,
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
getCiphersByIds as listStoredCiphersByIds,
|
||||
getCiphersPage as listStoredCiphersPage,
|
||||
saveCipher as saveStoredCipher,
|
||||
updateCipherIfUnchanged as updateStoredCipherIfUnchanged,
|
||||
deleteCipher as deleteStoredCipher,
|
||||
} from './storage-cipher-repo';
|
||||
import {
|
||||
@@ -313,6 +315,10 @@ export class StorageService {
|
||||
await createStoredUser(this.db, this.safeBind.bind(this), user);
|
||||
}
|
||||
|
||||
async upgradePasswordVerifier(userId: string, expectedVerifier: string, securityStamp: string, newVerifier: string): Promise<boolean> {
|
||||
return upgradeStoredPasswordVerifier(this.db, userId, expectedVerifier, securityStamp, newVerifier);
|
||||
}
|
||||
|
||||
async createFirstUser(user: User): Promise<boolean> {
|
||||
return createFirstStoredUser(this.db, this.safeBind.bind(this), user);
|
||||
}
|
||||
@@ -491,6 +497,10 @@ export class StorageService {
|
||||
await saveStoredCipher(this.db, this.safeBind.bind(this), cipher);
|
||||
}
|
||||
|
||||
async updateCipherIfUnchanged(cipher: Cipher, expectedUpdatedAt: string): Promise<boolean> {
|
||||
return updateStoredCipherIfUnchanged(this.db, this.safeBind.bind(this), cipher, expectedUpdatedAt);
|
||||
}
|
||||
|
||||
async deleteCipher(id: string, userId: string): Promise<void> {
|
||||
await deleteStoredCipher(this.db, id, userId);
|
||||
}
|
||||
|
||||
@@ -1,93 +1,24 @@
|
||||
[
|
||||
{"type":2,"domains":["ameritrade.com","tdameritrade.com"],"excluded":false},
|
||||
{"type":3,"domains":["bankofamerica.com","bofa.com","mbna.com","usecfo.com"],"excluded":false},
|
||||
{"type":4,"domains":["sprint.com","sprintpcs.com","nextel.com"],"excluded":false},
|
||||
{"type":0,"domains":["youtube.com","google.com","gmail.com"],"excluded":false},
|
||||
{"type":1,"domains":["apple.com","icloud.com"],"excluded":false},
|
||||
{"type":5,"domains":["wellsfargo.com","wf.com","wellsfargoadvisors.com"],"excluded":false},
|
||||
{"type":6,"domains":["mymerrill.com","ml.com","merrilledge.com"],"excluded":false},
|
||||
{"type":7,"domains":["accountonline.com","citi.com","citibank.com","citicards.com","citibankonline.com"],"excluded":false},
|
||||
{"type":8,"domains":["cnet.com","cnettv.com","com.com","download.com","news.com","search.com","upload.com"],"excluded":false},
|
||||
{"type":9,"domains":["bananarepublic.com","gap.com","oldnavy.com","piperlime.com"],"excluded":false},
|
||||
{"type":10,"domains":["bing.com","hotmail.com","live.com","microsoft.com","msn.com","passport.net","windows.com","microsoftonline.com","office.com","office365.com","microsoftstore.com","xbox.com","azure.com","windowsazure.com","cloud.microsoft"],"excluded":false},
|
||||
{"type":11,"domains":["ua2go.com","ual.com","united.com","unitedwifi.com"],"excluded":false},
|
||||
{"type":12,"domains":["overture.com","yahoo.com"],"excluded":false},
|
||||
{"type":13,"domains":["zonealarm.com","zonelabs.com"],"excluded":false},
|
||||
{"type":14,"domains":["paypal.com","paypal-search.com"],"excluded":false},
|
||||
{"type":15,"domains":["avon.com","youravon.com"],"excluded":false},
|
||||
{"type":16,"domains":["diapers.com","soap.com","wag.com","yoyo.com","beautybar.com","casa.com","afterschool.com","vine.com","bookworm.com","look.com","vinemarket.com"],"excluded":false},
|
||||
{"type":17,"domains":["1800contacts.com","800contacts.com"],"excluded":false},
|
||||
{"type":18,"domains":["amazon.com","amazon.com.be","amazon.ae","amazon.ca","amazon.co.uk","amazon.com.au","amazon.com.br","amazon.com.mx","amazon.com.tr","amazon.de","amazon.es","amazon.fr","amazon.in","amazon.it","amazon.nl","amazon.pl","amazon.sa","amazon.se","amazon.sg"],"excluded":false},
|
||||
{"type":19,"domains":["cox.com","cox.net","coxbusiness.com"],"excluded":false},
|
||||
{"type":20,"domains":["mynortonaccount.com","norton.com"],"excluded":false},
|
||||
{"type":21,"domains":["verizon.com","verizon.net"],"excluded":false},
|
||||
{"type":22,"domains":["rakuten.com","buy.com"],"excluded":false},
|
||||
{"type":23,"domains":["siriusxm.com","sirius.com"],"excluded":false},
|
||||
{"type":24,"domains":["ea.com","origin.com","play4free.com","tiberiumalliance.com"],"excluded":false},
|
||||
{"type":25,"domains":["37signals.com","basecamp.com","basecamphq.com","highrisehq.com"],"excluded":false},
|
||||
{"type":26,"domains":["steampowered.com","steamcommunity.com","steamgames.com"],"excluded":false},
|
||||
{"type":27,"domains":["chart.io","chartio.com"],"excluded":false},
|
||||
{"type":28,"domains":["gotomeeting.com","citrixonline.com"],"excluded":false},
|
||||
{"type":29,"domains":["gogoair.com","gogoinflight.com"],"excluded":false},
|
||||
{"type":30,"domains":["mysql.com","oracle.com"],"excluded":false},
|
||||
{"type":31,"domains":["discover.com","discovercard.com"],"excluded":false},
|
||||
{"type":32,"domains":["dcu.org","dcu-online.org"],"excluded":false},
|
||||
{"type":33,"domains":["healthcare.gov","cuidadodesalud.gov","cms.gov"],"excluded":false},
|
||||
{"type":34,"domains":["pepco.com","pepcoholdings.com"],"excluded":false},
|
||||
{"type":35,"domains":["century21.com","21online.com"],"excluded":false},
|
||||
{"type":36,"domains":["comcast.com","comcast.net","xfinity.com"],"excluded":false},
|
||||
{"type":37,"domains":["cricketwireless.com","aiowireless.com"],"excluded":false},
|
||||
{"type":38,"domains":["mandtbank.com","mtb.com"],"excluded":false},
|
||||
{"type":39,"domains":["dropbox.com","getdropbox.com"],"excluded":false},
|
||||
{"type":40,"domains":["snapfish.com","snapfish.ca"],"excluded":false},
|
||||
{"type":41,"domains":["alibaba.com","aliexpress.com","aliyun.com","net.cn"],"excluded":false},
|
||||
{"type":42,"domains":["playstation.com","sonyentertainmentnetwork.com"],"excluded":false},
|
||||
{"type":43,"domains":["mercadolivre.com","mercadolivre.com.br","mercadolibre.com","mercadolibre.com.ar","mercadolibre.com.mx"],"excluded":false},
|
||||
{"type":44,"domains":["zendesk.com","zopim.com"],"excluded":false},
|
||||
{"type":45,"domains":["autodesk.com","tinkercad.com"],"excluded":false},
|
||||
{"type":46,"domains":["railnation.ru","railnation.de","rail-nation.com","railnation.gr","railnation.us","trucknation.de","traviangames.com"],"excluded":false},
|
||||
{"type":47,"domains":["wpcu.coop","wpcuonline.com"],"excluded":false},
|
||||
{"type":48,"domains":["mathletics.com","mathletics.com.au","mathletics.co.uk"],"excluded":false},
|
||||
{"type":49,"domains":["discountbank.co.il","telebank.co.il"],"excluded":false},
|
||||
{"type":50,"domains":["mi.com","xiaomi.com"],"excluded":false},
|
||||
{"type":52,"domains":["postepay.it","poste.it"],"excluded":false},
|
||||
{"type":51,"domains":["facebook.com","messenger.com"],"excluded":false},
|
||||
{"type":53,"domains":["skysports.com","skybet.com","skyvegas.com"],"excluded":false},
|
||||
{"type":54,"domains":["disneymoviesanywhere.com","go.com","disney.com","dadt.com","disneyplus.com"],"excluded":false},
|
||||
{"type":55,"domains":["pokemon-gl.com","pokemon.com"],"excluded":false},
|
||||
{"type":56,"domains":["myuv.com","uvvu.com"],"excluded":false},
|
||||
{"type":58,"domains":["mdsol.com","imedidata.com"],"excluded":false},
|
||||
{"type":57,"domains":["bank-yahav.co.il","bankhapoalim.co.il"],"excluded":false},
|
||||
{"type":59,"domains":["sears.com","shld.net"],"excluded":false},
|
||||
{"type":60,"domains":["xiami.com","alipay.com"],"excluded":false},
|
||||
{"type":61,"domains":["belkin.com","seedonk.com"],"excluded":false},
|
||||
{"type":62,"domains":["turbotax.com","intuit.com"],"excluded":false},
|
||||
{"type":63,"domains":["shopify.com","myshopify.com"],"excluded":false},
|
||||
{"type":64,"domains":["ebay.com","ebay.at","ebay.be","ebay.ca","ebay.ch","ebay.cn","ebay.co.jp","ebay.co.th","ebay.co.uk","ebay.com.au","ebay.com.hk","ebay.com.my","ebay.com.sg","ebay.com.tw","ebay.de","ebay.es","ebay.fr","ebay.ie","ebay.in","ebay.it","ebay.nl","ebay.ph","ebay.pl"],"excluded":false},
|
||||
{"type":65,"domains":["techdata.com","techdata.ch"],"excluded":false},
|
||||
{"type":66,"domains":["schwab.com","schwabplan.com"],"excluded":false},
|
||||
{"type":68,"domains":["tesla.com","teslamotors.com"],"excluded":false},
|
||||
{"type":69,"domains":["morganstanley.com","morganstanleyclientserv.com","stockplanconnect.com","ms.com"],"excluded":false},
|
||||
{"type":70,"domains":["taxact.com","taxactonline.com"],"excluded":false},
|
||||
{"type":71,"domains":["mediawiki.org","wikibooks.org","wikidata.org","wikimedia.org","wikinews.org","wikipedia.org","wikiquote.org","wikisource.org","wikiversity.org","wikivoyage.org","wiktionary.org"],"excluded":false},
|
||||
{"type":72,"domains":["airbnb.at","airbnb.be","airbnb.ca","airbnb.ch","airbnb.cl","airbnb.co.cr","airbnb.co.id","airbnb.co.in","airbnb.co.kr","airbnb.co.nz","airbnb.co.uk","airbnb.co.ve","airbnb.com","airbnb.com.ar","airbnb.com.au","airbnb.com.bo","airbnb.com.br","airbnb.com.bz","airbnb.com.co","airbnb.com.ec","airbnb.com.gt","airbnb.com.hk","airbnb.com.hn","airbnb.com.mt","airbnb.com.my","airbnb.com.ni","airbnb.com.pa","airbnb.com.pe","airbnb.com.py","airbnb.com.sg","airbnb.com.sv","airbnb.com.tr","airbnb.com.tw","airbnb.cz","airbnb.de","airbnb.dk","airbnb.es","airbnb.fi","airbnb.fr","airbnb.gr","airbnb.gy","airbnb.hu","airbnb.ie","airbnb.is","airbnb.it","airbnb.jp","airbnb.mx","airbnb.nl","airbnb.no","airbnb.pl","airbnb.pt","airbnb.ru","airbnb.se"],"excluded":false},
|
||||
{"type":73,"domains":["eventbrite.at","eventbrite.be","eventbrite.ca","eventbrite.ch","eventbrite.cl","eventbrite.co","eventbrite.co.nz","eventbrite.co.uk","eventbrite.com","eventbrite.com.ar","eventbrite.com.au","eventbrite.com.br","eventbrite.com.mx","eventbrite.com.pe","eventbrite.de","eventbrite.dk","eventbrite.es","eventbrite.fi","eventbrite.fr","eventbrite.hk","eventbrite.ie","eventbrite.it","eventbrite.nl","eventbrite.pt","eventbrite.se","eventbrite.sg"],"excluded":false},
|
||||
{"type":74,"domains":["stackexchange.com","superuser.com","stackoverflow.com","serverfault.com","mathoverflow.net","askubuntu.com","stackapps.com"],"excluded":false},
|
||||
{"type":75,"domains":["docusign.com","docusign.net"],"excluded":false},
|
||||
{"type":76,"domains":["envato.com","themeforest.net","codecanyon.net","videohive.net","audiojungle.net","graphicriver.net","photodune.net","3docean.net"],"excluded":false},
|
||||
{"type":77,"domains":["x10hosting.com","x10premium.com"],"excluded":false},
|
||||
{"type":78,"domains":["dnsomatic.com","opendns.com","umbrella.com"],"excluded":false},
|
||||
{"type":79,"domains":["cagreatamerica.com","canadaswonderland.com","carowinds.com","cedarfair.com","cedarpoint.com","dorneypark.com","kingsdominion.com","knotts.com","miadventure.com","schlitterbahn.com","valleyfair.com","visitkingsisland.com","worldsoffun.com"],"excluded":false},
|
||||
{"type":80,"domains":["ubnt.com","ui.com"],"excluded":false},
|
||||
{"type":81,"domains":["discordapp.com","discord.com"],"excluded":false},
|
||||
{"type":82,"domains":["netcup.de","netcup.eu","customercontrolpanel.de"],"excluded":false},
|
||||
{"type":83,"domains":["yandex.com","ya.ru","yandex.az","yandex.by","yandex.co.il","yandex.com.am","yandex.com.ge","yandex.com.tr","yandex.ee","yandex.fi","yandex.fr","yandex.kg","yandex.kz","yandex.lt","yandex.lv","yandex.md","yandex.pl","yandex.ru","yandex.tj","yandex.tm","yandex.ua","yandex.uz"],"excluded":false},
|
||||
{"type":84,"domains":["sonyentertainmentnetwork.com","sony.com"],"excluded":false},
|
||||
{"type":85,"domains":["proton.me","protonmail.com","protonvpn.com"],"excluded":false},
|
||||
{"type":86,"domains":["ubisoft.com","ubi.com"],"excluded":false},
|
||||
{"type":87,"domains":["transferwise.com","wise.com"],"excluded":false},
|
||||
{"type":88,"domains":["takeaway.com","just-eat.dk","just-eat.no","just-eat.fr","just-eat.ch","lieferando.de","lieferando.at","thuisbezorgd.nl","pyszne.pl"],"excluded":false},
|
||||
{"type":89,"domains":["atlassian.com","bitbucket.org","trello.com","statuspage.io","atlassian.net","jira.com"],"excluded":false},
|
||||
{"type":90,"domains":["pinterest.com","pinterest.com.au","pinterest.cl","pinterest.de","pinterest.dk","pinterest.es","pinterest.fr","pinterest.co.uk","pinterest.jp","pinterest.co.kr","pinterest.nz","pinterest.pt","pinterest.se"],"excluded":false},
|
||||
{"type":91,"domains":["twitter.com","x.com"],"excluded":false}
|
||||
]
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"source": "https://github.com/bitwarden/server",
|
||||
"ref": "main",
|
||||
"generatedAt": "2026-05-05T00:00:00.000Z",
|
||||
"rulesCount": 91,
|
||||
"domainsCount": 436,
|
||||
"generatedAt": "2026-10-05T11:35:36.755Z",
|
||||
"rulesCount": 22,
|
||||
"domainsCount": 186,
|
||||
"sourceFiles": [
|
||||
"src/Core/Enums/GlobalEquivalentDomainsType.cs",
|
||||
"src/Core/Utilities/StaticStore.cs"
|
||||
|
||||
+18
-1
@@ -8,6 +8,13 @@ export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
|
||||
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
|
||||
] 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 {
|
||||
const raw = String(value || '').trim();
|
||||
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(
|
||||
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
|
||||
): 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(',')) {
|
||||
const origin = normalizeOrigin(item);
|
||||
if (origin) seen.add(origin);
|
||||
|
||||
+19
-3
@@ -3,6 +3,7 @@ import type { Env } from '../types';
|
||||
import {
|
||||
isBrowserExtensionOrigin,
|
||||
isConfiguredWebAuthnAllowedOrigin,
|
||||
isOfficialBitwardenDesktopOrigin,
|
||||
normalizeOrigin,
|
||||
} from './origins';
|
||||
|
||||
@@ -48,7 +49,10 @@ function getCorsPolicy(request: Request, env: Env): { allowOrigin: string | null
|
||||
if (origin === url.origin) {
|
||||
return { allowOrigin: origin, allowCredentials: true };
|
||||
}
|
||||
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
|
||||
if (
|
||||
(isBrowserExtensionOrigin(origin) || isOfficialBitwardenDesktopOrigin(origin))
|
||||
&& isConfiguredWebAuthnAllowedOrigin(env, origin)
|
||||
) {
|
||||
return { allowOrigin: origin, allowCredentials: true };
|
||||
}
|
||||
if (isWildcardCorsPath(url.pathname)) {
|
||||
@@ -100,10 +104,22 @@ export function applyCors(
|
||||
headers.set(k, v);
|
||||
}
|
||||
// Security headers applied to every response.
|
||||
headers.set('X-Frame-Options', 'DENY');
|
||||
headers.set('X-Content-Type-Options', 'nosniff');
|
||||
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:");
|
||||
}
|
||||
return new Response(response.body, {
|
||||
|
||||
@@ -17,8 +17,12 @@ export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>):
|
||||
wrappedPrivateKey: user.privateKey,
|
||||
publicKey,
|
||||
signedPublicKey: null,
|
||||
object: 'publicKeyEncryptionKeyPair',
|
||||
Object: 'publicKeyEncryptionKeyPair',
|
||||
},
|
||||
securityState: null,
|
||||
signatureKeyPair: null,
|
||||
object: 'privateKeys',
|
||||
Object: 'privateKeys',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { generateUUID } from './uuid';
|
||||
|
||||
const WEBSOCKET_NOTIFICATION_SCOPE = 'notifications.websocket';
|
||||
|
||||
export interface WebSocketConnectionTokenClaims {
|
||||
userId: string;
|
||||
expiresAt: number;
|
||||
nonce: string;
|
||||
scope: typeof WEBSOCKET_NOTIFICATION_SCOPE;
|
||||
}
|
||||
|
||||
function base64UrlEncode(data: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...data))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function base64UrlDecode(value: string): Uint8Array {
|
||||
let normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (normalized.length % 4) normalized += '=';
|
||||
const binary = atob(normalized);
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function getSigningKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign', 'verify']
|
||||
);
|
||||
}
|
||||
|
||||
export async function createWebSocketConnectionToken(
|
||||
userId: string,
|
||||
expiresAt: number,
|
||||
secret: string
|
||||
): Promise<string> {
|
||||
const claims: WebSocketConnectionTokenClaims = {
|
||||
userId,
|
||||
expiresAt,
|
||||
nonce: generateUUID(),
|
||||
scope: WEBSOCKET_NOTIFICATION_SCOPE,
|
||||
};
|
||||
const payload = base64UrlEncode(new TextEncoder().encode(JSON.stringify(claims)));
|
||||
const signature = await crypto.subtle.sign(
|
||||
'HMAC',
|
||||
await getSigningKey(secret),
|
||||
new TextEncoder().encode(payload)
|
||||
);
|
||||
return `${payload}.${base64UrlEncode(new Uint8Array(signature))}`;
|
||||
}
|
||||
|
||||
export async function verifyWebSocketConnectionToken(
|
||||
token: string,
|
||||
secret: string
|
||||
): Promise<WebSocketConnectionTokenClaims | null> {
|
||||
try {
|
||||
if (!token || token.length > 1024) return null;
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 2) return null;
|
||||
const [payload, encodedSignature] = parts;
|
||||
const valid = await crypto.subtle.verify(
|
||||
'HMAC',
|
||||
await getSigningKey(secret),
|
||||
base64UrlDecode(encodedSignature),
|
||||
new TextEncoder().encode(payload)
|
||||
);
|
||||
if (!valid) return null;
|
||||
|
||||
const claims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payload))) as Partial<WebSocketConnectionTokenClaims>;
|
||||
if (
|
||||
claims.scope !== WEBSOCKET_NOTIFICATION_SCOPE
|
||||
|| !String(claims.userId || '').trim()
|
||||
|| !Number.isFinite(claims.expiresAt)
|
||||
|| Number(claims.expiresAt) <= Date.now()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return claims as WebSocketConnectionTokenClaims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./webapp/index.html', './webapp/src/**/*.{ts,tsx}'],
|
||||
darkMode: ['class', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
canvas: 'var(--bg-accent)',
|
||||
panel: 'var(--panel)',
|
||||
'panel-soft': 'var(--panel-soft)',
|
||||
'panel-muted': 'var(--panel-muted)',
|
||||
line: 'var(--line)',
|
||||
'line-soft': 'var(--line-soft)',
|
||||
ink: 'var(--text)',
|
||||
muted: 'var(--muted)',
|
||||
'muted-strong': 'var(--muted-strong)',
|
||||
brand: 'var(--primary)',
|
||||
'brand-hover': 'var(--primary-hover)',
|
||||
'brand-strong': 'var(--primary-strong)',
|
||||
danger: 'var(--danger)',
|
||||
},
|
||||
boxShadow: {
|
||||
soft: 'var(--shadow-sm)',
|
||||
panel: 'var(--shadow-md)',
|
||||
elevated: 'var(--shadow-lg)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
+12
-4
@@ -1674,17 +1674,25 @@ export default function App() {
|
||||
reconnectAttempts += 1;
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
void connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
const connect = async () => {
|
||||
if (disposed) return;
|
||||
const accessToken = session.accessToken;
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const negotiateResponse = await fetch('/notifications/hub/negotiate?negotiateVersion=1', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!negotiateResponse.ok) throw new Error('Notification negotiation failed');
|
||||
const negotiation = (await negotiateResponse.json()) as { connectionToken?: string };
|
||||
if (!negotiation.connectionToken || disposed) throw new Error('Notification connection token missing');
|
||||
|
||||
const hubUrl = new URL('/notifications/hub', window.location.origin);
|
||||
hubUrl.searchParams.set('access_token', accessToken);
|
||||
hubUrl.searchParams.set('id', negotiation.connectionToken);
|
||||
hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
socket = new WebSocket(hubUrl.toString());
|
||||
} catch {
|
||||
@@ -1809,7 +1817,7 @@ export default function App() {
|
||||
});
|
||||
};
|
||||
|
||||
connect();
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
|
||||
@@ -1,158 +1,77 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Check, Copy, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
|
||||
import { Check, Copy, Download, LoaderCircle, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { EFFLongWordList } from '@/lib/eff-word-list';
|
||||
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';
|
||||
|
||||
type GeneratorMode = 'password' | 'passphrase';
|
||||
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v2';
|
||||
|
||||
interface PasswordOptions {
|
||||
length: number;
|
||||
uppercase: boolean;
|
||||
lowercase: boolean;
|
||||
numbers: boolean;
|
||||
special: boolean;
|
||||
minNumbers: number;
|
||||
minSpecial: number;
|
||||
avoidAmbiguous: boolean;
|
||||
}
|
||||
|
||||
interface PassphraseOptions {
|
||||
words: number;
|
||||
separator: string;
|
||||
capitalize: boolean;
|
||||
includeNumber: boolean;
|
||||
}
|
||||
|
||||
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v1';
|
||||
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const DIGITS = '0123456789';
|
||||
const SPECIAL = '!@#$%^&*';
|
||||
const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1']);
|
||||
|
||||
const defaultPasswordOptions: PasswordOptions = {
|
||||
length: 14,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
numbers: true,
|
||||
special: false,
|
||||
minNumbers: 1,
|
||||
minSpecial: 1,
|
||||
avoidAmbiguous: false,
|
||||
};
|
||||
|
||||
const defaultPassphraseOptions: PassphraseOptions = {
|
||||
words: 6,
|
||||
separator: '-',
|
||||
capitalize: false,
|
||||
includeNumber: false,
|
||||
};
|
||||
|
||||
function clamp(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;
|
||||
}
|
||||
|
||||
function readSettings(): { mode: GeneratorMode; password: PasswordOptions; passphrase: PassphraseOptions } {
|
||||
function readSettings(): GeneratorSettings {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') as Partial<{ mode: GeneratorMode; password: Partial<PasswordOptions>; passphrase: Partial<PassphraseOptions> }>;
|
||||
return {
|
||||
mode: stored.mode === 'passphrase' ? 'passphrase' : 'password',
|
||||
password: {
|
||||
...defaultPasswordOptions,
|
||||
...stored.password,
|
||||
length: clamp(stored.password?.length, 5, 128, defaultPasswordOptions.length),
|
||||
minNumbers: clamp(stored.password?.minNumbers, 0, 9, defaultPasswordOptions.minNumbers),
|
||||
minSpecial: clamp(stored.password?.minSpecial, 0, 9, defaultPasswordOptions.minSpecial),
|
||||
},
|
||||
passphrase: {
|
||||
...defaultPassphraseOptions,
|
||||
...stored.passphrase,
|
||||
words: clamp(stored.passphrase?.words, 3, 20, defaultPassphraseOptions.words),
|
||||
separator: String(stored.passphrase?.separator ?? defaultPassphraseOptions.separator).slice(0, 1),
|
||||
},
|
||||
};
|
||||
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 { mode: 'password', password: defaultPasswordOptions, passphrase: defaultPassphraseOptions };
|
||||
return defaultGeneratorSettings;
|
||||
}
|
||||
}
|
||||
|
||||
function randomIndex(length: number): number {
|
||||
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.split('').filter((character) => !AMBIGUOUS.has(character)).join('') : characters;
|
||||
}
|
||||
|
||||
function generatePassword(options: PasswordOptions): string {
|
||||
const sets: Array<{ chars: string; minimum: number }> = [];
|
||||
if (options.uppercase) sets.push({ chars: filtered(UPPERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||
if (options.lowercase) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||
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: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||
|
||||
const minimumLength = sets.reduce((total, set) => total + set.minimum, 0);
|
||||
const length = Math.max(options.length, minimumLength, 5);
|
||||
const allCharacters = sets.map((set) => set.chars).join('');
|
||||
const characters = sets.flatMap((set) => Array.from({ length: set.minimum }, () => pick(set.chars)));
|
||||
while (characters.length < length) characters.push(pick(allCharacters));
|
||||
return shuffle(characters).join('');
|
||||
}
|
||||
|
||||
function generatePassphrase(options: PassphraseOptions): string {
|
||||
const words = Array.from({ length: options.words }, () => EFFLongWordList[randomIndex(EFFLongWordList.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);
|
||||
}
|
||||
|
||||
function strengthLabel(mode: GeneratorMode, value: string): { label: string; score: number } {
|
||||
const score = mode === 'password' ? Math.min(4, Math.max(1, Math.floor(value.length / 5))) : Math.min(4, Math.max(1, Math.floor(value.split(/[-_. ]/).filter(Boolean).length / 2)));
|
||||
return { score, label: t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][score - 1]) };
|
||||
}
|
||||
|
||||
export default function PasswordGeneratorPage() {
|
||||
const initial = useMemo(readSettings, []);
|
||||
const [mode, setMode] = useState<GeneratorMode>(initial.mode);
|
||||
const [passwordOptions, setPasswordOptions] = useState<PasswordOptions>(initial.password);
|
||||
const [passphraseOptions, setPassphraseOptions] = useState<PassphraseOptions>(initial.passphrase);
|
||||
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(
|
||||
() => (mode === 'password' ? generatePassword(passwordOptions) : generatePassphrase(passphraseOptions)),
|
||||
[mode, passwordOptions, passphraseOptions, seed]
|
||||
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 strength = useMemo(() => strengthLabel(mode, generated), [generated, mode]);
|
||||
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({ mode, password: passwordOptions, passphrase: passphraseOptions }));
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
// The generator remains fully usable when browser storage is unavailable.
|
||||
}
|
||||
}, [mode, passwordOptions, passphraseOptions]);
|
||||
}, [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);
|
||||
@@ -160,17 +79,49 @@ export default function PasswordGeneratorPage() {
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
await copyTextToClipboard(generated, { onSuccess: () => setCopied(true), onError: () => setCopied(false) });
|
||||
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 changePasswordOption = <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => {
|
||||
setPasswordOptions((current) => ({ ...current, [key]: value }));
|
||||
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]) => {
|
||||
setPassphraseOptions((current) => ({ ...current, [key]: value }));
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -178,62 +129,182 @@ export default function PasswordGeneratorPage() {
|
||||
<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" role="tablist" aria-label={t('txt_generator_type')}>
|
||||
<button type="button" role="tab" aria-selected={mode === 'password'} className={`settings-category-tab ${mode === 'password' ? 'active' : ''}`} onClick={() => setMode('password')}>{t('txt_password')}</button>
|
||||
<button type="button" role="tab" aria-selected={mode === 'passphrase'} className={`settings-category-tab ${mode === 'passphrase' ? 'active' : ''}`} onClick={() => setMode('passphrase')}>{t('txt_passphrase')}</button>
|
||||
</div>
|
||||
<output className="generator-value" aria-label={t('txt_generated_password')}>{generated}</output>
|
||||
<div className="generator-strength-row">
|
||||
<div className="generator-strength" aria-label={`${t('txt_password_strength')}: ${strength.label}`}>
|
||||
{[1, 2, 3, 4].map((level) => <span key={level} className={level <= strength.score ? `active level-${strength.score}` : ''} />)}
|
||||
</div>
|
||||
<span><ShieldCheck size={15} /> {strength.label}</span>
|
||||
<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" onClick={regenerate}><RefreshCw size={16} className="btn-icon" />{t('txt_regenerate')}</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void copy()}><Copy size={16} className="btn-icon" />{copied ? t('txt_copied') : t('txt_copy')}</button>
|
||||
<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('txt_generator_security_note')}</p>
|
||||
<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>
|
||||
{mode === 'password' ? (
|
||||
{settings.mode === 'password' && (
|
||||
<PasswordOptionFields options={settings.password} onChange={changePasswordOption} onCharacterTypeChange={changeCharacterType} />
|
||||
)}
|
||||
{settings.mode === 'passphrase' && (
|
||||
<>
|
||||
<GeneratorNumberStepper id="length" label={t('txt_generator_length')} value={passwordOptions.length} minimum={5} maximum={128} fallback={14} onChange={(value) => changePasswordOption('length', value)} />
|
||||
<fieldset className="generator-option-group"><legend>{t('txt_generator_character_types')}</legend>
|
||||
<GeneratorToggle checked={passwordOptions.uppercase} onChange={(checked) => changePasswordOption('uppercase', checked)} label={t('txt_generator_uppercase')} />
|
||||
<GeneratorToggle checked={passwordOptions.lowercase} onChange={(checked) => changePasswordOption('lowercase', checked)} label={t('txt_generator_lowercase')} />
|
||||
<GeneratorToggle checked={passwordOptions.numbers} onChange={(checked) => changePasswordOption('numbers', checked)} label={t('txt_generator_numbers')} />
|
||||
{passwordOptions.numbers && <GeneratorNumberStepper id="min-numbers" compact label={t('txt_generator_minimum')} value={passwordOptions.minNumbers} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minNumbers', value)} />}
|
||||
<GeneratorToggle checked={passwordOptions.special} onChange={(checked) => changePasswordOption('special', checked)} label={t('txt_generator_special')} />
|
||||
{passwordOptions.special && <GeneratorNumberStepper id="min-special" compact label={t('txt_generator_minimum')} value={passwordOptions.minSpecial} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minSpecial', value)} />}
|
||||
</fieldset>
|
||||
<GeneratorToggle checked={passwordOptions.avoidAmbiguous} onChange={(checked) => changePasswordOption('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={passphraseOptions.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
|
||||
<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={passphraseOptions.separator} onInput={(event) => changePassphraseOption('separator', event.currentTarget.value.slice(0, 1))} /></label>
|
||||
<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={passphraseOptions.capitalize} onChange={(checked) => changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} />
|
||||
<GeneratorToggle checked={passphraseOptions.includeNumber} onChange={(checked) => changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
|
||||
<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(clamp(value, props.minimum, props.maximum, props.fallback));
|
||||
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>
|
||||
|
||||
@@ -382,7 +382,9 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
}
|
||||
const groupIndexByKey = new Map<string, number>();
|
||||
Array.from(groupKeys).sort().forEach((groupKey, index) => {
|
||||
groupIndexByKey.set(groupKey, index % 64);
|
||||
// Keep indices unique (no modulo): they are used both for group colors and
|
||||
// as the group identity when selecting duplicate items to delete.
|
||||
groupIndexByKey.set(groupKey, index);
|
||||
});
|
||||
const byId = new Map<string, number>();
|
||||
for (const [cipherId, groupKey] of groupKeyById.entries()) {
|
||||
@@ -831,13 +833,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setLocalError(t('txt_item_name_is_required'));
|
||||
return;
|
||||
}
|
||||
// Sync may change the current selection while a draft is open. Save only
|
||||
// the item that this draft belongs to, and retain it if the item disappeared.
|
||||
const editedCipher = isCreating ? null : cipherById.get(nextDraft.id || selectedCipherId);
|
||||
if (!isCreating && !editedCipher) {
|
||||
setLocalError(t('txt_item_changed_elsewhere'));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
if (isCreating) {
|
||||
await props.onCreate(nextDraft, attachmentQueue);
|
||||
} else if (selectedCipher) {
|
||||
} else if (editedCipher) {
|
||||
const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]);
|
||||
await props.onUpdate(selectedCipher, nextDraft, {
|
||||
await props.onUpdate(editedCipher, nextDraft, {
|
||||
addFiles: attachmentQueue,
|
||||
removeAttachmentIds,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import { cipherFieldMetadata } from '@/lib/cipher-fields';
|
||||
import {
|
||||
BookUser,
|
||||
CreditCard,
|
||||
@@ -558,6 +559,7 @@ export function createEmptyDraft(type: number): VaultDraft {
|
||||
export function draftFromCipher(cipher: Cipher): VaultDraft {
|
||||
const draft = createEmptyDraft(Number(cipher.type || 1));
|
||||
draft.id = cipher.id;
|
||||
draft.revisionDate = cipher.revisionDate;
|
||||
draft.favorite = !!cipher.favorite;
|
||||
draft.name = cipher.decName || '';
|
||||
draft.folderId = cipher.folderId || '';
|
||||
@@ -655,6 +657,7 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
|
||||
draft.passportExpirationDate = cipher.passport.decExpirationDate || '';
|
||||
}
|
||||
draft.customFields = (cipher.fields || []).map((field) => ({
|
||||
...cipherFieldMetadata(field),
|
||||
type: parseFieldType(field.type),
|
||||
label: field.decName || '',
|
||||
value: field.decValue || '',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'preact/hooks';
|
||||
import { cipherFieldMetadata } from '@/lib/cipher-fields';
|
||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||
import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
|
||||
import {
|
||||
@@ -221,7 +222,9 @@ function optimisticCipherFromDraft(draft: VaultDraft, current?: Cipher | null):
|
||||
}
|
||||
|
||||
next.fields = draft.customFields.map((field) => ({
|
||||
...cipherFieldMetadata(field.extra || {}).extra,
|
||||
type: field.type,
|
||||
...(field.type === 3 ? { linkedId: field.linkedId ?? null } : {}),
|
||||
name: field.label,
|
||||
value: field.value,
|
||||
decName: field.label,
|
||||
@@ -608,6 +611,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
onNotify('success', t('txt_item_updated'));
|
||||
} catch (error) {
|
||||
patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false });
|
||||
if (error instanceof Error && error.message === t('txt_item_changed_elsewhere')) {
|
||||
// Fetch the current item for reopening; the editor keeps the draft.
|
||||
await refetchCiphers().catch(() => {});
|
||||
}
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
|
||||
throw error;
|
||||
} finally {
|
||||
|
||||
@@ -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 type { AuthorizedDevice } from '../types';
|
||||
import type {
|
||||
@@ -428,14 +436,15 @@ export async function registerAccount(args: {
|
||||
}): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||
try {
|
||||
const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args;
|
||||
const webCrypto = requireWebCrypto();
|
||||
const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
|
||||
const masterHash = await pbkdf2(masterKey, password, 1, 32);
|
||||
const encKey = await hkdfExpand(masterKey, 'enc', 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 keyPair = await crypto.subtle.generateKey(
|
||||
const keyPair = await webCrypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2048,
|
||||
@@ -445,8 +454,8 @@ export async function registerAccount(args: {
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
|
||||
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
|
||||
const publicKey = new Uint8Array(await webCrypto.subtle.exportKey('spki', keyPair.publicKey));
|
||||
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 resp = await fetch('/api/accounts/register', {
|
||||
@@ -474,6 +483,9 @@ export async function registerAccount(args: {
|
||||
}
|
||||
return { ok: true };
|
||||
} 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') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ export async function downloadAdminBackupAttachmentBlob(
|
||||
authedFetch: AuthedFetch,
|
||||
blobName: string,
|
||||
masterPasswordHash: string
|
||||
): Promise<Uint8Array> {
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const resp = await authedFetch('/api/admin/backup/blob', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
} from './shared';
|
||||
import { readResponseBytesWithProgress } from '../download';
|
||||
import { loadVaultCoreSyncSnapshot } from './vault-sync';
|
||||
import { cipherFieldMetadata } from '../cipher-fields';
|
||||
import { t } from '../i18n';
|
||||
|
||||
type CipherLoginData = NonNullable<Cipher['login']>;
|
||||
const NODEWARDEN_WEB_REPAIR_HEADER = 'X-NodeWarden-Web';
|
||||
@@ -573,6 +575,8 @@ function plainCipherValue(decrypted: unknown, raw: unknown = ''): string {
|
||||
function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
|
||||
const type = Number(cipher.type || 1) || 1;
|
||||
const draft: VaultDraft = {
|
||||
id: cipher.id,
|
||||
revisionDate: cipher.revisionDate,
|
||||
type,
|
||||
name: plainCipherValue(cipher.decName, cipher.name).trim() || 'Untitled',
|
||||
notes: plainCipherValue(cipher.decNotes, cipher.notes),
|
||||
@@ -650,6 +654,7 @@ function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
|
||||
|
||||
draft.customFields = (cipher.fields || [])
|
||||
.map((field) => ({
|
||||
...cipherFieldMetadata(field),
|
||||
type: parseFieldType(field.type ?? 0),
|
||||
label: plainCipherValue(field.decName, field.name).trim(),
|
||||
value: plainCipherValue(field.decValue, field.value),
|
||||
@@ -797,15 +802,17 @@ async function encryptCustomFields(
|
||||
fields: VaultDraftField[],
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array
|
||||
): Promise<Array<{ type: number; name: string | null; value: string | null }>> {
|
||||
const out: Array<{ type: number; name: string | null; value: string | null }> = [];
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const out: Array<Record<string, unknown>> = [];
|
||||
for (const field of fields || []) {
|
||||
const label = String(field.label || '').trim();
|
||||
if (!label) continue;
|
||||
out.push({
|
||||
...cipherFieldMetadata(field.extra || {}).extra,
|
||||
type: parseFieldType(field.type),
|
||||
name: await encryptTextValue(label, enc, mac),
|
||||
value: await encryptTextValue(String(field.value || ''), enc, mac),
|
||||
...(parseFieldType(field.type) === 3 ? { linkedId: field.linkedId ?? null } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -1300,6 +1307,8 @@ async function buildCipherPayload(
|
||||
if (cipher?.id) {
|
||||
payload.id = cipher.id;
|
||||
payload.key = keys.key;
|
||||
// Use the version captured when editing began, even if sync refreshed cipher.
|
||||
payload.lastKnownRevisionDate = draft.revisionDate ?? cipher.revisionDate;
|
||||
}
|
||||
|
||||
if (type === 1) {
|
||||
@@ -1327,6 +1336,7 @@ async function buildCipherPayload(
|
||||
payload.passwordHistory = await buildUpdatedPasswordHistory(cipher, draft, keys.enc, keys.mac);
|
||||
} else if (type === 3) {
|
||||
payload.card = {
|
||||
...stripDecodedObjectFields(cipher?.card),
|
||||
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
|
||||
number: await encryptTextValue(draft.cardNumber, keys.enc, keys.mac),
|
||||
brand: await encryptTextValue(draft.cardBrand, keys.enc, keys.mac),
|
||||
@@ -1336,6 +1346,7 @@ async function buildCipherPayload(
|
||||
};
|
||||
} else if (type === 4) {
|
||||
payload.identity = {
|
||||
...stripDecodedObjectFields(cipher?.identity),
|
||||
title: await encryptTextValue(draft.identTitle, keys.enc, keys.mac),
|
||||
firstName: await encryptTextValue(draft.identFirstName, keys.enc, keys.mac),
|
||||
middleName: await encryptTextValue(draft.identMiddleName, keys.enc, keys.mac),
|
||||
@@ -1426,7 +1437,7 @@ async function buildCipherPayload(
|
||||
keys.mac
|
||||
);
|
||||
} else if (type === 2) {
|
||||
payload.secureNote = { type: 0 };
|
||||
payload.secureNote = { ...stripDecodedObjectFields(cipher?.secureNote), type: 0 };
|
||||
}
|
||||
|
||||
return payload;
|
||||
@@ -1462,6 +1473,9 @@ export async function updateCipher(
|
||||
extraPayload?: Record<string, unknown>,
|
||||
options?: { webRepair?: boolean }
|
||||
): Promise<Cipher> {
|
||||
if (draft.id && draft.id !== cipher.id) {
|
||||
throw createApiError(t('txt_item_changed_elsewhere'), 400);
|
||||
}
|
||||
const payload = await buildCipherPayload(session, draft, cipher);
|
||||
if (extraPayload) {
|
||||
Object.assign(payload, extraPayload);
|
||||
@@ -1475,7 +1489,7 @@ export async function updateCipher(
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Update item failed');
|
||||
if (!resp.ok) throw createApiError(await parseErrorMessage(resp, t('txt_update_item_failed')), resp.status);
|
||||
return (await parseJson<Cipher>(resp))!;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { hkdf } from '@/lib/crypto';
|
||||
import { cipherFieldMetadata } from './cipher-fields';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { VaultDraft } from '@/lib/types';
|
||||
import type { ImportResultSummary } from '@/components/ImportPage';
|
||||
@@ -206,6 +207,8 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
|
||||
const fieldType = parsedType === 1 || parsedType === 2 || parsedType === 3 ? (parsedType as 1 | 2 | 3) : 0;
|
||||
return {
|
||||
type: fieldType,
|
||||
// Plaintext import cannot safely pass through unknown encrypted values.
|
||||
...cipherFieldMetadata({ linkedId: field.linkedId }),
|
||||
label,
|
||||
value: asText(field.value),
|
||||
};
|
||||
|
||||
@@ -186,7 +186,7 @@ export function invalidateRemoteBrowserCacheForDestination(
|
||||
cache: Record<string, RemoteBackupBrowserResponse>,
|
||||
pathByDestination: Record<string, string>,
|
||||
pageByKey: Record<string, number>
|
||||
): PersistedRemoteBrowserState {
|
||||
): Omit<PersistedRemoteBrowserState, 'refreshedAt'> {
|
||||
return {
|
||||
cache: Object.fromEntries(Object.entries(cache).filter(([key]) => !key.startsWith(`${destinationId}:`))),
|
||||
pathByDestination: Object.fromEntries(Object.entries(pathByDestination).filter(([key]) => key !== destinationId)),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { VaultDraftField } from './types';
|
||||
|
||||
// Keep metadata on each draft field so removing or reordering fields cannot
|
||||
// accidentally attach another field's link or unknown encrypted properties.
|
||||
export function cipherFieldMetadata(field: object): Pick<VaultDraftField, 'linkedId' | 'extra'> {
|
||||
const raw = field as Record<string, unknown>;
|
||||
return {
|
||||
...(typeof raw.linkedId === 'number' || raw.linkedId === null ? { linkedId: raw.linkedId } : {}),
|
||||
extra: Object.fromEntries(Object.entries(raw).filter(([key]) =>
|
||||
!['type', 'name', 'value', 'linkedId'].includes(key) && !/^dec[A-Z]/.test(key)
|
||||
)),
|
||||
};
|
||||
}
|
||||
+52
-17
@@ -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 {
|
||||
let s = '';
|
||||
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> {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -51,7 +82,7 @@ function getHmacSha256Key(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
return getCachedCryptoKey(
|
||||
hmacSha256KeyCache,
|
||||
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(
|
||||
aesCbcEncryptKeyCache,
|
||||
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(
|
||||
aesCbcDecryptKeyCache,
|
||||
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> {
|
||||
const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes;
|
||||
const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes;
|
||||
const key = await crypto.subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
const subtle = requireWebCrypto().subtle;
|
||||
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 },
|
||||
key,
|
||||
keyLen * 8
|
||||
@@ -99,7 +131,8 @@ export async function pbkdf2(
|
||||
|
||||
export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise<Uint8Array> {
|
||||
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);
|
||||
let previous = new Uint8Array(0);
|
||||
let offset = 0;
|
||||
@@ -110,7 +143,7 @@ export async function hkdfExpand(prk: Uint8Array, info: string, length: number):
|
||||
input.set(previous, 0);
|
||||
input.set(infoBytes, previous.length);
|
||||
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);
|
||||
result.set(previous.slice(0, copyLen), offset);
|
||||
offset += copyLen;
|
||||
@@ -134,28 +167,29 @@ export async function hkdf(
|
||||
info: toBufferSource(infoBytes),
|
||||
hash: 'SHA-256',
|
||||
};
|
||||
const key = await crypto.subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits(params, key, outputByteSize * 8);
|
||||
const subtle = requireWebCrypto().subtle;
|
||||
const key = await subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']);
|
||||
const bits = await subtle.deriveBits(params, key, outputByteSize * 8);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = requireWebCrypto().getRandomValues(new Uint8Array(16));
|
||||
const cipher = await encryptAesCbc(data, encKey, iv);
|
||||
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
|
||||
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> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = requireWebCrypto().getRandomValues(new Uint8Array(16));
|
||||
const cipher = await encryptAesCbc(data, encKey, iv);
|
||||
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
|
||||
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;
|
||||
c = Math.floor(c / 256);
|
||||
}
|
||||
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']);
|
||||
const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message)));
|
||||
const subtle = requireWebCrypto().subtle;
|
||||
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 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');
|
||||
|
||||
@@ -163,6 +163,7 @@ export function translateServerError(message: string | null | undefined, fallbac
|
||||
|
||||
const key = {
|
||||
'Account is disabled': 'txt_server_error_account_disabled',
|
||||
'The client copy of this cipher is out of date. Resync the client and try again.': 'txt_item_changed_elsewhere',
|
||||
'Another backup or restore run is already in progress': 'txt_backup_error_another_backup_or_restore_running',
|
||||
'Another backup run is already in progress': 'txt_backup_error_another_backup_running',
|
||||
'Backup archive upload failed': 'txt_backup_error_archive_upload_failed',
|
||||
@@ -238,6 +239,7 @@ export function translateServerError(message: string | null | undefined, fallbac
|
||||
'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 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 or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
|
||||
}[normalized];
|
||||
|
||||
@@ -8,6 +8,10 @@ const de: Record<string, string> = {
|
||||
"nav_vault_items": "Tresor",
|
||||
"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_import_export": "Import und Export",
|
||||
"nav_group_data_backup": "Daten & Backup",
|
||||
@@ -511,6 +515,7 @@ const de: Record<string, string> = {
|
||||
"txt_create_account": "Konto erstellen",
|
||||
"txt_registering": "Konto wird erstellt...",
|
||||
"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_failed": "Fehler beim Erstellen des Ordners",
|
||||
"txt_create_item_failed": "Fehler beim Erstellen des Eintrags",
|
||||
@@ -1082,6 +1087,7 @@ const de: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Fehler beim Entfernen des Geräts",
|
||||
"txt_remove_all_devices_failed": "Fehler beim Entfernen aller Geräte",
|
||||
"txt_update_item_failed": "Fehler beim Aktualisieren des Eintrags",
|
||||
"txt_item_changed_elsewhere": "Dieser Eintrag wurde in einem anderen Client geändert. Deine Änderungen stehen noch im Formular. Kopiere sie, öffne den Eintrag erneut und speichere ihn.",
|
||||
"txt_update_send_failed": "Fehler beim Aktualisieren der Sendung",
|
||||
"txt_update_user_status_failed": "Fehler beim Aktualisieren des Benutzerstatus",
|
||||
"txt_use_recovery_code": "Wiederherstellungscode verwenden",
|
||||
|
||||
@@ -31,6 +31,40 @@ const en: Record<string, string> = {
|
||||
"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_import_export": "Import & Export",
|
||||
"nav_group_data_backup": "Data & Backup",
|
||||
@@ -534,6 +568,7 @@ const en: Record<string, string> = {
|
||||
"txt_create_account": "Create Account",
|
||||
"txt_registering": "Creating account...",
|
||||
"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_failed": "Create folder failed",
|
||||
"txt_create_item_failed": "Create item failed",
|
||||
@@ -1105,6 +1140,7 @@ const en: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Failed to remove device",
|
||||
"txt_remove_all_devices_failed": "Failed to remove all devices",
|
||||
"txt_update_item_failed": "Update item failed",
|
||||
"txt_item_changed_elsewhere": "This item was changed in another client. Your edits are still in the form. Copy the changes you need, reopen the item, and save again.",
|
||||
"txt_update_send_failed": "Update send failed",
|
||||
"txt_update_user_status_failed": "Failed to update user status",
|
||||
"txt_use_recovery_code": "Use Recovery Code",
|
||||
|
||||
@@ -8,6 +8,10 @@ const es: Record<string, string> = {
|
||||
"nav_vault_items": "Bóveda",
|
||||
"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_import_export": "Importar y exportar",
|
||||
"nav_group_data_backup": "Datos y copias",
|
||||
@@ -511,6 +515,7 @@ const es: Record<string, string> = {
|
||||
"txt_create_account": "Crear cuenta",
|
||||
"txt_registering": "Creando cuenta...",
|
||||
"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_failed": "Error al crear carpeta",
|
||||
"txt_create_item_failed": "Error al crear elemento",
|
||||
@@ -1082,6 +1087,7 @@ const es: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Error al quitar dispositivo",
|
||||
"txt_remove_all_devices_failed": "Error al quitar todos los dispositivos",
|
||||
"txt_update_item_failed": "Error al actualizar elemento",
|
||||
"txt_item_changed_elsewhere": "Este elemento se modificó en otro cliente. Tus cambios siguen en el formulario. Copia los cambios necesarios, vuelve a abrir el elemento y guárdalo.",
|
||||
"txt_update_send_failed": "Error al actualizar envío",
|
||||
"txt_update_user_status_failed": "Error al actualizar estado de usuario",
|
||||
"txt_use_recovery_code": "Usar código de recuperación",
|
||||
|
||||
@@ -8,6 +8,10 @@ const fi: Record<string, string> = {
|
||||
"nav_vault_items": "Holvi",
|
||||
"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_import_export": "Tuonti ja Vienti",
|
||||
"nav_group_data_backup": "Data & Varmuuskopiointi",
|
||||
@@ -511,6 +515,7 @@ const fi: Record<string, string> = {
|
||||
"txt_create_account": "Luo tili",
|
||||
"txt_registering": "Luodaan tiliä...",
|
||||
"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_failed": "Kansion luonti epäonnistui",
|
||||
"txt_create_item_failed": "Nimikkeen luonti epäonnistui",
|
||||
@@ -1082,6 +1087,7 @@ const fi: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Laitteen poistaminen epäonnistui",
|
||||
"txt_remove_all_devices_failed": "Kaikkien laitteiden poistaminen epäonnistui",
|
||||
"txt_update_item_failed": "Nimikkeen päivitys epäonnistui",
|
||||
"txt_item_changed_elsewhere": "Kohdetta muutettiin toisessa sovelluksessa. Muokkauksesi ovat yhä lomakkeessa. Kopioi tarvittavat muutokset, avaa kohde uudelleen ja tallenna.",
|
||||
"txt_update_send_failed": "Lähetyksen päivitys epäonnistui",
|
||||
"txt_update_user_status_failed": "Käyttäjän tilan päivitys epäonnistui",
|
||||
"txt_use_recovery_code": "Käytä palautuskoodia",
|
||||
|
||||
@@ -8,6 +8,10 @@ const fr: Record<string, string> = {
|
||||
"nav_vault_items": "Coffre-fort",
|
||||
"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 d’utilisateur", "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 d’alias", "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 l’alias.", "txt_generator_long_word_username_description": "Combinez un ou plusieurs mots longs en un identifiant mémorable.",
|
||||
"nav_backup_strategy": "Sauvegarde Cloud",
|
||||
"nav_import_export": "Importer & Exporter",
|
||||
"nav_group_data_backup": "Données & Sauvegarde",
|
||||
@@ -511,6 +515,7 @@ const fr: Record<string, string> = {
|
||||
"txt_create_account": "Créer un compte",
|
||||
"txt_registering": "Création du compte...",
|
||||
"txt_register_failed": "L'inscription a échoué",
|
||||
"txt_web_crypto_unavailable": "La cryptographie sécurisée du navigateur n’est pas disponible. Ouvrez NodeWarden via HTTPS dans un navigateur compatible.",
|
||||
"txt_create_folder": "Créer un dossier",
|
||||
"txt_create_folder_failed": "La création du dossier a échoué",
|
||||
"txt_create_item_failed": "La création de l'élément a échoué",
|
||||
@@ -1082,6 +1087,7 @@ const fr: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Le retrait de l'appareil a échoué",
|
||||
"txt_remove_all_devices_failed": "Le retrait de tous les appareils a échoué",
|
||||
"txt_update_item_failed": "La mise à jour de l'élément a échoué",
|
||||
"txt_item_changed_elsewhere": "Cet élément a été modifié dans un autre client. Vos modifications restent dans le formulaire. Copiez les changements nécessaires, rouvrez cet élément et enregistrez-le.",
|
||||
"txt_update_send_failed": "La mise à jour de l'envoi a échoué",
|
||||
"txt_update_user_status_failed": "La mise à jour du statut de l'utilisateur a échoué",
|
||||
"txt_use_recovery_code": "Utiliser le code de récupération",
|
||||
|
||||
@@ -8,6 +8,10 @@ const it: Record<string, string> = {
|
||||
"nav_vault_items": "Cassaforte",
|
||||
"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 l’indirizzo email o il dominio per generare un alias.", "txt_generator_email_description": "I dati restano su questo dispositivo e servono solo a comporre l’alias.", "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_import_export": "Importa ed Esporta",
|
||||
"nav_group_data_backup": "Dati e Backup",
|
||||
@@ -511,6 +515,7 @@ const it: Record<string, string> = {
|
||||
"txt_create_account": "Crea Account",
|
||||
"txt_registering": "Creazione Account in corso...",
|
||||
"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_failed": "Impossibile creare la cartella",
|
||||
"txt_create_item_failed": "Impossibile creare l'elemento",
|
||||
@@ -1082,6 +1087,7 @@ const it: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Impossibile rimuovere il dispositivo",
|
||||
"txt_remove_all_devices_failed": "Impossibile rimuovere tutti i dispositivi",
|
||||
"txt_update_item_failed": "Aggiornamento elemento fallito",
|
||||
"txt_item_changed_elsewhere": "Questo elemento è stato modificato in un altro client. Le tue modifiche sono ancora nel modulo. Copia quelle necessarie, riapri questo elemento e salva.",
|
||||
"txt_update_send_failed": "Aggiornamento invio fallito",
|
||||
"txt_update_user_status_failed": "Aggiornamento stato utente fallito",
|
||||
"txt_use_recovery_code": "Usa Codice di Recupero",
|
||||
|
||||
@@ -9,6 +9,10 @@ const ru: Record<string, string> = {
|
||||
"nav_vault_items": "Хранилище",
|
||||
"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_import_export": "Импорт и экспорт",
|
||||
"nav_group_data_backup": "Данные и резервные копии",
|
||||
@@ -511,6 +515,7 @@ const ru: Record<string, string> = {
|
||||
"txt_create_account": "Создать учетную запись",
|
||||
"txt_registering": "Создание учетной записи...",
|
||||
"txt_register_failed": "Не удалось зарегистрироваться",
|
||||
"txt_web_crypto_unavailable": "Безопасная криптография браузера недоступна. Откройте NodeWarden по HTTPS в поддерживаемом браузере.",
|
||||
"txt_create_folder": "Создать папку",
|
||||
"txt_create_folder_failed": "Создать папку не удалось",
|
||||
"txt_create_item_failed": "Создать элемент не удалось",
|
||||
@@ -1082,6 +1087,7 @@ const ru: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Не удалось удалить устройство.",
|
||||
"txt_remove_all_devices_failed": "Не удалось удалить все устройства.",
|
||||
"txt_update_item_failed": "Обновить элемент не удалось",
|
||||
"txt_item_changed_elsewhere": "Элемент изменён в другом клиенте. Ваши правки остались в форме. Скопируйте нужные изменения, откройте элемент заново и сохраните его.",
|
||||
"txt_update_send_failed": "Send обновления не удалась",
|
||||
"txt_update_user_status_failed": "Не удалось обновить статус пользователя",
|
||||
"txt_use_recovery_code": "Использовать код восстановления",
|
||||
|
||||
@@ -8,6 +8,10 @@ const sv: Record<string, string> = {
|
||||
"nav_vault_items": "Valv",
|
||||
"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_import_export": "Importera och Exportera",
|
||||
"nav_group_data_backup": "Data och Säkerhetskopiering",
|
||||
@@ -511,6 +515,7 @@ const sv: Record<string, string> = {
|
||||
"txt_create_account": "Skapa konto",
|
||||
"txt_registering": "Skapar konto...",
|
||||
"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_failed": "Misslyckades med att skapa mapp",
|
||||
"txt_create_item_failed": "Misslyckades med att skapa objekt",
|
||||
@@ -1082,6 +1087,7 @@ const sv: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Misslyckades med att ta bort enhet",
|
||||
"txt_remove_all_devices_failed": "Misslyckades med att ta bort alla enheter",
|
||||
"txt_update_item_failed": "Misslyckades med att uppdatera objekt",
|
||||
"txt_item_changed_elsewhere": "Objektet ändrades i en annan klient. Dina ändringar finns kvar i formuläret. Kopiera de ändringar du behöver, öppna objektet igen och spara.",
|
||||
"txt_update_send_failed": "Misslyckades med att uppdatera sändning",
|
||||
"txt_update_user_status_failed": "Misslyckades med att uppdatera användarstatus",
|
||||
"txt_use_recovery_code": "Använd återställningskod",
|
||||
|
||||
@@ -11,6 +11,10 @@ const zhCN: Record<string, string> = {
|
||||
"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_import_export": "导入导出",
|
||||
"nav_group_data_backup": "数据与备份",
|
||||
@@ -514,6 +518,7 @@ const zhCN: Record<string, string> = {
|
||||
"txt_create_account": "创建账户",
|
||||
"txt_registering": "正在注册...",
|
||||
"txt_register_failed": "注册失败",
|
||||
"txt_web_crypto_unavailable": "当前浏览器环境无法使用安全加密。请通过 HTTPS 打开 NodeWarden,并使用受支持的现代浏览器。",
|
||||
"txt_create_folder": "创建文件夹",
|
||||
"txt_create_folder_failed": "创建文件夹失败",
|
||||
"txt_create_item_failed": "创建项目失败",
|
||||
@@ -1085,6 +1090,7 @@ const zhCN: Record<string, string> = {
|
||||
"txt_remove_device_failed": "移除设备失败",
|
||||
"txt_remove_all_devices_failed": "移除所有设备失败",
|
||||
"txt_update_item_failed": "更新项目失败",
|
||||
"txt_item_changed_elsewhere": "条目已在其他客户端修改。你的编辑内容仍保留在表单中,请复制需要的修改,重新打开条目后再保存。",
|
||||
"txt_update_send_failed": "更新 Send 失败",
|
||||
"txt_update_user_status_failed": "更新用户状态失败",
|
||||
"txt_use_recovery_code": "使用恢复代码",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)));
|
||||
}
|
||||
@@ -23,7 +23,7 @@ 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 {
|
||||
function ensurePasswordSecurityState(fingerprint: string): InternalPasswordSecurityState {
|
||||
if (state?.fingerprint !== fingerprint) {
|
||||
state?.controller?.abort();
|
||||
state = createState(fingerprint);
|
||||
@@ -31,6 +31,10 @@ export function getPasswordSecurityState(fingerprint: string): PasswordSecurityS
|
||||
return state;
|
||||
}
|
||||
|
||||
export function getPasswordSecurityState(fingerprint: string): PasswordSecurityState {
|
||||
return ensurePasswordSecurityState(fingerprint);
|
||||
}
|
||||
|
||||
export function readPasswordSecurityState(fingerprint: string): PasswordSecurityState | null {
|
||||
return state?.fingerprint === fingerprint ? state : null;
|
||||
}
|
||||
@@ -41,7 +45,7 @@ export function subscribePasswordSecurityState(listener: () => void): () => void
|
||||
}
|
||||
|
||||
export function startPasswordSecurityScan(fingerprint: string, ciphers: Cipher[]): void {
|
||||
const current = getPasswordSecurityState(fingerprint);
|
||||
const current = ensurePasswordSecurityState(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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -320,10 +320,13 @@ export interface VaultDraftField {
|
||||
type: CustomFieldType;
|
||||
label: string;
|
||||
value: string;
|
||||
linkedId?: number | null;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface VaultDraft {
|
||||
id?: string;
|
||||
revisionDate?: string;
|
||||
type: number;
|
||||
favorite: boolean;
|
||||
name: string;
|
||||
|
||||
@@ -4,7 +4,6 @@ import App from './App';
|
||||
import { initI18n } from './lib/i18n';
|
||||
import { registerNodeWardenServiceWorker } from './lib/pwa';
|
||||
import './tailwind.css';
|
||||
import './styles.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
||||
@@ -353,7 +353,7 @@
|
||||
}
|
||||
|
||||
.not-found-logo {
|
||||
@apply h-14 w-14 flex-shrink-0 object-contain;
|
||||
@apply h-14 w-14 shrink-0 object-contain;
|
||||
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@
|
||||
.not-found-code {
|
||||
@apply rounded-full px-3 py-1 text-sm font-extrabold;
|
||||
background: #eef4ff;
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.not-found-copy {
|
||||
@@ -465,7 +465,7 @@
|
||||
}
|
||||
|
||||
.standalone-brand-logo {
|
||||
@apply h-14 w-[70px] flex-shrink-0 object-contain;
|
||||
@apply h-14 w-[70px] shrink-0 object-contain;
|
||||
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
|
||||
}
|
||||
|
||||
@@ -574,7 +574,7 @@
|
||||
|
||||
.jwt-inline-link {
|
||||
@apply font-bold no-underline;
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.jwt-inline-link:hover {
|
||||
@@ -613,7 +613,7 @@
|
||||
|
||||
.standalone-footer a {
|
||||
@apply font-bold no-underline;
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.standalone-footer a:hover {
|
||||
@@ -622,5 +622,5 @@
|
||||
|
||||
.standalone-version {
|
||||
@apply font-bold;
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
@@ -220,7 +220,7 @@
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .card-brand-icon {
|
||||
color: #bfdbfe;
|
||||
color: var(--primary-strong);
|
||||
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--line));
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply h-12 w-full rounded-xl border px-3.5 py-2.5 outline-none;
|
||||
@apply h-12 w-full rounded-xl border px-3.5 py-2.5 outline-hidden;
|
||||
background: var(--panel);
|
||||
border-color: rgba(74, 103, 150, 0.34);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
.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-strength-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted-strong); font-size: 13px; font-weight: 700; }
|
||||
.generator-strength-row > span, .generator-security-note { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.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); }
|
||||
@@ -20,6 +22,11 @@
|
||||
.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-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; } }
|
||||
@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; } }
|
||||
@@ -30,6 +30,14 @@
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
/* The detail panel is only as wide as the viewport minus the app sidebar and the
|
||||
two backup sidebars (~858px of chrome), so viewport breakpoints cannot tell when
|
||||
the remote backup list no longer fits. Query the panel's own width instead. */
|
||||
.backup-detail-panel {
|
||||
container-type: inline-size;
|
||||
container-name: backup-detail;
|
||||
}
|
||||
|
||||
.backup-actions-stack {
|
||||
@apply grid gap-2.5;
|
||||
}
|
||||
@@ -99,7 +107,7 @@
|
||||
@apply inline-flex h-[22px] w-[22px] shrink-0 cursor-pointer items-center justify-center rounded-full p-0 text-[13px] font-extrabold leading-none;
|
||||
border: 1px solid #bfd1f3;
|
||||
background: #eef4ff;
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.backup-help-trigger:hover,
|
||||
@@ -194,7 +202,7 @@
|
||||
}
|
||||
|
||||
.backup-recommendation-step a {
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
@@ -316,14 +324,14 @@
|
||||
}
|
||||
|
||||
.backup-interval-preset:hover:not(:disabled) {
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.backup-interval-preset.active {
|
||||
border-color: #2563eb;
|
||||
background: #2563eb;
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -371,13 +379,21 @@
|
||||
}
|
||||
|
||||
.backup-browser-list {
|
||||
@apply overflow-hidden rounded-lg border bg-white;
|
||||
@apply rounded-lg border bg-white;
|
||||
border: 1px solid var(--line);
|
||||
/* Never clip silently: if a row still overflows (long locale labels, different
|
||||
font metrics), scroll instead of hiding the action buttons. */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.backup-browser-head,
|
||||
.backup-browser-row {
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
|
||||
}
|
||||
|
||||
.backup-browser-head {
|
||||
@apply grid items-center gap-3 px-3 py-2 text-[11px] font-bold uppercase tracking-[0.08em];
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
@@ -404,7 +420,6 @@
|
||||
|
||||
.backup-browser-row {
|
||||
@apply grid items-center gap-3 px-3 py-2;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
@@ -434,7 +449,42 @@
|
||||
|
||||
.backup-browser-actions {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
/* Wrap instead of clipping: three non-shrinking buttons overflow the 220px
|
||||
actions track once the panel is narrow, hiding Delete entirely. */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.backup-browser-actions .btn.small {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Must stay after every base .backup-browser-* rule above: container queries add
|
||||
no specificity, so source order decides which declaration wins.
|
||||
The four tracks need ~702px, plus headroom for the widest locale label
|
||||
(fr "Restauration en cours..." pushes a button past the 220px actions track).
|
||||
Below that, stack the row into a card and drop the column header. */
|
||||
@container backup-detail (max-width: 820px) {
|
||||
.backup-browser-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.backup-browser-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.backup-browser-meta {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backup-browser-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.backup-browser-nav {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.backup-browser-empty {
|
||||
@@ -752,15 +802,25 @@
|
||||
}
|
||||
|
||||
.yubikey-manage-dialog-body {
|
||||
@apply mt-3 space-y-4;
|
||||
@apply mt-3;
|
||||
}
|
||||
|
||||
.yubikey-manage-dialog-body > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-plain-steps {
|
||||
@apply m-0 space-y-1 border-b pb-3 pl-5;
|
||||
@apply m-0 border-b pb-3 pl-5;
|
||||
border-color: var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-plain-steps > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.yubikey-input-row {
|
||||
@apply grid items-center gap-2;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -775,8 +835,9 @@
|
||||
@apply h-9 w-9 p-0;
|
||||
}
|
||||
|
||||
.settings-checkbox-block {
|
||||
@apply space-y-2;
|
||||
.settings-checkbox-block > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.checkbox-inline {
|
||||
@@ -1786,7 +1847,7 @@
|
||||
}
|
||||
|
||||
.restore-progress-item.active {
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.restore-progress-item.done {
|
||||
@@ -1799,7 +1860,7 @@
|
||||
}
|
||||
|
||||
.restore-progress-item.active .restore-progress-dot {
|
||||
background: #1d4ed8;
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.restore-progress-item.done .restore-progress-dot {
|
||||
@@ -1871,7 +1932,7 @@
|
||||
.authorized-device-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #2563eb;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.authorized-devices-table td:first-child {
|
||||
@@ -1879,7 +1940,9 @@
|
||||
}
|
||||
|
||||
.authorized-devices-actions {
|
||||
flex-wrap: nowrap;
|
||||
/* Wrap instead of clipping: four non-shrinking buttons overflow the
|
||||
fixed 26% actions column at common widths, hiding Delete entirely. */
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,29 +41,10 @@
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.import-export-panels,
|
||||
.backup-browser-row {
|
||||
.import-export-panels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-browser-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.backup-browser-meta {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backup-browser-actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.backup-browser-nav {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-twofactor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -151,7 +132,7 @@
|
||||
}
|
||||
|
||||
.topbar-actions > .network-status-badge {
|
||||
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 rounded-xl p-0 text-[0];
|
||||
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 rounded-xl p-0 text-[0px];
|
||||
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||
border-color: var(--line);
|
||||
box-shadow: none;
|
||||
@@ -163,7 +144,7 @@
|
||||
|
||||
.mobile-sidebar-toggle,
|
||||
.mobile-lock-btn {
|
||||
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 rounded-xl p-0 text-[0];
|
||||
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 rounded-xl p-0 text-[0px];
|
||||
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||
border-color: var(--line);
|
||||
box-shadow: none;
|
||||
@@ -470,7 +451,7 @@
|
||||
}
|
||||
|
||||
.mobile-fab-trigger {
|
||||
@apply h-9 w-9 gap-0 rounded-full p-0 text-[0];
|
||||
@apply h-9 w-9 gap-0 rounded-full p-0 text-[0px];
|
||||
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.28);
|
||||
transition: transform 180ms var(--ease-spring), box-shadow var(--dur-fast) var(--ease-out-soft);
|
||||
}
|
||||
@@ -1248,29 +1229,10 @@
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.backup-browser-row,
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-browser-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.backup-browser-meta {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backup-browser-actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.backup-browser-nav {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.backup-grid {
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
|
||||
Vendored
+15
-4
@@ -31,7 +31,12 @@
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
@apply min-w-0 flex-1 space-y-2;
|
||||
@apply min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.skeleton-content > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
@@ -63,13 +68,19 @@
|
||||
}
|
||||
|
||||
.skeleton-page {
|
||||
@apply h-full space-y-4 p-4;
|
||||
@apply h-full p-4;
|
||||
}
|
||||
|
||||
.skeleton-page > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
@apply mb-6;
|
||||
}
|
||||
|
||||
.skeleton-body {
|
||||
@apply space-y-3;
|
||||
.skeleton-body > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
.vault-grid {
|
||||
@apply grid h-full min-h-0 gap-3 p-0.5;
|
||||
grid-template-columns: 270px minmax(400px, 36%) minmax(400px, 1fr);
|
||||
/* The list and detail panes already scroll internally (overflow: auto), which
|
||||
zeroes their automatic minimum size. Keeping the track minimums at 0 lets the
|
||||
grid fit the content area at any width instead of overflowing the page; wide
|
||||
viewports are unaffected because 36%/1fr were never binding there. */
|
||||
grid-template-columns: 270px minmax(0, 36%) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
@@ -47,11 +51,11 @@
|
||||
}
|
||||
|
||||
.folder-add-btn:hover {
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@apply h-12 w-full rounded-xl border px-3.5 py-2.5 text-base text-ink outline-none transition;
|
||||
@apply h-12 w-full rounded-xl border px-3.5 py-2.5 text-base text-ink outline-hidden transition;
|
||||
background: var(--panel);
|
||||
border-color: rgba(74, 103, 150, 0.34);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
|
||||
@@ -161,7 +165,7 @@
|
||||
}
|
||||
|
||||
.folder-sort-btn:hover {
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
background: #dbeafe;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
@@ -230,7 +234,7 @@ select.input.duplicate-mode-toolbar-select {
|
||||
}
|
||||
|
||||
.list-head {
|
||||
@apply mb-1.5 flex items-center gap-2;
|
||||
@apply mb-1.5 flex flex-wrap items-center gap-2;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
@@ -412,7 +416,7 @@ select.input.duplicate-mode-toolbar-select {
|
||||
}
|
||||
|
||||
.desktop-create-trigger {
|
||||
@apply h-[34px] w-[34px] min-w-[34px] gap-0 rounded-[10px] p-0 text-[0];
|
||||
@apply h-[34px] w-[34px] min-w-[34px] gap-0 rounded-[10px] p-0 text-[0px];
|
||||
}
|
||||
|
||||
.desktop-create-trigger .btn-icon {
|
||||
@@ -582,7 +586,7 @@ select.input.duplicate-mode-toolbar-select {
|
||||
}
|
||||
|
||||
.card-brand-icon {
|
||||
@apply inline-grid h-6 w-9 shrink-0 place-items-center rounded border text-[7px] font-black uppercase leading-none;
|
||||
@apply inline-grid h-6 w-9 shrink-0 place-items-center rounded-sm border text-[7px] font-black uppercase leading-none;
|
||||
letter-spacing: 0;
|
||||
color: #1e3a8a;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f4f7fb 100%);
|
||||
@@ -860,7 +864,9 @@ select.input.duplicate-mode-toolbar-select {
|
||||
|
||||
.kv-row {
|
||||
@apply grid items-center gap-2.5 py-2.5;
|
||||
grid-template-columns: minmax(0px, 80px) minmax(0, 1fr) auto;
|
||||
/* Floor the value column so the auto-sized actions column wraps its
|
||||
buttons instead of collapsing the value to 0 width. */
|
||||
grid-template-columns: minmax(0px, 80px) minmax(min(35%, 140px), 1fr) auto;
|
||||
border-bottom: 1px solid rgba(154, 172, 205, 0.22);
|
||||
}
|
||||
|
||||
@@ -962,7 +968,7 @@ select.input.duplicate-mode-toolbar-select {
|
||||
}
|
||||
|
||||
.totp-ring-progress {
|
||||
stroke: #2563eb;
|
||||
stroke: var(--primary);
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 260ms linear, stroke 200ms ease;
|
||||
}
|
||||
|
||||
+71
-3
@@ -1,3 +1,71 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss' source(none);
|
||||
@import './styles.css';
|
||||
|
||||
@source '../index.html';
|
||||
@source './**/*.{ts,tsx}';
|
||||
|
||||
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
|
||||
|
||||
/* Inline aliases keep utilities tied to the active light/dark design tokens. */
|
||||
@theme inline {
|
||||
--color-canvas: var(--bg-accent);
|
||||
--color-panel: var(--panel);
|
||||
--color-panel-soft: var(--panel-soft);
|
||||
--color-panel-muted: var(--panel-muted);
|
||||
--color-line: var(--line);
|
||||
--color-line-soft: var(--line-soft);
|
||||
--color-ink: var(--text);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-strong: var(--muted-strong);
|
||||
--color-brand: var(--primary);
|
||||
--color-brand-hover: var(--primary-hover);
|
||||
--color-brand-strong: var(--primary-strong);
|
||||
--color-danger: var(--danger);
|
||||
|
||||
--shadow-soft: var(--shadow-sm);
|
||||
--shadow-panel: var(--shadow-md);
|
||||
--shadow-elevated: var(--shadow-lg);
|
||||
|
||||
--font-sans: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
|
||||
/* Preserve utility radii independently of tokens.css's --radius-* names. */
|
||||
--radius-xs: 0.125rem;
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--radius-2xl: 1rem;
|
||||
--radius-3xl: 1.5rem;
|
||||
|
||||
/* Keep line heights fixed when component styles override the font size. */
|
||||
--text-xs--line-height: 1rem;
|
||||
--text-sm--line-height: 1.25rem;
|
||||
--text-base--line-height: 1.5rem;
|
||||
--text-lg--line-height: 1.75rem;
|
||||
--text-xl--line-height: 1.75rem;
|
||||
--text-2xl--line-height: 2rem;
|
||||
--text-3xl--line-height: 2.25rem;
|
||||
--text-4xl--line-height: 2.5rem;
|
||||
}
|
||||
|
||||
/* Retain the v3 defaults where components do not set their own values. */
|
||||
@layer base {
|
||||
*,
|
||||
::before,
|
||||
::after,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role='button']:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
+52
-61
@@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import preact from '@preact/preset-vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig, type Plugin } from 'vite';
|
||||
|
||||
const rootDir = fileURLToPath(new URL('.', import.meta.url));
|
||||
@@ -102,6 +103,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) {
|
||||
const keys = await cache.keys();
|
||||
if (keys.length <= maxEntries) return;
|
||||
@@ -145,6 +168,13 @@ self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(request.url);
|
||||
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') {
|
||||
event.respondWith(appShellNavigation(request));
|
||||
if (navigator.onLine !== false) {
|
||||
@@ -229,39 +259,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 }) => {
|
||||
const isDemo = mode === 'demo';
|
||||
|
||||
return {
|
||||
root: rootDir,
|
||||
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
|
||||
plugins: [preact(), tailwindcss(), searchIndexPolicyPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
|
||||
define: {
|
||||
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
|
||||
},
|
||||
@@ -282,41 +285,29 @@ export default defineConfig(({ mode }) => {
|
||||
sourcemap: false,
|
||||
target: 'esnext',
|
||||
chunkSizeWarningLimit: 800,
|
||||
rollupOptions: {
|
||||
treeshake: {
|
||||
preset: 'smallest',
|
||||
rolldownOptions: {
|
||||
checks: {
|
||||
pluginTimings: false,
|
||||
},
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const normalized = id.replace(/\\/g, '/');
|
||||
|
||||
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
|
||||
if (localeMatch) {
|
||||
if (localeMatch[1] === 'en') return undefined;
|
||||
return `i18n-${localeMatch[1]}`;
|
||||
}
|
||||
|
||||
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;
|
||||
codeSplitting: {
|
||||
groups: [
|
||||
{
|
||||
name: 'shared',
|
||||
minShareCount: 2,
|
||||
minSize: 50 * 1024,
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
name(id) {
|
||||
const normalized = id.replace(/\\/g, '/');
|
||||
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
|
||||
return localeMatch && localeMatch[1] !== 'en' ? `i18n-${localeMatch[1]}` : null;
|
||||
},
|
||||
test: /[\\/]src[\\/]lib[\\/]i18n[\\/]locales[\\/]/,
|
||||
priority: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ command = "npm run build"
|
||||
[assets]
|
||||
binding = "ASSETS"
|
||||
directory = "./dist"
|
||||
html_handling = "none"
|
||||
not_found_handling = "single-page-application"
|
||||
run_worker_first = true
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ command = "npm run build"
|
||||
[assets]
|
||||
binding = "ASSETS"
|
||||
directory = "./dist"
|
||||
html_handling = "none"
|
||||
not_found_handling = "single-page-application"
|
||||
run_worker_first = true
|
||||
|
||||
|
||||
Reference in new issue
Block a user