mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-11-04 18:40:20 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3ef9ef5dc | ||
|
|
fbc0e65df8 | ||
|
|
cb871095c1 | ||
|
|
a8d0e70263 | ||
|
|
470232d140 | ||
|
|
7a5c365dae | ||
|
|
e92c91e771 | ||
|
|
02081ea07f | ||
|
|
4c241aa64b | ||
|
|
293f5ce196 | ||
|
|
5f71cb388c |
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: "/"
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: "22.23.4"
|
||||
|
||||
- name: Run pnpm audit
|
||||
shell: bash
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: "22.23.4"
|
||||
|
||||
- name: Sync generated Bitwarden domains
|
||||
env:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
24.22.1
|
||||
@@ -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
|
||||
|
||||
@@ -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
+1004
-1377
File diff suppressed because it is too large.
Load diff
+23
-21
@@ -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.23.4 || ^24.22.1 || ^26.12.1"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "wrangler dev -c wrangler.toml",
|
||||
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
||||
@@ -16,6 +19,7 @@
|
||||
"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",
|
||||
@@ -50,38 +54,36 @@
|
||||
},
|
||||
"overrides": {
|
||||
"nanoid": "3.3.18",
|
||||
"undici": "8.9.0",
|
||||
"undici": "8.10.2",
|
||||
"@babel/core": ">=7.29.6",
|
||||
"esbuild": ">=0.28.1",
|
||||
"ws": "8.21.0",
|
||||
"sharp": "0.35.4",
|
||||
"sharp": "0.35.5",
|
||||
"browserslist": "4.28.7",
|
||||
"baseline-browser-mapping": "2.11.0",
|
||||
"postcss-selector-parser": "6.1.3"
|
||||
"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.23",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tsx": "^4.22.4",
|
||||
"@cloudflare/workers-types": "^5.20261001.1",
|
||||
"@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.3",
|
||||
"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,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);
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '1.8.0';
|
||||
export const APP_VERSION = '1.8.1';
|
||||
@@ -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;
|
||||
|
||||
+17
-12
@@ -174,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 {
|
||||
@@ -522,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,
|
||||
};
|
||||
}
|
||||
@@ -655,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,
|
||||
@@ -1048,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);
|
||||
@@ -1057,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);
|
||||
}
|
||||
|
||||
@@ -1065,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)
|
||||
@@ -1076,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,
|
||||
};
|
||||
@@ -1115,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);
|
||||
@@ -1451,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);
|
||||
|
||||
@@ -1472,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
|
||||
|
||||
+18
-28
@@ -391,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 &&
|
||||
@@ -435,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,
|
||||
@@ -577,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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+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"
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -833,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 {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
)),
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -1087,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",
|
||||
|
||||
@@ -1140,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",
|
||||
|
||||
@@ -1087,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",
|
||||
|
||||
@@ -1087,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",
|
||||
|
||||
@@ -1087,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",
|
||||
|
||||
@@ -1087,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",
|
||||
|
||||
@@ -1087,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": "Использовать код восстановления",
|
||||
|
||||
@@ -1087,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",
|
||||
|
||||
@@ -1090,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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -802,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;
|
||||
@@ -825,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 {
|
||||
|
||||
@@ -132,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;
|
||||
@@ -144,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;
|
||||
@@ -451,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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -55,7 +55,7 @@
|
||||
}
|
||||
|
||||
.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);
|
||||
@@ -416,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 {
|
||||
@@ -586,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%);
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
@@ -263,7 +264,7 @@ export default defineConfig(({ mode }) => {
|
||||
|
||||
return {
|
||||
root: rootDir,
|
||||
plugins: [preact(), searchIndexPolicyPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
|
||||
plugins: [preact(), tailwindcss(), searchIndexPolicyPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
|
||||
define: {
|
||||
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user