mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-22 21:50:13 +00:00
Compare commits
17 Commits
8f2704fd41
...
v1.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5048cc0720 | |||
| 3f785febc8 | |||
| 907126d152 | |||
| c1f57957c0 | |||
| cd2ec8240b | |||
| 16bde22604 | |||
| 4900de0444 | |||
| 79ed7c9f85 | |||
| 9a21504f40 | |||
| 045b23fc47 | |||
| 42b765b113 | |||
| f9fe53285f | |||
| 46ba8b9950 | |||
| f096681a2b | |||
| fe0c66c561 | |||
| add921b3b3 | |||
| f1b716fb31 |
+5
-17
@@ -1,17 +1,5 @@
|
|||||||
# CodeGraph data files
|
# CodeGraph data files — local to each machine, not for committing.
|
||||||
# These are local to each machine and should not be committed
|
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||||
|
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||||
# Database
|
*
|
||||||
*.db
|
!.gitignore
|
||||||
*.db-wal
|
|
||||||
*.db-shm
|
|
||||||
|
|
||||||
# Cache
|
|
||||||
cache/
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Hook markers
|
|
||||||
.dirty
|
|
||||||
*.pid
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ build/
|
|||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
docs/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
|
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
|
||||||
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
|
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
|
||||||
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
||||||
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
|
| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
|
||||||
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
||||||
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
||||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@
|
|||||||
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
|
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
|
||||||
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
|
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
|
||||||
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
|
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
|
||||||
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
|
| Real-time sync | ✅ | ✅ | Web, browser extension, desktop, and mobile clients stay in sync in real time |
|
||||||
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
||||||
| Send | ✅ | ✅ | Supports both text and file Sends |
|
| Send | ✅ | ✅ | Supports both text and file Sends |
|
||||||
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
|
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
|
||||||
|
|||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Thank you for helping keep NodeWarden safe.
|
||||||
|
|
||||||
|
Please **do not report security vulnerabilities through public GitHub issues, discussions, pull requests, or chat groups**.
|
||||||
|
|
||||||
|
Use GitHub Private Vulnerability Reporting instead:
|
||||||
|
|
||||||
|
1. Open the NodeWarden repository on GitHub.
|
||||||
|
2. Go to **Security and quality**.
|
||||||
|
3. Click **Report a vulnerability**.
|
||||||
|
4. Submit the report privately.
|
||||||
|
|
||||||
|
NodeWarden is independent from Bitwarden. Please do not report NodeWarden-specific issues to the official Bitwarden team.
|
||||||
|
|
||||||
|
## What to Include
|
||||||
|
|
||||||
|
Please include as much detail as possible:
|
||||||
|
|
||||||
|
* A clear description of the vulnerability.
|
||||||
|
* Steps to reproduce.
|
||||||
|
* Affected version, commit, or deployment method.
|
||||||
|
* Affected area, such as login, sync, vault data, attachments, Send, import/export, backup/restore, Passkey, WebAuthn, or API routes.
|
||||||
|
* Expected behavior and actual behavior.
|
||||||
|
* Security impact, such as authentication bypass, authorization bypass, replay, cross-user access, token misuse, data leakage, or secret exposure.
|
||||||
|
* Proof of concept, logs, screenshots, or request examples, if safe to share privately.
|
||||||
|
|
||||||
|
Please redact real passwords, tokens, private keys, recovery keys, vault data, and other secrets before submitting.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Security reports are welcome for issues affecting NodeWarden itself, including:
|
||||||
|
|
||||||
|
* Authentication and session handling.
|
||||||
|
* User authorization and cross-user access.
|
||||||
|
* Vault data, cipher sync, attachments, and Send.
|
||||||
|
* Import, export, backup, and restore.
|
||||||
|
* Passkey, WebAuthn, and two-factor authentication.
|
||||||
|
* Secret handling and provider credentials.
|
||||||
|
* Cloudflare Workers, D1, R2, KV, WebDAV, or S3 behavior caused by NodeWarden code or documentation.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
The following are usually out of scope:
|
||||||
|
|
||||||
|
* Issues only affecting third-party services or user infrastructure.
|
||||||
|
* Misconfigured personal deployments not caused by NodeWarden defaults.
|
||||||
|
* Social engineering or phishing.
|
||||||
|
* Denial-of-service testing.
|
||||||
|
* Scanner-only reports without a practical exploit path.
|
||||||
|
* Reports that only mention outdated dependencies without showing real impact.
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
NodeWarden is maintained on a best-effort basis.
|
||||||
|
|
||||||
|
We aim to acknowledge valid private reports within 72 hours, investigate the issue, and release a fix or mitigation when appropriate.
|
||||||
|
|
||||||
|
Please do not publicly disclose vulnerability details before a fix or mitigation is available.
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
Security fixes are generally provided for the latest release and the latest code on the default branch.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| -------------- | ---------------------- |
|
||||||
|
| Latest release | Yes |
|
||||||
|
| `main` branch | Yes |
|
||||||
|
| Older releases | Best effort |
|
||||||
|
| Modified forks | Not directly supported |
|
||||||
|
|
||||||
|
## Rewards
|
||||||
|
|
||||||
|
NodeWarden does not currently operate a paid bug bounty program.
|
||||||
@@ -1,785 +0,0 @@
|
|||||||
# NodeWarden Passkey 登录研究记录
|
|
||||||
|
|
||||||
记录日期:2026-06-09
|
|
||||||
研究范围:NodeWarden 自己的 server、web 登录/注册链路,以及官方 Bitwarden server、web、browser extension 对账户 passkey 登录的实现方式。
|
|
||||||
|
|
||||||
## 结论先放前面
|
|
||||||
|
|
||||||
NodeWarden 现在已经有完整的主密码注册、主密码登录、刷新 token、2FA、设备记录、官方客户端兼容的 `UserDecryptionOptions`,也支持 vault item 里的 `login.fido2Credentials` 字段。但它还没有“账户 passkey 登录”。现有 `src/utils/passkey.ts` 只有 base64url、challenge、clientData 解析这类工具函数,不能完成 FIDO2/WebAuthn 服务端注册和认证验证。
|
|
||||||
|
|
||||||
要支持“自己的 web 用 passkey 登录”和“官方/自定义浏览器扩展也能 passkey 登录”,不能只加一个登录按钮。必须补齐四块:
|
|
||||||
|
|
||||||
1. Server 端新增账户 WebAuthn credential 表、challenge/token 防重放机制、FIDO2 attestation/assertion 验证、`grant_type=webauthn`。
|
|
||||||
2. Server 响应里按 Bitwarden 形状返回 PRF 解密材料:登录 token 响应用单个 `UserDecryptionOptions.WebAuthnPrfOption`,sync 响应用多个 `UserDecryption.WebAuthnPrfOptions`。
|
|
||||||
3. NodeWarden web 新增 passkey 注册、管理、登录和 PRF 解锁 vault key 的客户端流程。
|
|
||||||
4. 扩展兼容要跟官方 Bitwarden endpoint 和 response shape 对齐。官方 browser extension 当前只在 Chromium 系浏览器开放 passkey 登录,因为 Firefox/Safari 扩展环境还不能按官方代码需要的方式覆盖 RP ID。
|
|
||||||
|
|
||||||
下面按代码链路展开。
|
|
||||||
|
|
||||||
## 术语边界
|
|
||||||
|
|
||||||
这里有三个容易混淆的东西,文档后面严格区分:
|
|
||||||
|
|
||||||
- 账户 passkey 登录:用户不用主密码,使用 WebAuthn/passkey 完成账号认证,并且用 PRF 解开 vault user key。官方 Bitwarden 叫 `WebAuthnLogin`。
|
|
||||||
- Vault item 里的 passkey:某个登录条目保存网站 passkey/FIDO2 credential 数据,对应 NodeWarden 的 `cipher.login.fido2Credentials`。这是“保险库保存别的网站 passkey”,不是“登录 NodeWarden 账号”。
|
|
||||||
- WebAuthn 2FA:主密码登录之后用安全密钥做第二因素。官方旧 web repo 里主要是这一类,不等于 passkey 登录。
|
|
||||||
|
|
||||||
## NodeWarden 现状
|
|
||||||
|
|
||||||
### 路由和入口
|
|
||||||
|
|
||||||
NodeWarden 后端是 Cloudflare Workers + D1。主入口 `src/index.ts` 初始化存储后进入 router。认证边界在:
|
|
||||||
|
|
||||||
- `src/router-public.ts`:公开接口,包含 `/identity/connect/token`、`/identity/accounts/prelogin`、`/api/accounts/register`。
|
|
||||||
- `src/router-authenticated.ts`:需要 access token 的接口,包含 profile、change password、TOTP、sync、vault、devices。
|
|
||||||
- `src/handlers/identity.ts`:OAuth/token 兼容入口。
|
|
||||||
- `src/handlers/accounts.ts`:注册、profile、密码变更、TOTP、API key 等账户接口。
|
|
||||||
|
|
||||||
目前公开路由没有:
|
|
||||||
|
|
||||||
- `GET /identity/accounts/webauthn/assertion-options`
|
|
||||||
- `POST /identity/connect/token` 的 `grant_type=webauthn`
|
|
||||||
- `POST /api/webauthn/attestation-options`
|
|
||||||
- `POST /api/webauthn/assertion-options`
|
|
||||||
- `GET/POST/PUT /api/webauthn`
|
|
||||||
|
|
||||||
### 注册链路
|
|
||||||
|
|
||||||
NodeWarden 自己 web 的注册入口在 `webapp/src/lib/api/auth.ts` 的 `registerAccount()`:
|
|
||||||
|
|
||||||
- 使用邮箱作为 salt,用 PBKDF2 派生 master key。
|
|
||||||
- 再用 PBKDF2(masterKey, password, 1) 得到 client master password hash。
|
|
||||||
- 随机生成 64 字节 vault symmetric key。
|
|
||||||
- 用 masterKey 经 HKDF 拆成 enc/mac,把 vault key 加密成 Bitwarden `Key`。
|
|
||||||
- 生成 RSA-OAEP key pair,把 private key 用 vault symmetric key 加密。
|
|
||||||
- POST `/api/accounts/register`,提交 `email`、`name`、`masterPasswordHash`、`key`、KDF 参数、invite code、`keys.publicKey`、`keys.encryptedPrivateKey`。
|
|
||||||
|
|
||||||
后端 `src/handlers/accounts.ts` 的 `handleRegister()`:
|
|
||||||
|
|
||||||
- 第一个用户自动成为 admin,后续用户需要 invite。
|
|
||||||
- 校验 `JWT_SECRET`、邮箱、KDF 下限、加密字符串形状、公钥/私钥。
|
|
||||||
- 不直接保存 client hash,而是 `AuthService.hashPasswordServer(masterPasswordHash, email)` 后保存到 `users.master_password_hash`。
|
|
||||||
- 保存 `users.key`、`users.private_key`、`users.public_key`、KDF 参数、`security_stamp`。
|
|
||||||
|
|
||||||
结论:账户 passkey 注册不是替代账号注册,而是“用户已登录后在安全设置里新增一个可登录 credential”。仍然需要已有 vault user key 来生成 PRF keyset。
|
|
||||||
|
|
||||||
### 主密码登录链路
|
|
||||||
|
|
||||||
NodeWarden 自己 web 的登录入口是 `webapp/src/lib/app-auth.ts` 的 `performPasswordLogin()`:
|
|
||||||
|
|
||||||
- 先 `deriveLoginHashLocally()` 得到 masterKey 和 client hash。
|
|
||||||
- 调 `loginWithPassword()` POST `/identity/connect/token`。
|
|
||||||
- token 成功后 `completeLogin()` 用 `token.Key` 和本地 masterKey 解开 vault key。
|
|
||||||
- 保存离线解锁记录。
|
|
||||||
|
|
||||||
`webapp/src/lib/api/auth.ts` 也有 `deriveLoginHash()` 和 `getPreloginKdfConfig()` 会调用 `/identity/accounts/prelogin`,但当前 `performPasswordLogin()` 走的是本地 fallback iterations。passkey 登录不应复用这条 masterKey 路径,因为 passkey 登录没有主密码,拿不到 password-derived masterKey。
|
|
||||||
|
|
||||||
后端 `src/handlers/identity.ts` 的 `handleToken()` 当前支持:
|
|
||||||
|
|
||||||
- `grant_type=password`
|
|
||||||
- `grant_type=client_credentials`
|
|
||||||
- `grant_type=refresh_token`
|
|
||||||
|
|
||||||
密码登录成功后会:
|
|
||||||
|
|
||||||
- 验证 IP 登录频率和用户状态。
|
|
||||||
- `AuthService.verifyPassword()` 验证 client hash。
|
|
||||||
- 处理 TOTP 或 remember 2FA token。
|
|
||||||
- 记录/更新 device。
|
|
||||||
- 生成 access token 和 refresh token。
|
|
||||||
- 返回 `Key`、`PrivateKey`、`AccountKeys`、KDF 参数、`UserDecryptionOptions`。
|
|
||||||
|
|
||||||
### UserDecryptionOptions 和 sync
|
|
||||||
|
|
||||||
NodeWarden 的 `src/utils/user-decryption.ts` 当前只构造主密码解锁:
|
|
||||||
|
|
||||||
- `HasMasterPassword: true`
|
|
||||||
- `MasterPasswordUnlock`
|
|
||||||
- `TrustedDeviceOption: null`
|
|
||||||
- `KeyConnectorOption: null`
|
|
||||||
|
|
||||||
`src/types/index.ts` 的 sync 类型里预留了 `UserDecryption.WebAuthnPrfOption?: null`,但当前 `src/handlers/sync.ts` 实际只返回 `MasterPasswordUnlock`,没有账户 passkey PRF 解密选项。
|
|
||||||
|
|
||||||
passkey 登录必须新增两类 shape:
|
|
||||||
|
|
||||||
- 登录 token 响应:`UserDecryptionOptions.WebAuthnPrfOption`,只返回本次认证所用 credential 的 PRF 解密材料。
|
|
||||||
- sync 响应:`UserDecryption.WebAuthnPrfOptions`,返回该用户所有已启用 PRF keyset 的 passkey 解密材料,供官方客户端锁定/解锁和 key rotation 使用。
|
|
||||||
|
|
||||||
### 现有 passkey 相关代码
|
|
||||||
|
|
||||||
NodeWarden 已支持 vault item 里的 FIDO2/passkey 字段:
|
|
||||||
|
|
||||||
- `src/types/index.ts`:`CipherLogin.fido2Credentials`
|
|
||||||
- `src/handlers/ciphers.ts`:读写 cipher 时保留/规范化 `fido2Credentials`
|
|
||||||
- `webapp/src/lib/api/vault.ts`:加密/解密 vault item 内的 `fido2Credentials`
|
|
||||||
- `webapp/src/lib/types.ts`:`CipherLoginPasskey`
|
|
||||||
|
|
||||||
这部分是“保存网站 passkey”,不是账户登录。
|
|
||||||
|
|
||||||
`src/utils/passkey.ts` 只有:
|
|
||||||
|
|
||||||
- `bytesToBase64Url()`
|
|
||||||
- `base64UrlToBytes()`
|
|
||||||
- `randomChallenge()`
|
|
||||||
- `parseClientDataJSON()`
|
|
||||||
|
|
||||||
缺少的核心能力:
|
|
||||||
|
|
||||||
- attestation verification
|
|
||||||
- assertion verification
|
|
||||||
- authenticator public key 格式处理
|
|
||||||
- signature verification
|
|
||||||
- sign counter 更新
|
|
||||||
- userHandle 与 user id 绑定验证
|
|
||||||
- origin/RP ID 验证
|
|
||||||
- challenge 过期和防重放
|
|
||||||
|
|
||||||
### 数据库和备份影响
|
|
||||||
|
|
||||||
NodeWarden schema 在这些地方需要同步:
|
|
||||||
|
|
||||||
- `migrations/0001_init.sql`
|
|
||||||
- `src/services/storage-schema.ts`
|
|
||||||
- `wrangler.toml` migrations
|
|
||||||
- `src/services/backup-archive.ts`
|
|
||||||
- `src/services/backup-import.ts`
|
|
||||||
- `shared/backup-schema` 相关类型
|
|
||||||
|
|
||||||
当前表里没有账户 passkey credential,也没有 WebAuthn challenge 表。`devices` 表保存设备 trust/key 信息,不适合混入 passkey credential,因为 WebAuthn credential 需要自己的 public key、credential id、counter、AAGUID、PRF keyset 等字段。
|
|
||||||
|
|
||||||
## 官方 Bitwarden server 参考
|
|
||||||
|
|
||||||
上游代码位置:
|
|
||||||
|
|
||||||
- `.codex-upstream/bitwarden-server`
|
|
||||||
- 研究时 HEAD:`574f3fd`
|
|
||||||
|
|
||||||
官方 server 里也有两个 WebAuthn 概念:
|
|
||||||
|
|
||||||
- 传统 WebAuthn 2FA:`TwoFactorController`、`WebAuthnTokenProvider`
|
|
||||||
- 账户 passkey 登录:`WebAuthnLogin`
|
|
||||||
|
|
||||||
本项目要参考的是后者。
|
|
||||||
|
|
||||||
### 公开 passkey 登录入口
|
|
||||||
|
|
||||||
`src/Identity/Controllers/AccountsController.cs`
|
|
||||||
|
|
||||||
- `GET /accounts/webauthn/assertion-options`
|
|
||||||
- 返回 `WebAuthnLoginAssertionOptionsResponseModel`
|
|
||||||
- response 包含:
|
|
||||||
- `options`
|
|
||||||
- `token`
|
|
||||||
- token 使用 `WebAuthnLoginAssertionOptionsTokenable`
|
|
||||||
- scope 为 `Authentication`
|
|
||||||
- token 生命周期约 17 分钟
|
|
||||||
|
|
||||||
`src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs`
|
|
||||||
|
|
||||||
- 新增 OAuth extension grant:`grant_type=webauthn`
|
|
||||||
- 从 form 读取:
|
|
||||||
- `token`
|
|
||||||
- `deviceResponse`
|
|
||||||
- 解开 token,校验 scope 必须是 `Authentication`
|
|
||||||
- 反序列化 `AuthenticatorAssertionRawResponse`
|
|
||||||
- 调用 `AssertWebAuthnLoginCredential`
|
|
||||||
- 把成功认证的 credential 传给 `UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential)`
|
|
||||||
- 之后走通用登录成功逻辑,返回 access/refresh token 和账号加密状态。
|
|
||||||
|
|
||||||
`src/Identity/IdentityServer/ApiClient.cs`
|
|
||||||
|
|
||||||
- official identity client 的 allowed grant types 包含 `WebAuthnGrantValidator.GrantType`。
|
|
||||||
|
|
||||||
`TwoFactorAuthenticationValidator` 里有一个重要行为:FIDO2 user verification 已经被视为第二因素,所以 passkey 登录成功后官方不会再要求额外 2FA。NodeWarden 之后需要明确策略:要兼容官方客户端,应把 passkey 登录视作已满足 2FA,否则官方 `LoginViaWebAuthnComponent` 会显示“不支持 passkey 2FA”的错误。
|
|
||||||
|
|
||||||
### 账户 passkey 管理接口
|
|
||||||
|
|
||||||
`src/Api/Auth/Controllers/WebAuthnController.cs`
|
|
||||||
|
|
||||||
官方 authenticated API:
|
|
||||||
|
|
||||||
- `GET /webauthn`:列出账户 passkey credentials。
|
|
||||||
- `POST /webauthn/attestation-options`:主密码/secret verification 后生成 credential create options 和 token。
|
|
||||||
- `POST /webauthn/assertion-options`:主密码/secret verification 后生成 assertion options 和 token,用于给已有 credential 启用/更新 PRF keyset。
|
|
||||||
- `POST /webauthn`:保存新 credential。
|
|
||||||
- `PUT /webauthn`:更新 credential 的 PRF encryption keyset。
|
|
||||||
- `POST /webauthn/{id}/delete`:删除 credential。
|
|
||||||
|
|
||||||
官方创建 credential 时保存:
|
|
||||||
|
|
||||||
- `name`
|
|
||||||
- `token`
|
|
||||||
- `deviceResponse`
|
|
||||||
- `supportsPrf`
|
|
||||||
- 可选 `encryptedUserKey`
|
|
||||||
- 可选 `encryptedPublicKey`
|
|
||||||
- 可选 `encryptedPrivateKey`
|
|
||||||
|
|
||||||
官方最多允许 5 个账户 passkey credentials。
|
|
||||||
|
|
||||||
### 官方 WebAuthnCredential 表
|
|
||||||
|
|
||||||
`src/Core/Auth/Entities/WebAuthnCredential.cs`
|
|
||||||
|
|
||||||
字段:
|
|
||||||
|
|
||||||
- `Id`
|
|
||||||
- `UserId`
|
|
||||||
- `Name`
|
|
||||||
- `PublicKey`
|
|
||||||
- `CredentialId`
|
|
||||||
- `Counter`
|
|
||||||
- `Type`
|
|
||||||
- `AaGuid`
|
|
||||||
- `EncryptedUserKey`
|
|
||||||
- `EncryptedPrivateKey`
|
|
||||||
- `EncryptedPublicKey`
|
|
||||||
- `SupportsPrf`
|
|
||||||
- `CreationDate`
|
|
||||||
- `RevisionDate`
|
|
||||||
|
|
||||||
SQLite migration:`util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs`
|
|
||||||
|
|
||||||
表名是 `WebAuthnCredential`,对 `User` 做 cascade delete,并按 `UserId` 建索引。
|
|
||||||
|
|
||||||
`GetPrfStatus()`:
|
|
||||||
|
|
||||||
- `Unsupported`:`SupportsPrf` 为 false。
|
|
||||||
- `Supported`:credential 支持 PRF,但还没有完整 encrypted keyset。
|
|
||||||
- `Enabled`:`EncryptedUserKey`、`EncryptedPrivateKey`、`EncryptedPublicKey` 都存在。
|
|
||||||
|
|
||||||
### 官方创建和认证策略
|
|
||||||
|
|
||||||
`GetWebAuthnLoginCredentialCreateOptionsCommand.cs`
|
|
||||||
|
|
||||||
- 使用 Fido2NetLib。
|
|
||||||
- `user.id` 是用户 id bytes。
|
|
||||||
- `user.name/displayName` 使用用户邮箱。
|
|
||||||
- 排除当前用户已有 credential ids。
|
|
||||||
- `residentKey: required`
|
|
||||||
- `userVerification: required`
|
|
||||||
- `attestation: none`
|
|
||||||
|
|
||||||
`GetWebAuthnLoginCredentialAssertionOptionsCommand.cs`
|
|
||||||
|
|
||||||
- `allowCredentials` 传空数组。
|
|
||||||
- `userVerification: required`
|
|
||||||
- 空 allow list 代表使用 discoverable credentials,也就是 passkey 登录页可以不先输入邮箱。
|
|
||||||
|
|
||||||
`CreateWebAuthnLoginCredentialCommand.cs`
|
|
||||||
|
|
||||||
- 限制每用户最多 5 个。
|
|
||||||
- 检查 credential id 在该用户下不能重复。
|
|
||||||
- FIDO `MakeNewCredentialAsync` 验证 attestation。
|
|
||||||
- 保存 credential id/public key/counter/type/AAGUID/PRF keyset。
|
|
||||||
|
|
||||||
`AssertWebAuthnLoginCredentialCommand.cs`
|
|
||||||
|
|
||||||
- 先用 challenge cache 防重放。
|
|
||||||
- 从 assertion response 的 `userHandle` 解析出 user id。
|
|
||||||
- 加载该用户所有 WebAuthn credentials。
|
|
||||||
- 用 credential id 找到记录。
|
|
||||||
- FIDO `MakeAssertionAsync` 验证签名、challenge、origin、RP ID、user verification。
|
|
||||||
- 成功后更新 counter。
|
|
||||||
|
|
||||||
### 官方 PRF 解密协议
|
|
||||||
|
|
||||||
`src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs`
|
|
||||||
|
|
||||||
`WebAuthnPrfDecryptionOption` 字段:
|
|
||||||
|
|
||||||
- `EncryptedPrivateKey`
|
|
||||||
- `EncryptedUserKey`
|
|
||||||
- `CredentialId`
|
|
||||||
- `Transports`
|
|
||||||
|
|
||||||
`src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs`
|
|
||||||
|
|
||||||
- `WithWebAuthnLoginCredential()` 只在 credential 的 PRF status 是 `Enabled` 时加入 `WebAuthnPrfOption`。
|
|
||||||
- 如果 credential 没有 PRF keyset,passkey 只能认证账号,不能解开 vault。
|
|
||||||
|
|
||||||
`src/Api/Vault/Models/Response/SyncResponseModel.cs`
|
|
||||||
|
|
||||||
- sync response 会把所有 enabled PRF credentials 放进 `UserDecryption.WebAuthnPrfOptions`。
|
|
||||||
|
|
||||||
## 官方 Bitwarden web/browser client 参考
|
|
||||||
|
|
||||||
上游代码位置:
|
|
||||||
|
|
||||||
- `.codex-upstream/bitwarden-clients`
|
|
||||||
- `.codex-upstream/bitwarden-browser`
|
|
||||||
- 两者研究时 HEAD 都是 `825f9be`,browser repo 内容和 clients monorepo 对应。
|
|
||||||
|
|
||||||
旧的 `.codex-upstream/bitwarden-web` 主要有 WebAuthn connector 和 2FA 设置页,没有现代账户 passkey 登录主流程。账户 passkey 登录应以 `bitwarden-clients` 为准。
|
|
||||||
|
|
||||||
### 登录按钮可见性
|
|
||||||
|
|
||||||
`libs/auth/src/angular/login/default-login-component.service.ts`
|
|
||||||
|
|
||||||
- 默认只对 `ClientType.Web` 开启 passkey 登录。
|
|
||||||
|
|
||||||
`apps/browser/src/auth/popup/login/extension-login-component.service.ts`
|
|
||||||
|
|
||||||
- browser extension 覆盖逻辑:只对 Chromium 开启。
|
|
||||||
- 注释说明 Firefox 和 Safari 不能在扩展里覆盖 relying party ID。
|
|
||||||
- 官方代码引用了 W3C webextensions issue 238、Mozilla bug 1956484、Apple forum thread 774351。
|
|
||||||
|
|
||||||
结论:NodeWarden 后端即使完全兼容官方 passkey API,官方扩展也只有 Chromium 系会显示 passkey 登录入口。
|
|
||||||
|
|
||||||
### Passkey 登录页
|
|
||||||
|
|
||||||
`libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts`
|
|
||||||
|
|
||||||
流程:
|
|
||||||
|
|
||||||
1. 进入 `/login-with-passkey` 后自动开始认证。
|
|
||||||
2. 调 `webAuthnLoginService.getCredentialAssertionOptions()`。
|
|
||||||
3. 调 `webAuthnLoginService.assertCredential(options)` 触发 `navigator.credentials.get()`。
|
|
||||||
4. 调 `webAuthnLoginService.logIn(assertion)` 走 identity token grant。
|
|
||||||
5. 如果 `authResult.requiresTwoFactor` 为 true,显示“客户端不支持 passkey 2FA”错误。
|
|
||||||
6. 只有本地 `keyService.userKey$(authResult.userId)` 已经拿到 user key,才运行 login success handler。
|
|
||||||
7. 成功路由:
|
|
||||||
- Web:`/vault`
|
|
||||||
- Browser:`/tabs/vault`
|
|
||||||
- Desktop:`/vault`
|
|
||||||
|
|
||||||
Browser popout 下还会在成功后重新打开普通 popup 并关闭 popout。
|
|
||||||
|
|
||||||
### 客户端 passkey 登录请求
|
|
||||||
|
|
||||||
`libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts`
|
|
||||||
|
|
||||||
- GET `${identityUrl}/accounts/webauthn/assertion-options`
|
|
||||||
- 如果 NodeWarden 的 identityUrl 是站点 origin + `/identity`,实际路径就是 `/identity/accounts/webauthn/assertion-options`。
|
|
||||||
|
|
||||||
`libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts`
|
|
||||||
|
|
||||||
- `navigator.credentials.get({ publicKey: options })`
|
|
||||||
- 会主动加 PRF extension:
|
|
||||||
- salt 是 `SHA-256("passwordless-login")`
|
|
||||||
- extension shape 是 `extensions.prf.eval.first`
|
|
||||||
- 从 `credential.getClientExtensionResults().prf.results.first` 取 PRF 输出。
|
|
||||||
- 用 `WebAuthnLoginPrfKeyService.createSymmetricKeyFromPrf()` 转成 PRF key。
|
|
||||||
- 构造 `WebAuthnLoginAssertionResponseRequest`。
|
|
||||||
- 明确检查 `deviceResponse.extensions` 里不能含 `prf`,避免把 PRF 输出泄漏给服务端。
|
|
||||||
|
|
||||||
`libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts`
|
|
||||||
|
|
||||||
- salt 常量:`passwordless-login`
|
|
||||||
- 先 SHA-256。
|
|
||||||
- 再用 HKDF expand 拆成 64 字节:
|
|
||||||
- `"enc"` 32 bytes
|
|
||||||
- `"mac"` 32 bytes
|
|
||||||
|
|
||||||
`libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts`
|
|
||||||
|
|
||||||
form encoded token 请求字段:
|
|
||||||
|
|
||||||
- `grant_type=webauthn`
|
|
||||||
- `token=<server assertion options token>`
|
|
||||||
- `deviceResponse=<JSON string>`
|
|
||||||
- 还会带 common device request 字段。
|
|
||||||
|
|
||||||
`libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts`
|
|
||||||
|
|
||||||
`deviceResponse` shape:
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `rawId`
|
|
||||||
- `type`
|
|
||||||
- `extensions: {}`
|
|
||||||
- `response.authenticatorData`
|
|
||||||
- `response.signature`
|
|
||||||
- `response.clientDataJSON`
|
|
||||||
- `response.userHandle`
|
|
||||||
|
|
||||||
全部二进制字段使用 base64url。
|
|
||||||
|
|
||||||
### 客户端如何用 PRF 解 vault key
|
|
||||||
|
|
||||||
`libs/auth/src/common/login-strategies/webauthn-login.strategy.ts`
|
|
||||||
|
|
||||||
- `setMasterKey()` 是空实现,因为 passkey 登录没有主密码 masterKey。
|
|
||||||
- `setUserKey()`:
|
|
||||||
- 如果 token response 有 `key`,保存为 master-key-encrypted user key,兼容主密码解锁。
|
|
||||||
- 如果 `userDecryptionOptions.webAuthnPrfOption` 存在,且本地 assertion 得到了 `prfKey`:
|
|
||||||
1. 用 PRF key unwrap `encryptedPrivateKey`。
|
|
||||||
2. 用 private key decapsulate `encryptedUserKey`。
|
|
||||||
3. 得到 user key,写入 `keyService`。
|
|
||||||
|
|
||||||
核心约束:服务端永远看不到 PRF 输出。服务端只保存和返回被 PRF 相关密钥加密后的 keyset。
|
|
||||||
|
|
||||||
### 官方 web 设置页注册 passkey
|
|
||||||
|
|
||||||
`apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts`
|
|
||||||
|
|
||||||
调用的 API:
|
|
||||||
|
|
||||||
- `POST /webauthn/attestation-options`
|
|
||||||
- `POST /webauthn/assertion-options`
|
|
||||||
- `POST /webauthn`
|
|
||||||
- `GET /webauthn`
|
|
||||||
- `POST /webauthn/{id}/delete`
|
|
||||||
- `PUT /webauthn`
|
|
||||||
|
|
||||||
`apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts`
|
|
||||||
|
|
||||||
创建流程:
|
|
||||||
|
|
||||||
1. 用户做 secret verification。
|
|
||||||
2. 请求 attestation options。
|
|
||||||
3. `navigator.credentials.create({ publicKey: options })`,并带 `extensions.prf = {}`。
|
|
||||||
4. 从 client extension results 判断 `supportsPrf`。
|
|
||||||
5. 如果要用于 vault encryption,再立即做一次 `navigator.credentials.get()`:
|
|
||||||
- `allowCredentials` 锁定刚创建的 credential。
|
|
||||||
- 使用同一个 challenge、rpId、timeout、userVerification。
|
|
||||||
- 带 PRF eval salt。
|
|
||||||
6. 用 PRF key 和当前 user key 创建 rotateable keyset。
|
|
||||||
7. 保存 credential,带上 `encryptedUserKey`、`encryptedPublicKey`、`encryptedPrivateKey`。
|
|
||||||
|
|
||||||
删除流程需要 secret verification。启用 encryption 的流程是对已有 credential 做 assertion,再创建并 PUT keyset。
|
|
||||||
|
|
||||||
`apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts`
|
|
||||||
|
|
||||||
- `Enabled = 0`
|
|
||||||
- `Supported = 1`
|
|
||||||
- `Unsupported = 2`
|
|
||||||
|
|
||||||
## NodeWarden 应实现的协议形状
|
|
||||||
|
|
||||||
### 公开登录流程
|
|
||||||
|
|
||||||
目标兼容官方客户端和 NodeWarden 自己 web:
|
|
||||||
|
|
||||||
1. `GET /identity/accounts/webauthn/assertion-options`
|
|
||||||
- 生成 discoverable credential assertion options。
|
|
||||||
- `allowCredentials: []`
|
|
||||||
- `userVerification: "required"`
|
|
||||||
- 返回 `{ options, token }`。
|
|
||||||
- token 绑定 challenge、scope=`Authentication`、RP ID、origin/audience、过期时间。
|
|
||||||
|
|
||||||
2. Browser/web 调 `navigator.credentials.get()`。
|
|
||||||
- NodeWarden 自己 web 也要使用 PRF extension。
|
|
||||||
- PRF salt 必须和官方一致:`SHA-256("passwordless-login")`。
|
|
||||||
|
|
||||||
3. `POST /identity/connect/token`
|
|
||||||
- 支持 `grant_type=webauthn`。
|
|
||||||
- 接收 `token`、`deviceResponse`、device fields。
|
|
||||||
- 解 token,校验 challenge/scope/过期。
|
|
||||||
- 验证 assertion。
|
|
||||||
- 从 `userHandle` 找到 user id。
|
|
||||||
- 从 credential id 找到 passkey record。
|
|
||||||
- 更新 counter。
|
|
||||||
- 记录/更新 device。
|
|
||||||
- 返回 access/refresh token、`AccountKeys`、`UserDecryptionOptions.WebAuthnPrfOption`。
|
|
||||||
|
|
||||||
如果用户启用了 TOTP,建议为了官方兼容先遵循 Bitwarden:passkey 的 user verification 视作已满足第二因素。否则官方 passkey 登录页会进入 unsupported 2FA 错误状态。
|
|
||||||
|
|
||||||
### 账户 passkey 管理流程
|
|
||||||
|
|
||||||
建议对齐官方 API,同时在 NodeWarden 内部可挂到 `/api/webauthn`:
|
|
||||||
|
|
||||||
- `GET /api/webauthn`
|
|
||||||
- `POST /api/webauthn/attestation-options`
|
|
||||||
- `POST /api/webauthn/assertion-options`
|
|
||||||
- `POST /api/webauthn`
|
|
||||||
- `PUT /api/webauthn`
|
|
||||||
- `POST /api/webauthn/:id/delete`
|
|
||||||
|
|
||||||
为了官方客户端兼容,可能还需要接受无 `/api` 前缀的 aliases:
|
|
||||||
|
|
||||||
- `/webauthn`
|
|
||||||
- `/webauthn/attestation-options`
|
|
||||||
- `/webauthn/assertion-options`
|
|
||||||
- `/webauthn/:id/delete`
|
|
||||||
|
|
||||||
NodeWarden 自己 web 可以直接用 `/api/webauthn`,官方 web/browser 客户端会按它自己的 API base 组装 `/webauthn`。
|
|
||||||
|
|
||||||
### 建议新增表
|
|
||||||
|
|
||||||
按 NodeWarden 命名风格,建议用小写 snake_case:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
public_key TEXT NOT NULL,
|
|
||||||
credential_id TEXT NOT NULL,
|
|
||||||
counter INTEGER NOT NULL DEFAULT 0,
|
|
||||||
type TEXT,
|
|
||||||
aa_guid TEXT,
|
|
||||||
transports TEXT,
|
|
||||||
encrypted_user_key TEXT,
|
|
||||||
encrypted_public_key TEXT,
|
|
||||||
encrypted_private_key TEXT,
|
|
||||||
supports_prf INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_user_credential
|
|
||||||
ON webauthn_credentials(user_id, credential_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
|
|
||||||
ON webauthn_credentials(user_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
如果要更严格防止同一个 credential id 被跨用户重复注册,也可以加全局 unique index `credential_id`。官方代码至少检查同用户唯一;实际安全上更建议全局唯一,因为 credential id 本身应该唯一标识 authenticator credential。
|
|
||||||
|
|
||||||
PRF status 不必落库为枚举,可以由字段计算:
|
|
||||||
|
|
||||||
- `supports_prf = 0` => `Unsupported`
|
|
||||||
- `supports_prf = 1` 且三段 encrypted key 不全 => `Supported`
|
|
||||||
- `supports_prf = 1` 且三段 encrypted key 全存在 => `Enabled`
|
|
||||||
|
|
||||||
### Challenge/token 存储
|
|
||||||
|
|
||||||
官方 server 用 protected token 携带 options,再用 challenge cache 防重放。NodeWarden 在 Workers/D1 里建议组合:
|
|
||||||
|
|
||||||
- token:HMAC/JWT 样式,绑定 `scope`、`challenge`、`userId?`、`rpId`、`createdAt`、`expiresAt`。
|
|
||||||
- D1 表或 KV:记录 challenge 是否使用过,至少字段 `challenge_hash`、`scope`、`user_id`、`expires_at`、`used_at`。
|
|
||||||
- 登录 assertion options 是公开接口,不绑定 user id;create/update/delete 管理流程应绑定 user id。
|
|
||||||
- 验证成功后立即 mark used。
|
|
||||||
|
|
||||||
建议 scopes:
|
|
||||||
|
|
||||||
- `Authentication`
|
|
||||||
- `CreateCredential`
|
|
||||||
- `UpdateKeySet`
|
|
||||||
|
|
||||||
官方还有 `PrfRegistration` 语义,NodeWarden 可以用 `CreateCredential` 覆盖,只要 token 逻辑严谨即可。
|
|
||||||
|
|
||||||
### 服务端 WebAuthn 验证库
|
|
||||||
|
|
||||||
NodeWarden 当前没有 FIDO2/WebAuthn 服务端验证依赖。不要手写签名和 attestation 解析。
|
|
||||||
|
|
||||||
候选:`@simplewebauthn/server`。官方文档当前说明它提供 `generateRegistrationOptions`、`verifyRegistrationResponse`、`generateAuthenticationOptions`、`verifyAuthenticationResponse`,并记录了 RP ID、origin、credential public key、counter、transports 等数据结构。文档地址:https://simplewebauthn.dev/docs/packages/server
|
|
||||||
|
|
||||||
注意:NodeWarden 跑在 Cloudflare Workers,不是普通 Node server。正式选库前需要做一次构建/runtime 验证,确认包不会依赖 Workers 不支持的 Node API。这个验证属于实现阶段,不在本研究文档里写测试程序。
|
|
||||||
|
|
||||||
## NodeWarden web 需要改的地方
|
|
||||||
|
|
||||||
### 登录页
|
|
||||||
|
|
||||||
当前登录 UI 在 `webapp/src/components/AuthViews.tsx`,状态和行为主要由 `webapp/src/App.tsx`、`webapp/src/lib/app-auth.ts` 管。
|
|
||||||
|
|
||||||
新增:
|
|
||||||
|
|
||||||
- 登录页增加“使用 passkey 登录”按钮。
|
|
||||||
- 新增 `performPasskeyLogin()`:
|
|
||||||
1. GET `/identity/accounts/webauthn/assertion-options`
|
|
||||||
2. 转换 server options 里的 base64url challenge/user id/credential id 为 ArrayBuffer。
|
|
||||||
3. `navigator.credentials.get()`,带 PRF salt。
|
|
||||||
4. POST `/identity/connect/token`,`grant_type=webauthn`。
|
|
||||||
5. 从 response 的 `UserDecryptionOptions.WebAuthnPrfOption` 取 encrypted keyset。
|
|
||||||
6. 用本地 PRF key 解出 user key。
|
|
||||||
7. 构造 `SessionState` 并进入 app。
|
|
||||||
|
|
||||||
不能复用 `completeLogin(token, email, masterKey, fallbackKdfIterations)`,因为它要求 masterKey。应新增 passkey 专用 complete 函数。
|
|
||||||
|
|
||||||
### 设置页
|
|
||||||
|
|
||||||
当前账户/安全相关 UI 在 `webapp/src/components/SettingsPage.tsx` 一带。
|
|
||||||
|
|
||||||
新增:
|
|
||||||
|
|
||||||
- Passkey 列表。
|
|
||||||
- 新建 passkey dialog。
|
|
||||||
- 删除 passkey。
|
|
||||||
- 对支持 PRF 但未启用 encryption 的 passkey,提供“启用用于登录解锁”的操作。
|
|
||||||
|
|
||||||
自己 web 的新建流程要和官方一致:
|
|
||||||
|
|
||||||
1. 已登录状态下先验证主密码或现有 session secret。
|
|
||||||
2. 请求 attestation options。
|
|
||||||
3. `navigator.credentials.create()` 带 `extensions.prf = {}`。
|
|
||||||
4. 如果用户希望这个 passkey 可直接解锁 vault,再对刚创建 credential 做一次 `navigator.credentials.get()` 获取 PRF 输出。
|
|
||||||
5. 用 PRF key 加密/封装当前 user key,发送到 server 保存。
|
|
||||||
|
|
||||||
### 客户端加密能力
|
|
||||||
|
|
||||||
NodeWarden web 当前已经有:
|
|
||||||
|
|
||||||
- PBKDF2
|
|
||||||
- HKDF expand
|
|
||||||
- Bitwarden EncString 加解密
|
|
||||||
- RSA-OAEP private key 加密
|
|
||||||
|
|
||||||
但 passkey PRF keyset 需要和官方策略对齐:
|
|
||||||
|
|
||||||
- PRF key 是 64 字节 symmetric key,前 32 enc、后 32 mac。
|
|
||||||
- `encryptedPrivateKey` 用 PRF key wrap 一个 decapsulation private key。
|
|
||||||
- `encryptedUserKey` 用对应 public key encapsulate user key。
|
|
||||||
- `encryptedPublicKey` 用于 key rotation。
|
|
||||||
|
|
||||||
这里需要认真复用或补齐 NodeWarden 现有 crypto helper,避免做出和官方客户端无法互解的 keyset。
|
|
||||||
|
|
||||||
## 扩展兼容要求
|
|
||||||
|
|
||||||
### 官方 browser extension
|
|
||||||
|
|
||||||
官方 extension passkey 登录入口在:
|
|
||||||
|
|
||||||
- `apps/browser/src/auth/popup/login/extension-login-component.service.ts`
|
|
||||||
- 只在 Chromium 开启。
|
|
||||||
|
|
||||||
如果要官方/派生扩展能对 NodeWarden passkey 登录:
|
|
||||||
|
|
||||||
- identity URL 必须能访问 `/accounts/webauthn/assertion-options`。
|
|
||||||
- token URL 必须支持 `grant_type=webauthn`。
|
|
||||||
- API URL 必须能访问 `/webauthn` 管理接口。
|
|
||||||
- response 大小写和字段名要同时照顾 PascalCase/camelCase,NodeWarden 当前 token response 已经在一些字段上双写,这个风格应继续沿用。
|
|
||||||
- passkey 登录成功时必须返回可解开 vault 的 `webAuthnPrfOption`,否则官方组件虽然认证成功,也不会进入可用 vault。
|
|
||||||
|
|
||||||
### RP ID 和 origin
|
|
||||||
|
|
||||||
自己的 web:
|
|
||||||
|
|
||||||
- RP ID 通常是站点 host,例如 `vault.example.com`。
|
|
||||||
- origin 是 `https://vault.example.com`。
|
|
||||||
|
|
||||||
官方 browser extension:
|
|
||||||
|
|
||||||
- 扩展页面 origin 是 `chrome-extension://...`。
|
|
||||||
- 官方之所以只开 Chromium,是因为 Chromium extension 具备它需要的 RP ID 覆盖能力。
|
|
||||||
- NodeWarden server 验证 assertion 时必须允许正确的 origin/RP ID 组合。这里不能简单只接受当前 request origin,否则扩展登录会失败。
|
|
||||||
|
|
||||||
建议配置化:
|
|
||||||
|
|
||||||
- `WEBAUTHN_RP_ID`
|
|
||||||
- `WEBAUTHN_RP_NAME`
|
|
||||||
- `WEBAUTHN_ALLOWED_ORIGINS`
|
|
||||||
|
|
||||||
默认可以从 request URL 推导 web origin,但生产建议显式配置。
|
|
||||||
|
|
||||||
## 安全约束
|
|
||||||
|
|
||||||
- 所有账户 passkey 必须 `userVerification: required`。
|
|
||||||
- 登录 assertion 使用 discoverable credential,`userHandle` 必须能解析成 user id 并和 credential 记录一致。
|
|
||||||
- challenge 必须有过期时间和一次性使用标记。
|
|
||||||
- PRF 输出绝不能传给 server,也不能写入日志。
|
|
||||||
- token 里要绑定 scope,防止 attestation token 被拿去 authentication 用。
|
|
||||||
- counter 要更新。遇到 counter 异常时至少记录 audit event,是否阻断要结合 multi-device passkey 现实处理。
|
|
||||||
- 每用户 credential 数量限制建议沿用官方 5 个。
|
|
||||||
- 删除/新增/启用 encryption 必须要求已登录用户二次验证。
|
|
||||||
- 密码变更、user key rotation 后,所有 enabled PRF credentials 的 keyset 也要 rotation,否则 passkey 登录会解不开新 vault key。
|
|
||||||
- 备份导出/导入必须包含账户 passkey 表,否则恢复后 passkey 登录会全部失效。
|
|
||||||
- 审计日志建议新增:
|
|
||||||
- `auth.passkey.login.success`
|
|
||||||
- `auth.passkey.login.failed`
|
|
||||||
- `account.passkey.create`
|
|
||||||
- `account.passkey.delete`
|
|
||||||
- `account.passkey.encryption.enable`
|
|
||||||
- `account.passkey.rotate`
|
|
||||||
|
|
||||||
## 建议实施顺序
|
|
||||||
|
|
||||||
### 第一阶段:后端基础
|
|
||||||
|
|
||||||
1. 新增 `webauthn_credentials` 和 challenge 表。
|
|
||||||
2. 新增 storage repo。
|
|
||||||
3. 接入 WebAuthn 服务端验证库。
|
|
||||||
4. 实现 assertion options 和 `grant_type=webauthn`。
|
|
||||||
5. token response 加 `WebAuthnPrfOption` shape。
|
|
||||||
|
|
||||||
这阶段先能让“已有手工塞入的 enabled credential”完成登录验证,但还不做 UI。
|
|
||||||
|
|
||||||
### 第二阶段:账户 passkey 管理 API
|
|
||||||
|
|
||||||
1. 实现 `/api/webauthn` 和 `/webauthn` aliases。
|
|
||||||
2. 实现 attestation options、save credential、list、delete、enable/update encryption。
|
|
||||||
3. 加 audit event。
|
|
||||||
4. 接入 backup export/import。
|
|
||||||
5. sync response 加 `WebAuthnPrfOptions`。
|
|
||||||
|
|
||||||
### 第三阶段:NodeWarden 自己 web
|
|
||||||
|
|
||||||
1. 登录页 passkey 按钮和 `performPasskeyLogin()`。
|
|
||||||
2. Passkey 设置页。
|
|
||||||
3. PRF keyset 创建、保存、删除、启用 encryption。
|
|
||||||
4. 浏览器能力判断和错误提示。
|
|
||||||
|
|
||||||
### 第四阶段:扩展兼容
|
|
||||||
|
|
||||||
1. 用官方 browser extension 的 Chromium passkey 登录流程校对 endpoint。
|
|
||||||
2. 校对 `/config` 里 identity/api/web vault URL。
|
|
||||||
3. 校对 RP ID、allowed origins。
|
|
||||||
4. 必要时加兼容字段或 alias route。
|
|
||||||
|
|
||||||
按用户要求,本阶段只需要代码跑通不报错;不在这里写可视化测试或测试程序。
|
|
||||||
|
|
||||||
## 待实现清单
|
|
||||||
|
|
||||||
- [ ] 设计并落库 `webauthn_credentials`。
|
|
||||||
- [ ] 设计并落库 WebAuthn challenge/replay cache。
|
|
||||||
- [ ] 选定并验证 Workers 可用的 WebAuthn server library。
|
|
||||||
- [ ] `GET /identity/accounts/webauthn/assertion-options`。
|
|
||||||
- [ ] `POST /identity/connect/token` 支持 `grant_type=webauthn`。
|
|
||||||
- [ ] `UserDecryptionOptions.WebAuthnPrfOption`。
|
|
||||||
- [ ] `UserDecryption.WebAuthnPrfOptions`。
|
|
||||||
- [ ] `/api/webauthn` 管理接口。
|
|
||||||
- [ ] `/webauthn` 官方客户端 alias。
|
|
||||||
- [ ] NodeWarden web passkey 登录入口。
|
|
||||||
- [ ] NodeWarden web passkey 管理页。
|
|
||||||
- [ ] key rotation 时同步 rotate PRF keysets。
|
|
||||||
- [ ] backup export/import 覆盖新表。
|
|
||||||
- [ ] audit logs 覆盖 passkey 管理和登录。
|
|
||||||
|
|
||||||
## 关键文件索引
|
|
||||||
|
|
||||||
NodeWarden:
|
|
||||||
|
|
||||||
- `src/router-public.ts`
|
|
||||||
- `src/router-authenticated.ts`
|
|
||||||
- `src/handlers/accounts.ts`
|
|
||||||
- `src/handlers/identity.ts`
|
|
||||||
- `src/handlers/sync.ts`
|
|
||||||
- `src/services/auth.ts`
|
|
||||||
- `src/services/storage-schema.ts`
|
|
||||||
- `src/services/storage-user-repo.ts`
|
|
||||||
- `src/services/storage-device-repo.ts`
|
|
||||||
- `src/utils/passkey.ts`
|
|
||||||
- `src/utils/user-decryption.ts`
|
|
||||||
- `src/types/index.ts`
|
|
||||||
- `webapp/src/lib/api/auth.ts`
|
|
||||||
- `webapp/src/lib/app-auth.ts`
|
|
||||||
- `webapp/src/components/AuthViews.tsx`
|
|
||||||
- `webapp/src/components/SettingsPage.tsx`
|
|
||||||
|
|
||||||
Bitwarden server:
|
|
||||||
|
|
||||||
- `.codex-upstream/bitwarden-server/src/Identity/Controllers/AccountsController.cs`
|
|
||||||
- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs`
|
|
||||||
- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/ApiClient.cs`
|
|
||||||
- `.codex-upstream/bitwarden-server/src/Api/Auth/Controllers/WebAuthnController.cs`
|
|
||||||
- `.codex-upstream/bitwarden-server/src/Core/Auth/Entities/WebAuthnCredential.cs`
|
|
||||||
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialCreateOptionsCommand.cs`
|
|
||||||
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialAssertionOptionsCommand.cs`
|
|
||||||
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs`
|
|
||||||
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/AssertWebAuthnLoginCredentialCommand.cs`
|
|
||||||
- `.codex-upstream/bitwarden-server/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs`
|
|
||||||
- `.codex-upstream/bitwarden-server/util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs`
|
|
||||||
|
|
||||||
Bitwarden clients/browser:
|
|
||||||
|
|
||||||
- `.codex-upstream/bitwarden-clients/libs/auth/src/angular/login/default-login-component.service.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/apps/browser/src/auth/popup/login/extension-login-component.service.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-response.request.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/save-credential.request.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/enable-credential-encryption.request.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts`
|
|
||||||
- `.codex-upstream/bitwarden-clients/libs/auth/src/common/models/domain/user-decryption-options.ts`
|
|
||||||
|
|
||||||
@@ -176,6 +176,8 @@ CREATE TABLE IF NOT EXISTS devices (
|
|||||||
encrypted_user_key TEXT,
|
encrypted_user_key TEXT,
|
||||||
encrypted_public_key TEXT,
|
encrypted_public_key TEXT,
|
||||||
encrypted_private_key TEXT,
|
encrypted_private_key TEXT,
|
||||||
|
push_uuid TEXT,
|
||||||
|
push_token TEXT,
|
||||||
banned INTEGER NOT NULL DEFAULT 0,
|
banned INTEGER NOT NULL DEFAULT 0,
|
||||||
banned_at TEXT,
|
banned_at TEXT,
|
||||||
device_note TEXT,
|
device_note TEXT,
|
||||||
@@ -187,6 +189,7 @@ CREATE TABLE IF NOT EXISTS devices (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
|
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_requests (
|
CREATE TABLE IF NOT EXISTS auth_requests (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.5.2",
|
"version": "1.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.5.2",
|
"version": "1.7.0",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.6.1",
|
"version": "1.7.0",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '1.6.1';
|
export const APP_VERSION = '1.7.0';
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import { DurableObject, waitUntil } from 'cloudflare:workers';
|
import { DurableObject, waitUntil } from 'cloudflare:workers';
|
||||||
import type { Env } from '../types';
|
import type { Env } from '../types';
|
||||||
|
import { notifyMobilePush } from '../services/push-relay';
|
||||||
|
|
||||||
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
||||||
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
|
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE = 0;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE = 1;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE = 3;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_CIPHERS = 4;
|
||||||
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE = 7;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE = 8;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE = 9;
|
||||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
const SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE = 12;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE = 13;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14;
|
||||||
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
|
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
|
||||||
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
|
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
|
||||||
|
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
|
||||||
|
|
||||||
type HubProtocol = 'json' | 'messagepack';
|
type HubProtocol = 'json' | 'messagepack';
|
||||||
type HubKind = 'user' | 'anonymous-auth-request';
|
type HubKind = 'user' | 'anonymous-auth-request';
|
||||||
@@ -164,7 +175,7 @@ function buildSignalRMessagePackInvocation(
|
|||||||
target: string = 'ReceiveMessage'
|
target: string = 'ReceiveMessage'
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
// SignalR MessagePack hub protocol uses an array-based invocation shape:
|
// SignalR MessagePack hub protocol uses an array-based invocation shape:
|
||||||
// [type, headers, invocationId, target, arguments]
|
// [type, headers, invocationId, target, arguments, streamIds]
|
||||||
const encodedPayload = encodeMsgPack([
|
const encodedPayload = encodeMsgPack([
|
||||||
1,
|
1,
|
||||||
{},
|
{},
|
||||||
@@ -177,6 +188,7 @@ function buildSignalRMessagePackInvocation(
|
|||||||
Payload: messagePayload,
|
Payload: messagePayload,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[],
|
||||||
]);
|
]);
|
||||||
return frameSignalRBinary(encodedPayload);
|
return frameSignalRBinary(encodedPayload);
|
||||||
}
|
}
|
||||||
@@ -207,7 +219,9 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
||||||
const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim();
|
const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim();
|
||||||
const contextId = String(body?.contextId || '').trim() || null;
|
const contextId = String(body?.contextId || '').trim() || null;
|
||||||
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
const rawUpdateType = body?.updateType;
|
||||||
|
const parsedUpdateType = typeof rawUpdateType === 'number' ? rawUpdateType : Number(rawUpdateType);
|
||||||
|
const updateType = Number.isFinite(parsedUpdateType) ? parsedUpdateType : SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
||||||
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
||||||
const payload = body?.payload && typeof body.payload === 'object'
|
const payload = body?.payload && typeof body.payload === 'object'
|
||||||
? body.payload
|
? body.payload
|
||||||
@@ -422,6 +436,243 @@ export function notifyUserVaultSync(
|
|||||||
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null));
|
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function notifyUserCiphersSync(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string,
|
||||||
|
contextId?: string | null
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_CIPHERS, revisionDate, contextId ?? null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserCipherCreate(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
cipherId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
organizationId?: string | null;
|
||||||
|
collectionIds?: string[] | null;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.cipherId,
|
||||||
|
OrganizationId: payload.organizationId ?? null,
|
||||||
|
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserCipherUpdate(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
cipherId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
organizationId?: string | null;
|
||||||
|
collectionIds?: string[] | null;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.cipherId,
|
||||||
|
OrganizationId: payload.organizationId ?? null,
|
||||||
|
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserCipherDelete(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
cipherId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
organizationId?: string | null;
|
||||||
|
collectionIds?: string[] | null;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.cipherId,
|
||||||
|
OrganizationId: payload.organizationId ?? null,
|
||||||
|
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserFolderCreate(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
folderId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.folderId,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserFolderUpdate(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
folderId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.folderId,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserFolderDelete(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
folderId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.folderId,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserSendCreate(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
sendId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.sendId,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserSendUpdate(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
sendId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.sendId,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserSendDelete(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
sendId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.sendId,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
export function notifyUserLogout(
|
export function notifyUserLogout(
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -517,6 +768,16 @@ async function notifyUserUpdate(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
await notifyMobilePush(env, {
|
||||||
|
userId,
|
||||||
|
updateType,
|
||||||
|
revisionDate,
|
||||||
|
contextId,
|
||||||
|
payload: payloadOverride || {
|
||||||
|
UserId: userId,
|
||||||
|
Date: revisionDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to broadcast realtime notification:', error);
|
console.error('Failed to broadcast realtime notification:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-42
@@ -1,4 +1,4 @@
|
|||||||
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
|
import { Env, User, DEFAULT_DEV_SECRET } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { AuthService } from '../services/auth';
|
import { AuthService } from '../services/auth';
|
||||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||||
@@ -9,6 +9,7 @@ import { LIMITS } from '../config/limits';
|
|||||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
import { buildAccountKeys } from '../utils/user-decryption';
|
import { buildAccountKeys } from '../utils/user-decryption';
|
||||||
|
import { buildProfileResponse } from '../utils/profile-response';
|
||||||
|
|
||||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
|
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
|
||||||
@@ -174,6 +175,24 @@ function readBodyString(body: Record<string, unknown>, names: string[]): string
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readNestedString(source: unknown, path: string[]): string {
|
||||||
|
let current = source;
|
||||||
|
for (const key of path) {
|
||||||
|
if (!current || typeof current !== 'object') return '';
|
||||||
|
current = (current as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return typeof current === 'string' ? current : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNestedNumber(source: unknown, path: string[]): number | undefined {
|
||||||
|
let current = source;
|
||||||
|
for (const key of path) {
|
||||||
|
if (!current || typeof current !== 'object') return undefined;
|
||||||
|
current = (current as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return typeof current === 'number' ? current : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
|
async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
|
||||||
const contentType = request.headers.get('content-type') || '';
|
const contentType = request.headers.get('content-type') || '';
|
||||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||||
@@ -183,34 +202,32 @@ async function readRequestBody(request: Request): Promise<Record<string, unknown
|
|||||||
return await request.json();
|
return await request.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toProfile(user: User, env: Env): ProfileResponse {
|
function masterPasswordPolicyResponse(): Record<string, unknown> {
|
||||||
void env;
|
return {
|
||||||
|
minComplexity: 0,
|
||||||
|
minLength: 0,
|
||||||
|
requireUpper: false,
|
||||||
|
requireLower: false,
|
||||||
|
requireNumbers: false,
|
||||||
|
requireSpecial: false,
|
||||||
|
enforceOnLogin: false,
|
||||||
|
object: 'masterPasswordPolicy',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function keysResponse(user: User): Record<string, unknown> {
|
||||||
const accountKeys = buildAccountKeys(user);
|
const accountKeys = buildAccountKeys(user);
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
Key: user.key,
|
||||||
name: user.name,
|
PublicKey: user.publicKey ?? '',
|
||||||
email: user.email,
|
PrivateKey: user.privateKey ?? '',
|
||||||
emailVerified: true,
|
AccountKeys: accountKeys,
|
||||||
premium: true,
|
Object: 'keys',
|
||||||
premiumFromOrganization: false,
|
|
||||||
usesKeyConnector: false,
|
|
||||||
masterPasswordHint: user.masterPasswordHint,
|
|
||||||
culture: 'en-US',
|
|
||||||
twoFactorEnabled: !!user.totpSecret,
|
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
publicKey: user.publicKey ?? '',
|
||||||
|
privateKey: user.privateKey ?? '',
|
||||||
accountKeys,
|
accountKeys,
|
||||||
securityStamp: user.securityStamp || user.id,
|
object: 'keys',
|
||||||
organizations: [],
|
|
||||||
providers: [],
|
|
||||||
providerOrganizations: [],
|
|
||||||
forcePasswordReset: false,
|
|
||||||
avatarColor: null,
|
|
||||||
creationDate: user.createdAt,
|
|
||||||
verifyDevices: user.verifyDevices,
|
|
||||||
role: user.role,
|
|
||||||
status: user.status,
|
|
||||||
object: 'profile',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,7 +462,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin
|
|||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) return errorResponse('User not found', 404);
|
if (!user) return errorResponse('User not found', 404);
|
||||||
return jsonResponse(toProfile(user, env));
|
return jsonResponse(buildProfileResponse(user, env));
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/accounts/profile
|
// PUT /api/accounts/profile
|
||||||
@@ -484,7 +501,7 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResponse(toProfile(user, env));
|
return jsonResponse(buildProfileResponse(user, env));
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT/POST /api/accounts/verify-devices
|
// PUT/POST /api/accounts/verify-devices
|
||||||
@@ -498,6 +515,7 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
|
|||||||
secret?: string;
|
secret?: string;
|
||||||
masterPasswordHash?: string;
|
masterPasswordHash?: string;
|
||||||
verifyDevices?: boolean;
|
verifyDevices?: boolean;
|
||||||
|
VerifyDevices?: boolean;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
@@ -505,7 +523,8 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
|
|||||||
return errorResponse('Invalid JSON', 400);
|
return errorResponse('Invalid JSON', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof body.verifyDevices !== 'boolean') {
|
const verifyDevices = typeof body.verifyDevices === 'boolean' ? body.verifyDevices : body.VerifyDevices;
|
||||||
|
if (typeof verifyDevices !== 'boolean') {
|
||||||
return errorResponse('verifyDevices must be true or false', 400);
|
return errorResponse('verifyDevices must be true or false', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,7 +533,7 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
|
|||||||
return errorResponse('User verification failed.', 400);
|
return errorResponse('User verification failed.', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.verifyDevices = body.verifyDevices;
|
user.verifyDevices = verifyDevices;
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
await writeAuditEvent(storage, {
|
await writeAuditEvent(storage, {
|
||||||
@@ -533,6 +552,19 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
|
|||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/accounts/keys
|
||||||
|
export async function handleGetKeys(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(keysResponse(user));
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/accounts/keys
|
// POST /api/accounts/keys
|
||||||
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
@@ -593,7 +625,7 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleGetProfile(request, env, userId);
|
return jsonResponse(keysResponse(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST/PUT /api/accounts/password
|
// POST/PUT /api/accounts/password
|
||||||
@@ -607,6 +639,7 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
|
|||||||
masterPasswordHash?: string;
|
masterPasswordHash?: string;
|
||||||
currentPasswordHash?: string;
|
currentPasswordHash?: string;
|
||||||
newMasterPasswordHash?: string;
|
newMasterPasswordHash?: string;
|
||||||
|
masterPasswordHint?: string | null;
|
||||||
key?: string;
|
key?: string;
|
||||||
newKey?: string;
|
newKey?: string;
|
||||||
encryptedPrivateKey?: string;
|
encryptedPrivateKey?: string;
|
||||||
@@ -617,6 +650,8 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
|
|||||||
kdfIterations?: number;
|
kdfIterations?: number;
|
||||||
kdfMemory?: number;
|
kdfMemory?: number;
|
||||||
kdfParallelism?: number;
|
kdfParallelism?: number;
|
||||||
|
authenticationData?: Record<string, unknown>;
|
||||||
|
unlockData?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
@@ -629,10 +664,16 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
|
|||||||
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
|
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
|
||||||
if (!valid) return errorResponse('Invalid password', 400);
|
if (!valid) return errorResponse('Invalid password', 400);
|
||||||
|
|
||||||
if (!body.newMasterPasswordHash) {
|
const newMasterPasswordHash =
|
||||||
|
body.newMasterPasswordHash ||
|
||||||
|
readNestedString(body, ['authenticationData', 'masterPasswordAuthenticationHash']);
|
||||||
|
if (!newMasterPasswordHash) {
|
||||||
return errorResponse('newMasterPasswordHash is required', 400);
|
return errorResponse('newMasterPasswordHash is required', 400);
|
||||||
}
|
}
|
||||||
const nextKey = body.newKey || body.key;
|
const nextKey =
|
||||||
|
body.newKey ||
|
||||||
|
body.key ||
|
||||||
|
readNestedString(body, ['unlockData', 'masterKeyWrappedUserKey']);
|
||||||
const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey;
|
const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey;
|
||||||
const nextPublicKey = body.newPublicKey || body.publicKey;
|
const nextPublicKey = body.newPublicKey || body.publicKey;
|
||||||
if (nextKey && !looksLikeEncString(nextKey)) {
|
if (nextKey && !looksLikeEncString(nextKey)) {
|
||||||
@@ -642,17 +683,24 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
|
|||||||
return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400);
|
return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const kdfErr = validateKdfParams(body.kdf ?? user.kdfType, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
|
const nextKdf = body.kdf ?? readNestedNumber(body, ['unlockData', 'kdf', 'kdfType']) ?? user.kdfType;
|
||||||
|
const nextKdfIterations = body.kdfIterations ?? readNestedNumber(body, ['unlockData', 'kdf', 'iterations']);
|
||||||
|
const nextKdfMemory = body.kdfMemory ?? readNestedNumber(body, ['unlockData', 'kdf', 'memory']);
|
||||||
|
const nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']);
|
||||||
|
const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism);
|
||||||
if (kdfErr) return errorResponse(kdfErr, 400);
|
if (kdfErr) return errorResponse(kdfErr, 400);
|
||||||
|
|
||||||
user.masterPasswordHash = await auth.hashPasswordServer(body.newMasterPasswordHash, user.email);
|
user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email);
|
||||||
if (nextKey) user.key = nextKey;
|
if (nextKey) user.key = nextKey;
|
||||||
if (nextPrivateKey) user.privateKey = nextPrivateKey;
|
if (nextPrivateKey) user.privateKey = nextPrivateKey;
|
||||||
if (nextPublicKey) user.publicKey = nextPublicKey;
|
if (nextPublicKey) user.publicKey = nextPublicKey;
|
||||||
if (typeof body.kdf === 'number') user.kdfType = body.kdf;
|
if (typeof nextKdf === 'number') user.kdfType = nextKdf;
|
||||||
if (typeof body.kdfIterations === 'number') user.kdfIterations = body.kdfIterations;
|
if (typeof nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations;
|
||||||
if (typeof body.kdfMemory === 'number') user.kdfMemory = body.kdfMemory;
|
if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory;
|
||||||
if (typeof body.kdfParallelism === 'number') user.kdfParallelism = body.kdfParallelism;
|
if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism;
|
||||||
|
if (typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null) {
|
||||||
|
user.masterPasswordHint = body.masterPasswordHint;
|
||||||
|
}
|
||||||
user.securityStamp = generateUUID();
|
user.securityStamp = generateUUID();
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
@@ -1061,23 +1109,26 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
|
|||||||
return errorResponse('User not found', 404);
|
return errorResponse('User not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: { masterPasswordHash?: string };
|
let body: { masterPasswordHash?: string; authenticationData?: Record<string, unknown> };
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
return errorResponse('Invalid JSON', 400);
|
return errorResponse('Invalid JSON', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!body.masterPasswordHash) {
|
const masterPasswordHash =
|
||||||
|
body.masterPasswordHash ||
|
||||||
|
readNestedString(body, ['authenticationData', 'masterPasswordAuthenticationHash']);
|
||||||
|
if (!masterPasswordHash) {
|
||||||
return errorResponse('masterPasswordHash is required', 400);
|
return errorResponse('masterPasswordHash is required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
|
const valid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return errorResponse('Invalid password', 400);
|
return errorResponse('Invalid password', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return jsonResponse(masterPasswordPolicyResponse());
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/accounts/api-key
|
// POST /api/accounts/api-key
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
|
import { Env, Attachment, Cipher, DEFAULT_DEV_SECRET } from '../types';
|
||||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
|
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
|
||||||
@@ -31,6 +31,38 @@ function notifyVaultSyncForRequest(
|
|||||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalId(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyCipherUpdateForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
cipher: Cipher,
|
||||||
|
revisionDate: string
|
||||||
|
): void {
|
||||||
|
notifyUserCipherUpdate(env, {
|
||||||
|
userId: cipher.userId,
|
||||||
|
cipherId: cipher.id,
|
||||||
|
revisionDate,
|
||||||
|
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
|
||||||
|
collectionIds: Array.isArray((cipher as any).collectionIds)
|
||||||
|
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
|
||||||
|
: null,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentDispositionAttachment(fileName: string | null | undefined): string {
|
||||||
|
const fallback = 'attachment';
|
||||||
|
const value = String(fileName || fallback)
|
||||||
|
.replace(/[\r\n"]/g, '_')
|
||||||
|
.trim() || fallback;
|
||||||
|
return `attachment; filename="${value}"`;
|
||||||
|
}
|
||||||
|
|
||||||
async function writeAttachmentAudit(
|
async function writeAttachmentAudit(
|
||||||
storage: StorageService,
|
storage: StorageService,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -75,6 +107,7 @@ async function runWithConcurrency<T>(
|
|||||||
async function processAttachmentUpload(
|
async function processAttachmentUpload(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
|
cipher: Cipher,
|
||||||
attachment: Attachment,
|
attachment: Attachment,
|
||||||
cipherId: string
|
cipherId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
@@ -116,6 +149,7 @@ async function processAttachmentUpload(
|
|||||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
if (revisionInfo) {
|
if (revisionInfo) {
|
||||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 201 });
|
return new Response(null, { status: 201 });
|
||||||
@@ -176,6 +210,7 @@ export async function handleCreateAttachment(
|
|||||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
if (revisionInfo) {
|
if (revisionInfo) {
|
||||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get updated cipher for response
|
// Get updated cipher for response
|
||||||
@@ -219,7 +254,7 @@ export async function handleUploadAttachment(
|
|||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return processAttachmentUpload(request, env, attachment, cipherId);
|
return processAttachmentUpload(request, env, cipher, attachment, cipherId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handlePublicUploadAttachment(
|
export async function handlePublicUploadAttachment(
|
||||||
@@ -257,7 +292,7 @@ export async function handlePublicUploadAttachment(
|
|||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return processAttachmentUpload(request, env, attachment, cipherId);
|
return processAttachmentUpload(request, env, cipher, attachment, cipherId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
|
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||||
@@ -348,6 +383,7 @@ export async function handleUpdateAttachmentMetadata(
|
|||||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
if (revisionInfo) {
|
if (revisionInfo) {
|
||||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
@@ -415,7 +451,9 @@ export async function handlePublicDownloadAttachment(
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': object.contentType || 'application/octet-stream',
|
'Content-Type': object.contentType || 'application/octet-stream',
|
||||||
'Content-Length': String(object.size),
|
'Content-Length': String(object.size),
|
||||||
|
'Content-Disposition': contentDispositionAttachment(attachment.fileName),
|
||||||
'Cache-Control': 'private, no-cache',
|
'Cache-Control': 'private, no-cache',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -453,6 +491,7 @@ export async function handleDeleteAttachment(
|
|||||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
if (revisionInfo) {
|
if (revisionInfo) {
|
||||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||||
await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', {
|
await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', {
|
||||||
id: attachmentId,
|
id: attachmentId,
|
||||||
cipherId,
|
cipherId,
|
||||||
@@ -463,9 +502,13 @@ export async function handleDeleteAttachment(
|
|||||||
// Get updated cipher for response
|
// Get updated cipher for response
|
||||||
const updatedCipher = await storage.getCipher(cipherId);
|
const updatedCipher = await storage.getCipher(cipherId);
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
const cipherResponse = cipherToResponse(updatedCipher!, attachments);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
cipher: cipherToResponse(updatedCipher!, attachments),
|
Cipher: cipherResponse,
|
||||||
|
cipher: cipherResponse,
|
||||||
|
Object: 'deleteAttachment',
|
||||||
|
object: 'deleteAttachment',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+94
-7
@@ -11,7 +11,13 @@ import {
|
|||||||
PasswordHistory,
|
PasswordHistory,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
import {
|
||||||
|
notifyUserCipherCreate,
|
||||||
|
notifyUserCipherDelete,
|
||||||
|
notifyUserCipherUpdate,
|
||||||
|
notifyUserCiphersSync,
|
||||||
|
notifyUserVaultSync,
|
||||||
|
} from '../durable/notifications-hub';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
|
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
|
||||||
@@ -42,6 +48,22 @@ function normalizeOptionalId(value: unknown): string | null {
|
|||||||
return normalized ? normalized : null;
|
return normalized ? normalized : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBooleanOrFallback(value: unknown, fallback: boolean): boolean {
|
||||||
|
return typeof value === 'boolean' ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCipherPermissions(passthrough: Record<string, unknown>): { delete: boolean; restore: boolean } {
|
||||||
|
const raw = passthrough.permissions;
|
||||||
|
const source = raw && typeof raw === 'object' && !Array.isArray(raw)
|
||||||
|
? raw as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
delete: readBooleanOrFallback(source?.delete, true),
|
||||||
|
restore: readBooleanOrFallback(source?.restore, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function notifyVaultSyncForRequest(
|
function notifyVaultSyncForRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
@@ -51,6 +73,60 @@ function notifyVaultSyncForRequest(
|
|||||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notifyCipherCreateForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
cipher: Cipher,
|
||||||
|
revisionDate: string
|
||||||
|
): void {
|
||||||
|
notifyUserCipherCreate(env, {
|
||||||
|
userId: cipher.userId,
|
||||||
|
cipherId: cipher.id,
|
||||||
|
revisionDate,
|
||||||
|
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
|
||||||
|
collectionIds: Array.isArray((cipher as any).collectionIds)
|
||||||
|
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
|
||||||
|
: null,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyCipherUpdateForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
cipher: Cipher,
|
||||||
|
revisionDate: string
|
||||||
|
): void {
|
||||||
|
notifyUserCipherUpdate(env, {
|
||||||
|
userId: cipher.userId,
|
||||||
|
cipherId: cipher.id,
|
||||||
|
revisionDate,
|
||||||
|
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
|
||||||
|
collectionIds: Array.isArray((cipher as any).collectionIds)
|
||||||
|
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
|
||||||
|
: null,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyCipherDeleteForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
cipher: Cipher,
|
||||||
|
revisionDate: string
|
||||||
|
): void {
|
||||||
|
notifyUserCipherDelete(env, {
|
||||||
|
userId: cipher.userId,
|
||||||
|
cipherId: cipher.id,
|
||||||
|
revisionDate,
|
||||||
|
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
|
||||||
|
collectionIds: Array.isArray((cipher as any).collectionIds)
|
||||||
|
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
|
||||||
|
: null,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
|
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
|
||||||
if (!source || typeof source !== 'object') return { present: false, value: undefined };
|
if (!source || typeof source !== 'object') return { present: false, value: undefined };
|
||||||
for (const key of aliases) {
|
for (const key of aliases) {
|
||||||
@@ -645,6 +721,7 @@ export function cipherToResponse(
|
|||||||
? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 }
|
? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 }
|
||||||
: null;
|
: null;
|
||||||
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
|
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
|
||||||
|
const responsePermissions = buildCipherPermissions(passthrough);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Pass through ALL stored cipher fields (known + unknown)
|
// Pass through ALL stored cipher fields (known + unknown)
|
||||||
@@ -658,12 +735,9 @@ export function cipherToResponse(
|
|||||||
revisionDate: updatedAt,
|
revisionDate: updatedAt,
|
||||||
deletedDate: deletedAt,
|
deletedDate: deletedAt,
|
||||||
archivedDate: archivedAt ?? null,
|
archivedDate: archivedAt ?? null,
|
||||||
edit: true,
|
edit: readBooleanOrFallback((passthrough as any).edit, true),
|
||||||
viewPassword: true,
|
viewPassword: readBooleanOrFallback((passthrough as any).viewPassword, true),
|
||||||
permissions: {
|
permissions: responsePermissions,
|
||||||
delete: true,
|
|
||||||
restore: true,
|
|
||||||
},
|
|
||||||
object: 'cipherDetails',
|
object: 'cipherDetails',
|
||||||
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
|
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
|
||||||
attachments: formatAttachments(responseAttachments),
|
attachments: formatAttachments(responseAttachments),
|
||||||
@@ -815,6 +889,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyCipherCreateForRequest(request, env, cipher, revisionDate);
|
||||||
const responseOptions = cipherResponseOptionsForRequest(request);
|
const responseOptions = cipherResponseOptionsForRequest(request);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
@@ -925,6 +1000,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
const responseOptions = cipherResponseOptionsForRequest(request);
|
const responseOptions = cipherResponseOptionsForRequest(request);
|
||||||
|
|
||||||
@@ -949,6 +1025,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
|||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyCipherDeleteForRequest(request, env, cipher, revisionDate);
|
||||||
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', {
|
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', {
|
||||||
id: cipher.id,
|
id: cipher.id,
|
||||||
type: cipher.type,
|
type: cipher.type,
|
||||||
@@ -978,6 +1055,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
|
|||||||
await storage.deleteCipher(id, userId);
|
await storage.deleteCipher(id, userId);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyCipherDeleteForRequest(request, env, cipher, revisionDate);
|
||||||
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
|
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
|
||||||
id,
|
id,
|
||||||
type: cipher.type,
|
type: cipher.type,
|
||||||
@@ -1005,6 +1083,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
|
|||||||
await storage.deleteCipher(id, userId);
|
await storage.deleteCipher(id, userId);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyCipherDeleteForRequest(request, env, cipher, revisionDate);
|
||||||
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
|
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
|
||||||
id,
|
id,
|
||||||
type: cipher.type,
|
type: cipher.type,
|
||||||
@@ -1029,6 +1108,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
|||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
|
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
|
||||||
@@ -1068,6 +1148,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
|||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
|
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
|
||||||
@@ -1144,6 +1225,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
|
|||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
|
||||||
|
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
@@ -1192,6 +1274,7 @@ export async function handleBulkArchiveCiphers(request: Request, env: Env, userI
|
|||||||
const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
|
const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildCipherListResponse(request, storage, userId, ids);
|
return buildCipherListResponse(request, storage, userId, ids);
|
||||||
@@ -1216,6 +1299,7 @@ export async function handleBulkUnarchiveCiphers(request: Request, env: Env, use
|
|||||||
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
|
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildCipherListResponse(request, storage, userId, ids);
|
return buildCipherListResponse(request, storage, userId, ids);
|
||||||
@@ -1239,6 +1323,7 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
|
|||||||
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
|
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', {
|
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', {
|
||||||
count: body.ids.length,
|
count: body.ids.length,
|
||||||
});
|
});
|
||||||
@@ -1265,6 +1350,7 @@ export async function handleBulkRestoreCiphers(request: Request, env: Env, userI
|
|||||||
const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId);
|
const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
@@ -1301,6 +1387,7 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En
|
|||||||
const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId);
|
const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', {
|
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', {
|
||||||
count: ownedIds.length,
|
count: ownedIds.length,
|
||||||
requestedCount: ids.length,
|
requestedCount: ids.length,
|
||||||
|
|||||||
+40
-9
@@ -3,6 +3,7 @@ import { Env } from '../types';
|
|||||||
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
|
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
|
||||||
import { AuthService } from '../services/auth';
|
import { AuthService } from '../services/auth';
|
||||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||||
|
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import { readKnownDeviceProbe } from '../utils/device';
|
import { readKnownDeviceProbe } from '../utils/device';
|
||||||
@@ -223,6 +224,8 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
|
|||||||
encryptedUserKey: null,
|
encryptedUserKey: null,
|
||||||
encryptedPublicKey: null,
|
encryptedPublicKey: null,
|
||||||
encryptedPrivateKey: null,
|
encryptedPrivateKey: null,
|
||||||
|
pushUuid: null,
|
||||||
|
pushToken: null,
|
||||||
devicePendingAuthRequest: null,
|
devicePendingAuthRequest: null,
|
||||||
deviceNote: null,
|
deviceNote: null,
|
||||||
lastSeenAt: null,
|
lastSeenAt: null,
|
||||||
@@ -325,10 +328,12 @@ export async function handleDeleteDevice(
|
|||||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||||
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||||
const deleted = await storage.deleteDevice(userId, normalized);
|
const deleted = await storage.deleteDevice(userId, normalized);
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
|
await unregisterMobilePushDevice(env, device?.pushUuid);
|
||||||
AuthService.invalidateDeviceCache(userId, normalized);
|
AuthService.invalidateDeviceCache(userId, normalized);
|
||||||
notifyUserLogout(env, userId, normalized);
|
notifyUserLogout(env, userId, normalized);
|
||||||
}
|
}
|
||||||
@@ -537,10 +542,12 @@ export async function handleDeactivateDevice(
|
|||||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||||
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||||
const deleted = await storage.deleteDevice(userId, normalized);
|
const deleted = await storage.deleteDevice(userId, normalized);
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
|
await unregisterMobilePushDevice(env, device?.pushUuid);
|
||||||
AuthService.invalidateDeviceCache(userId, normalized);
|
AuthService.invalidateDeviceCache(userId, normalized);
|
||||||
notifyUserLogout(env, userId, normalized);
|
notifyUserLogout(env, userId, normalized);
|
||||||
}
|
}
|
||||||
@@ -557,18 +564,36 @@ export async function handleDeactivateDevice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/devices/identifier/{deviceIdentifier}/token
|
// PUT /api/devices/identifier/{deviceIdentifier}/token
|
||||||
// Bitwarden mobile reports push token updates to this endpoint.
|
// Bitwarden mobile reports APNs/FCM push token updates to this endpoint.
|
||||||
// NodeWarden does not implement push notifications, so accept and no-op.
|
|
||||||
export async function handleUpdateDeviceToken(
|
export async function handleUpdateDeviceToken(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
deviceIdentifier: string
|
deviceIdentifier: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
void request;
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
void env;
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
void userId;
|
|
||||||
void deviceIdentifier;
|
const body = await readJsonBody(request);
|
||||||
|
const pushToken = String(body?.pushToken ?? body?.PushToken ?? '').trim();
|
||||||
|
if (!pushToken) return errorResponse('Invalid push token', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
|
if (!device) return errorResponse('Device not found', 404);
|
||||||
|
|
||||||
|
const pushUuid = device.pushUuid || generateUUID();
|
||||||
|
const updated = await storage.updateDevicePushToken(userId, normalized, pushUuid, pushToken);
|
||||||
|
if (updated) {
|
||||||
|
await registerMobilePushDevice(env, {
|
||||||
|
userId,
|
||||||
|
deviceIdentifier: normalized,
|
||||||
|
type: device.type,
|
||||||
|
pushUuid,
|
||||||
|
pushToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,9 +619,15 @@ export async function handleClearDeviceToken(
|
|||||||
deviceIdentifier: string
|
deviceIdentifier: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
void request;
|
void request;
|
||||||
void env;
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
void userId;
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
void deviceIdentifier;
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const cleared = await storage.clearDevicePushToken(userId, normalized);
|
||||||
|
if (cleared?.pushUuid) {
|
||||||
|
await unregisterMobilePushDevice(env, cleared.pushUuid);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+38
-1
@@ -1,5 +1,10 @@
|
|||||||
import { Env, Folder, FolderResponse } from '../types';
|
import { Env, Folder, FolderResponse } from '../types';
|
||||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
import {
|
||||||
|
notifyUserFolderCreate,
|
||||||
|
notifyUserFolderDelete,
|
||||||
|
notifyUserFolderUpdate,
|
||||||
|
notifyUserVaultSync,
|
||||||
|
} from '../durable/notifications-hub';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
@@ -111,6 +116,12 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
|
|||||||
await storage.saveFolder(folder);
|
await storage.saveFolder(folder);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyUserFolderCreate(env, {
|
||||||
|
userId,
|
||||||
|
folderId: folder.id,
|
||||||
|
revisionDate,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
|
||||||
return jsonResponse(folderToResponse(folder), 200);
|
return jsonResponse(folderToResponse(folder), 200);
|
||||||
}
|
}
|
||||||
@@ -139,6 +150,12 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
|
|||||||
await storage.saveFolder(folder);
|
await storage.saveFolder(folder);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyUserFolderUpdate(env, {
|
||||||
|
userId,
|
||||||
|
folderId: folder.id,
|
||||||
|
revisionDate,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
|
||||||
return jsonResponse(folderToResponse(folder));
|
return jsonResponse(folderToResponse(folder));
|
||||||
}
|
}
|
||||||
@@ -156,6 +173,12 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
|
|||||||
await storage.deleteFolder(id, userId);
|
await storage.deleteFolder(id, userId);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyUserFolderDelete(env, {
|
||||||
|
userId,
|
||||||
|
folderId: id,
|
||||||
|
revisionDate,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
await writeFolderAudit(storage, request, userId, 'folder.delete', {
|
await writeFolderAudit(storage, request, userId, 'folder.delete', {
|
||||||
id,
|
id,
|
||||||
});
|
});
|
||||||
@@ -179,9 +202,23 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
|
|||||||
return errorResponse('Folder ids are required', 400);
|
return errorResponse('Folder ids are required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const folders = (
|
||||||
|
await Promise.all(ids.map(async (id) => {
|
||||||
|
const folder = await storage.getFolder(id);
|
||||||
|
return folder && folder.userId === userId ? folder : null;
|
||||||
|
}))
|
||||||
|
).filter((folder): folder is Folder => !!folder);
|
||||||
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
for (const folder of folders) {
|
||||||
|
notifyUserFolderDelete(env, {
|
||||||
|
userId,
|
||||||
|
folderId: folder.id,
|
||||||
|
revisionDate,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', {
|
await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', {
|
||||||
count: ids.length,
|
count: ids.length,
|
||||||
});
|
});
|
||||||
|
|||||||
+61
-15
@@ -10,6 +10,7 @@ import { readAuthRequestDeviceInfo } from '../utils/device';
|
|||||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { issueSendAccessToken } from './sends';
|
import { issueSendAccessToken } from './sends';
|
||||||
|
import { registerMobilePushDevice } from '../services/push-relay';
|
||||||
import {
|
import {
|
||||||
buildAccountKeys,
|
buildAccountKeys,
|
||||||
buildUserDecryptionOptions,
|
buildUserDecryptionOptions,
|
||||||
@@ -50,6 +51,44 @@ async function resolveDeviceSession(
|
|||||||
return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
|
return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readDevicePushToken(body: Record<string, string>): string {
|
||||||
|
return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistIdentityDevicePushToken(
|
||||||
|
env: Env,
|
||||||
|
storage: StorageService,
|
||||||
|
userId: string,
|
||||||
|
deviceSession: { identifier: string; sessionStamp: string } | null,
|
||||||
|
deviceType: number,
|
||||||
|
body: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!deviceSession) return;
|
||||||
|
const pushToken = readDevicePushToken(body);
|
||||||
|
if (!pushToken) return;
|
||||||
|
|
||||||
|
const device = await storage.getDevice(userId, deviceSession.identifier);
|
||||||
|
if (!device) return;
|
||||||
|
|
||||||
|
const pushUuid = device.pushUuid || generateUUID();
|
||||||
|
await storage.updateDevicePushToken(userId, deviceSession.identifier, pushUuid, pushToken);
|
||||||
|
const registered = await registerMobilePushDevice(env, {
|
||||||
|
userId,
|
||||||
|
deviceIdentifier: deviceSession.identifier,
|
||||||
|
type: device.type || deviceType,
|
||||||
|
pushUuid,
|
||||||
|
pushToken,
|
||||||
|
});
|
||||||
|
console.info('Mobile push token updated from identity token request', {
|
||||||
|
userId,
|
||||||
|
deviceIdentifier: deviceSession.identifier,
|
||||||
|
deviceType: device.type || deviceType,
|
||||||
|
pushUuid,
|
||||||
|
pushTokenLength: pushToken.length,
|
||||||
|
relayRegistered: registered,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function shouldUseWebSession(request: Request): boolean {
|
function shouldUseWebSession(request: Request): boolean {
|
||||||
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
|
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
|
||||||
}
|
}
|
||||||
@@ -140,6 +179,20 @@ function buildPreloginResponse(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] {
|
||||||
|
return {
|
||||||
|
minComplexity: 0,
|
||||||
|
minLength: 0,
|
||||||
|
requireUpper: false,
|
||||||
|
requireLower: false,
|
||||||
|
requireNumbers: false,
|
||||||
|
requireSpecial: false,
|
||||||
|
enforceOnLogin: false,
|
||||||
|
Object: 'masterPasswordPolicy',
|
||||||
|
object: 'masterPasswordPolicy',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
|
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
|
||||||
// Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
|
// Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
|
||||||
// Clients expose recovery-code entry points themselves; Android 2026.4 fails to
|
// Clients expose recovery-code entry points themselves; Android 2026.4 fails to
|
||||||
@@ -151,9 +204,7 @@ function twoFactorRequiredResponse(message: string = 'Two factor required.'): Re
|
|||||||
TwoFactorProviders: providers,
|
TwoFactorProviders: providers,
|
||||||
TwoFactorProviders2: providers2,
|
TwoFactorProviders2: providers2,
|
||||||
SsoEmail2faSessionToken: null,
|
SsoEmail2faSessionToken: null,
|
||||||
MasterPasswordPolicy: {
|
MasterPasswordPolicy: masterPasswordPolicyResponse(),
|
||||||
Object: 'masterPasswordPolicy',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
||||||
@@ -402,6 +453,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
deviceInfo.deviceType,
|
deviceInfo.deviceType,
|
||||||
deviceSession.sessionStamp
|
deviceSession.sessionStamp
|
||||||
);
|
);
|
||||||
|
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful login - clear failed attempts
|
// Successful login - clear failed attempts
|
||||||
@@ -446,9 +498,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
KdfParallelism: user.kdfParallelism,
|
KdfParallelism: user.kdfParallelism,
|
||||||
ForcePasswordReset: false,
|
ForcePasswordReset: false,
|
||||||
ResetMasterPassword: false,
|
ResetMasterPassword: false,
|
||||||
MasterPasswordPolicy: {
|
MasterPasswordPolicy: masterPasswordPolicyResponse(),
|
||||||
Object: 'masterPasswordPolicy',
|
|
||||||
},
|
|
||||||
ApiUseKeyConnector: false,
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
@@ -526,6 +576,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
deviceInfo.deviceType,
|
deviceInfo.deviceType,
|
||||||
deviceSession.sessionStamp
|
deviceSession.sessionStamp
|
||||||
);
|
);
|
||||||
|
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
await rateLimit.clearLoginAttempts(loginIdentifier);
|
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||||
@@ -566,9 +617,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
KdfParallelism: user.kdfParallelism,
|
KdfParallelism: user.kdfParallelism,
|
||||||
ForcePasswordReset: false,
|
ForcePasswordReset: false,
|
||||||
ResetMasterPassword: false,
|
ResetMasterPassword: false,
|
||||||
MasterPasswordPolicy: {
|
MasterPasswordPolicy: masterPasswordPolicyResponse(),
|
||||||
Object: 'masterPasswordPolicy',
|
|
||||||
},
|
|
||||||
ApiUseKeyConnector: false,
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
@@ -656,6 +705,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
deviceInfo.deviceType,
|
deviceInfo.deviceType,
|
||||||
deviceSession.sessionStamp
|
deviceSession.sessionStamp
|
||||||
);
|
);
|
||||||
|
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful login - clear failed attempts
|
// Successful login - clear failed attempts
|
||||||
@@ -696,9 +746,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
KdfParallelism: user.kdfParallelism,
|
KdfParallelism: user.kdfParallelism,
|
||||||
ForcePasswordReset: false,
|
ForcePasswordReset: false,
|
||||||
ResetMasterPassword: false,
|
ResetMasterPassword: false,
|
||||||
MasterPasswordPolicy: {
|
MasterPasswordPolicy: masterPasswordPolicyResponse(),
|
||||||
Object: 'masterPasswordPolicy',
|
|
||||||
},
|
|
||||||
ApiUseKeyConnector: false,
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
@@ -836,9 +884,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
KdfParallelism: user.kdfParallelism,
|
KdfParallelism: user.kdfParallelism,
|
||||||
ForcePasswordReset: false,
|
ForcePasswordReset: false,
|
||||||
ResetMasterPassword: false,
|
ResetMasterPassword: false,
|
||||||
MasterPasswordPolicy: {
|
MasterPasswordPolicy: masterPasswordPolicyResponse(),
|
||||||
Object: 'masterPasswordPolicy',
|
|
||||||
},
|
|
||||||
ApiUseKeyConnector: false,
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
formatSize,
|
formatSize,
|
||||||
getAliasedProp,
|
getAliasedProp,
|
||||||
normalizeEmails,
|
normalizeEmails,
|
||||||
|
notifySendCreateForRequest,
|
||||||
|
notifySendDeleteForRequest,
|
||||||
|
notifySendUpdateForRequest,
|
||||||
notifyVaultSyncForRequest,
|
notifyVaultSyncForRequest,
|
||||||
parseDate,
|
parseDate,
|
||||||
parseFileLength,
|
parseFileLength,
|
||||||
@@ -99,6 +102,7 @@ async function processSendFileUpload(
|
|||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
|
||||||
|
|
||||||
return new Response(null, { status: 201 });
|
return new Response(null, { status: 201 });
|
||||||
}
|
}
|
||||||
@@ -249,6 +253,7 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
|
|||||||
await storage.saveSend(send);
|
await storage.saveSend(send);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifySendCreateForRequest(request, env, send.id, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(sendToResponse(send));
|
return jsonResponse(sendToResponse(send));
|
||||||
}
|
}
|
||||||
@@ -372,6 +377,7 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
|
|||||||
await storage.saveSend(send);
|
await storage.saveSend(send);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifySendCreateForRequest(request, env, send.id, userId, revisionDate);
|
||||||
const jwtSecret = getSafeJwtSecret(env);
|
const jwtSecret = getSafeJwtSecret(env);
|
||||||
if (!jwtSecret) {
|
if (!jwtSecret) {
|
||||||
return errorResponse('Server configuration error', 500);
|
return errorResponse('Server configuration error', 500);
|
||||||
@@ -619,6 +625,7 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
|
|||||||
await storage.saveSend(send);
|
await storage.saveSend(send);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(sendToResponse(send));
|
return jsonResponse(sendToResponse(send));
|
||||||
}
|
}
|
||||||
@@ -641,6 +648,7 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
|
|||||||
await storage.deleteSend(sendId, userId);
|
await storage.deleteSend(sendId, userId);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifySendDeleteForRequest(request, env, sendId, userId, revisionDate);
|
||||||
await writeSendAudit(storage, request, userId, 'send.delete', {
|
await writeSendAudit(storage, request, userId, 'send.delete', {
|
||||||
id: sendId,
|
id: sendId,
|
||||||
type: send.type,
|
type: send.type,
|
||||||
@@ -676,6 +684,9 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
|
|||||||
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
|
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
for (const send of sends) {
|
||||||
|
notifySendDeleteForRequest(request, env, send.id, userId, revisionDate);
|
||||||
|
}
|
||||||
await writeSendAudit(storage, request, userId, 'send.delete.bulk', {
|
await writeSendAudit(storage, request, userId, 'send.delete.bulk', {
|
||||||
count: sends.length,
|
count: sends.length,
|
||||||
requestedCount: body.ids.length,
|
requestedCount: body.ids.length,
|
||||||
@@ -697,6 +708,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
|
|||||||
await storage.saveSend(send);
|
await storage.saveSend(send);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
|
||||||
await writeSendAudit(storage, request, userId, 'send.password.remove', {
|
await writeSendAudit(storage, request, userId, 'send.password.remove', {
|
||||||
id: send.id,
|
id: send.id,
|
||||||
type: send.type,
|
type: send.type,
|
||||||
@@ -718,6 +730,7 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
|
|||||||
await storage.saveSend(send);
|
await storage.saveSend(send);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
|
||||||
await writeSendAudit(storage, request, userId, 'send.auth.remove', {
|
await writeSendAudit(storage, request, userId, 'send.auth.remove', {
|
||||||
id: send.id,
|
id: send.id,
|
||||||
type: send.type,
|
type: send.type,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getSafeJwtSecret,
|
getSafeJwtSecret,
|
||||||
hasEmailAuth,
|
hasEmailAuth,
|
||||||
isSendAvailable,
|
isSendAvailable,
|
||||||
|
notifySendUpdateForRequest,
|
||||||
notifyVaultSyncForRequest,
|
notifyVaultSyncForRequest,
|
||||||
parseStoredSendData,
|
parseStoredSendData,
|
||||||
resolveSendFromIdOrAccessId,
|
resolveSendFromIdOrAccessId,
|
||||||
@@ -33,6 +34,14 @@ import {
|
|||||||
verifySendPasswordHashB64,
|
verifySendPasswordHashB64,
|
||||||
} from './sends-shared';
|
} from './sends-shared';
|
||||||
|
|
||||||
|
function contentDispositionAttachment(fileName: string | null | undefined): string {
|
||||||
|
const fallback = 'send-file';
|
||||||
|
const value = String(fileName || fallback)
|
||||||
|
.replace(/[\r\n"]/g, '_')
|
||||||
|
.trim() || fallback;
|
||||||
|
return `attachment; filename="${value}"`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> {
|
export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const sendId = fromAccessId(accessId);
|
const sendId = fromAccessId(accessId);
|
||||||
@@ -90,6 +99,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
|
|||||||
send.accessCount += 1;
|
send.accessCount += 1;
|
||||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||||
@@ -163,6 +173,7 @@ export async function handleAccessSendFile(
|
|||||||
send.accessCount += 1;
|
send.accessCount += 1;
|
||||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
|
||||||
|
|
||||||
const token = await createSendFileDownloadToken(send.id, fileId, secret);
|
const token = await createSendFileDownloadToken(send.id, fileId, secret);
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -203,6 +214,7 @@ export async function handleAccessSendV2(request: Request, env: Env): Promise<Re
|
|||||||
send.accessCount += 1;
|
send.accessCount += 1;
|
||||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||||
@@ -242,6 +254,7 @@ export async function handleAccessSendFileV2(request: Request, env: Env, fileId:
|
|||||||
send.accessCount += 1;
|
send.accessCount += 1;
|
||||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
|
||||||
|
|
||||||
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
|
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -282,6 +295,9 @@ export async function handleDownloadSendFile(
|
|||||||
if (!object) {
|
if (!object) {
|
||||||
return errorResponse('Send file not found', 404);
|
return errorResponse('Send file not found', 404);
|
||||||
}
|
}
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
const data = send ? parseStoredSendData(send) : {};
|
||||||
|
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
|
||||||
|
|
||||||
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
|
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
|
||||||
if (!firstUse) {
|
if (!firstUse) {
|
||||||
@@ -292,7 +308,9 @@ export async function handleDownloadSendFile(
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': object.contentType || 'application/octet-stream',
|
'Content-Type': object.contentType || 'application/octet-stream',
|
||||||
'Content-Length': String(object.size),
|
'Content-Length': String(object.size),
|
||||||
|
'Content-Disposition': contentDispositionAttachment(fileName),
|
||||||
'Cache-Control': 'private, no-cache',
|
'Cache-Control': 'private, no-cache',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
|
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
|
||||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
import {
|
||||||
|
notifyUserSendCreate,
|
||||||
|
notifyUserSendDelete,
|
||||||
|
notifyUserSendUpdate,
|
||||||
|
notifyUserVaultSync,
|
||||||
|
} from '../durable/notifications-hub';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
@@ -18,6 +23,51 @@ export function notifyVaultSyncForRequest(
|
|||||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function notifySendCreateForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
sendId: string,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string
|
||||||
|
): void {
|
||||||
|
notifyUserSendCreate(env, {
|
||||||
|
userId,
|
||||||
|
sendId,
|
||||||
|
revisionDate,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifySendUpdateForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
sendId: string,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string
|
||||||
|
): void {
|
||||||
|
notifyUserSendUpdate(env, {
|
||||||
|
userId,
|
||||||
|
sendId,
|
||||||
|
revisionDate,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifySendDeleteForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
sendId: string,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string
|
||||||
|
): void {
|
||||||
|
notifyUserSendDelete(env, {
|
||||||
|
userId,
|
||||||
|
sendId,
|
||||||
|
revisionDate,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
|
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
|
||||||
if (!source || typeof source !== 'object') return { present: false, value: undefined };
|
if (!source || typeof source !== 'object') return { present: false, value: undefined };
|
||||||
for (const key of aliases) {
|
for (const key of aliases) {
|
||||||
|
|||||||
+4
-26
@@ -5,12 +5,12 @@ import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepaira
|
|||||||
import { sendToResponse } from './sends';
|
import { sendToResponse } from './sends';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import {
|
import {
|
||||||
buildAccountKeys,
|
|
||||||
buildUserDecryptionCompat,
|
buildUserDecryptionCompat,
|
||||||
buildUserDecryptionOptions,
|
buildUserDecryptionOptions,
|
||||||
} from '../utils/user-decryption';
|
} from '../utils/user-decryption';
|
||||||
import { buildDomainsResponse } from '../services/domain-rules';
|
import { buildDomainsResponse } from '../services/domain-rules';
|
||||||
import { buildWebAuthnPrfOption } from '../utils/account-passkeys';
|
import { buildWebAuthnPrfOption } from '../utils/account-passkeys';
|
||||||
|
import { buildProfileResponse } from '../utils/profile-response';
|
||||||
|
|
||||||
// CONTRACT:
|
// CONTRACT:
|
||||||
// /api/sync reuses cipherToResponse() as the single cipher response shaper.
|
// /api/sync reuses cipherToResponse() as the single cipher response shaper.
|
||||||
@@ -84,36 +84,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
storage.getAttachmentsByUserId(userId),
|
storage.getAttachmentsByUserId(userId),
|
||||||
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
|
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
|
||||||
]);
|
]);
|
||||||
const accountKeys = buildAccountKeys(user);
|
|
||||||
const webAuthnPrfOptions = accountPasskeys
|
const webAuthnPrfOptions = accountPasskeys
|
||||||
.map(buildWebAuthnPrfOption)
|
.map(buildWebAuthnPrfOption)
|
||||||
.filter((option): option is NonNullable<typeof option> => !!option);
|
.filter((option): option is NonNullable<typeof option> => !!option);
|
||||||
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
|
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
|
||||||
|
|
||||||
const profile: ProfileResponse = {
|
const profile: ProfileResponse = buildProfileResponse(user, env);
|
||||||
id: user.id,
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
emailVerified: true,
|
|
||||||
premium: true,
|
|
||||||
premiumFromOrganization: false,
|
|
||||||
usesKeyConnector: false,
|
|
||||||
masterPasswordHint: user.masterPasswordHint,
|
|
||||||
culture: 'en-US',
|
|
||||||
twoFactorEnabled: !!user.totpSecret,
|
|
||||||
key: user.key,
|
|
||||||
privateKey: user.privateKey,
|
|
||||||
accountKeys,
|
|
||||||
securityStamp: user.securityStamp || user.id,
|
|
||||||
organizations: [],
|
|
||||||
providers: [],
|
|
||||||
providerOrganizations: [],
|
|
||||||
forcePasswordReset: false,
|
|
||||||
avatarColor: null,
|
|
||||||
creationDate: user.createdAt,
|
|
||||||
verifyDevices: user.verifyDevices,
|
|
||||||
object: 'profile',
|
|
||||||
};
|
|
||||||
|
|
||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of ciphers) {
|
for (const cipher of ciphers) {
|
||||||
@@ -149,6 +125,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
{ omitExcludedGlobals: true }
|
{ omitExcludedGlobals: true }
|
||||||
),
|
),
|
||||||
policies: [],
|
policies: [],
|
||||||
|
policiesNew: [],
|
||||||
sends: sendResponses,
|
sends: sendResponses,
|
||||||
UserDecryption: {
|
UserDecryption: {
|
||||||
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||||
@@ -156,6 +133,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
KeyConnectorOption: null,
|
KeyConnectorOption: null,
|
||||||
WebAuthnPrfOption: webAuthnPrfOptions[0] || null,
|
WebAuthnPrfOption: webAuthnPrfOptions[0] || null,
|
||||||
WebAuthnPrfOptions: webAuthnPrfOptions,
|
WebAuthnPrfOptions: webAuthnPrfOptions,
|
||||||
|
V2UpgradeToken: null,
|
||||||
Object: 'userDecryption',
|
Object: 'userDecryption',
|
||||||
},
|
},
|
||||||
UserDecryptionOptions: userDecryptionOptions,
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { errorResponse, jsonResponse } from './utils/response';
|
|||||||
import {
|
import {
|
||||||
handleGetProfile,
|
handleGetProfile,
|
||||||
handleUpdateProfile,
|
handleUpdateProfile,
|
||||||
|
handleGetKeys,
|
||||||
handleSetKeys,
|
handleSetKeys,
|
||||||
handleGetRevisionDate,
|
handleGetRevisionDate,
|
||||||
handleVerifyPassword,
|
handleVerifyPassword,
|
||||||
@@ -115,8 +116,10 @@ export async function handleAuthenticatedRoute(
|
|||||||
return handleChangePassword(request, env, userId);
|
return handleChangePassword(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/accounts/keys' && method === 'POST') {
|
if (path === '/api/accounts/keys') {
|
||||||
return handleSetKeys(request, env, userId);
|
if (method === 'GET') return handleGetKeys(request, env, userId);
|
||||||
|
if (method === 'POST') return handleSetKeys(request, env, userId);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/accounts/totp') {
|
if (path === '/api/accounts/totp') {
|
||||||
|
|||||||
+19
-15
@@ -20,6 +20,10 @@ import {
|
|||||||
handleClearDeviceToken,
|
handleClearDeviceToken,
|
||||||
} from './handlers/devices';
|
} from './handlers/devices';
|
||||||
|
|
||||||
|
function devicesPath(pattern: string): RegExp {
|
||||||
|
return new RegExp(`^/(?:api/)?devices${pattern}$`, 'i');
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleAuthenticatedDeviceRoute(
|
export async function handleAuthenticatedDeviceRoute(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
@@ -27,31 +31,31 @@ export async function handleAuthenticatedDeviceRoute(
|
|||||||
path: string,
|
path: string,
|
||||||
method: string
|
method: string
|
||||||
): Promise<Response | null> {
|
): Promise<Response | null> {
|
||||||
if (path === '/api/devices') {
|
if (path === '/api/devices' || path === '/devices') {
|
||||||
if (method === 'GET') return handleGetDevices(request, env, userId);
|
if (method === 'GET') return handleGetDevices(request, env, userId);
|
||||||
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
|
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/devices/authorized') {
|
if (path === '/api/devices/authorized' || path === '/devices/authorized') {
|
||||||
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
||||||
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
|
const authorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)'));
|
||||||
if (authorizedDeviceMatch && method === 'DELETE') {
|
if (authorizedDeviceMatch && method === 'DELETE') {
|
||||||
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
|
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
|
||||||
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
|
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const permanentAuthorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)\/permanent$/i);
|
const permanentAuthorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)/permanent'));
|
||||||
if (permanentAuthorizedDeviceMatch && method === 'POST') {
|
if (permanentAuthorizedDeviceMatch && method === 'POST') {
|
||||||
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
|
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
|
||||||
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
|
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
|
const deleteDeviceMatch = path.match(devicesPath('/([^/]+)'));
|
||||||
if (deleteDeviceMatch && method === 'GET') {
|
if (deleteDeviceMatch && method === 'GET') {
|
||||||
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
||||||
return handleGetDevice(request, env, userId, deviceIdentifier);
|
return handleGetDevice(request, env, userId, deviceIdentifier);
|
||||||
@@ -61,59 +65,59 @@ export async function handleAuthenticatedDeviceRoute(
|
|||||||
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i);
|
const updateDeviceNameMatch = path.match(devicesPath('/([^/]+)/name'));
|
||||||
if (updateDeviceNameMatch && method === 'PUT') {
|
if (updateDeviceNameMatch && method === 'PUT') {
|
||||||
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
|
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
|
||||||
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
|
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
|
const identifierMatch = path.match(devicesPath('/identifier/([^/]+)'));
|
||||||
if (identifierMatch && method === 'GET') {
|
if (identifierMatch && method === 'GET') {
|
||||||
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
||||||
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
|
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i);
|
const deviceKeysMatch = path.match(devicesPath('/([^/]+)/keys')) || path.match(devicesPath('/identifier/([^/]+)/keys'));
|
||||||
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
|
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
|
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
|
||||||
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
|
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
|
const identifierTokenMatch = path.match(devicesPath('/identifier/([^/]+)/token'));
|
||||||
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
|
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
|
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
|
||||||
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
|
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i);
|
const identifierWebPushMatch = path.match(devicesPath('/identifier/([^/]+)/web-push-auth'));
|
||||||
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
|
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
|
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
|
||||||
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
|
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
|
const identifierClearTokenMatch = path.match(devicesPath('/identifier/([^/]+)/clear-token'));
|
||||||
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
|
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
|
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
|
||||||
return handleClearDeviceToken(request, env, userId, deviceIdentifier);
|
return handleClearDeviceToken(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i);
|
const identifierRetrieveKeysMatch = path.match(devicesPath('/([^/]+)/retrieve-keys'));
|
||||||
if (identifierRetrieveKeysMatch && method === 'POST') {
|
if (identifierRetrieveKeysMatch && method === 'POST') {
|
||||||
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
|
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
|
||||||
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
|
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i);
|
const identifierDeactivateMatch = path.match(devicesPath('/([^/]+)/deactivate'));
|
||||||
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
|
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
|
||||||
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
|
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
|
||||||
return handleDeactivateDevice(request, env, userId, deviceIdentifier);
|
return handleDeactivateDevice(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/devices/update-trust' && method === 'POST') {
|
if ((path === '/api/devices/update-trust' || path === '/devices/update-trust') && method === 'POST') {
|
||||||
return handleUpdateDeviceTrust(request, env, userId);
|
return handleUpdateDeviceTrust(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/devices/untrust' && method === 'POST') {
|
if ((path === '/api/devices/untrust' || path === '/devices/untrust') && method === 'POST') {
|
||||||
return handleUntrustDevices(request, env, userId);
|
return handleUntrustDevices(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
import {
|
||||||
|
setConfigValue as saveConfigValue,
|
||||||
|
} from './storage-config-repo';
|
||||||
|
|
||||||
|
const PUSH_RELAY_URI = 'https://push.bitwarden.com';
|
||||||
|
const PUSH_IDENTITY_URI = 'https://identity.bitwarden.com';
|
||||||
|
const INSTALLATIONS_URI = 'https://api.bitwarden.com/installations';
|
||||||
|
const PUSH_INSTALLATION_ID_KEY = 'push.installation.id';
|
||||||
|
const PUSH_INSTALLATION_KEY_KEY = 'push.installation.key';
|
||||||
|
const PUSH_REQUEST_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
interface CachedPushAccessToken {
|
||||||
|
token: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedPushAccessToken: CachedPushAccessToken | null = null;
|
||||||
|
|
||||||
|
async function fetchPushEndpoint(url: string, init: RequestInit, errorMessage: string): Promise<Response | null> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), PUSH_REQUEST_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...init, signal: controller.signal });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(errorMessage, error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomInstallationEmail(): string {
|
||||||
|
const bytes = new Uint8Array(10);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
const localPart = Array.from(bytes, (byte) => (byte % 36).toString(36)).join('');
|
||||||
|
return `${localPart}@nodewarden.app`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getConfigKeyPresence(db: D1Database, key: string): Promise<string | null> {
|
||||||
|
const row = await db.prepare('SELECT value FROM config WHERE key = ? LIMIT 1').bind(key).first<{ value: string }>();
|
||||||
|
return typeof row?.value === 'string' ? row.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
|
||||||
|
const [id, key] = await Promise.all([
|
||||||
|
getConfigKeyPresence(db, PUSH_INSTALLATION_ID_KEY),
|
||||||
|
getConfigKeyPresence(db, PUSH_INSTALLATION_KEY_KEY),
|
||||||
|
]);
|
||||||
|
const normalizedId = String(id || '').trim();
|
||||||
|
const normalizedKey = String(key || '').trim();
|
||||||
|
return normalizedId && normalizedKey ? { id: normalizedId, key: normalizedKey } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensurePushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
|
||||||
|
const existing = await getPushInstallationCredentials(db);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const response = await fetchPushEndpoint(
|
||||||
|
INSTALLATIONS_URI,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||||
|
'cache-control': 'no-cache',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
origin: 'https://bitwarden.com',
|
||||||
|
pragma: 'no-cache',
|
||||||
|
priority: 'u=1, i',
|
||||||
|
referer: 'https://bitwarden.com/host/',
|
||||||
|
'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
|
||||||
|
'sec-ch-ua-mobile': '?0',
|
||||||
|
'sec-ch-ua-platform': '"Windows"',
|
||||||
|
'sec-fetch-dest': 'empty',
|
||||||
|
'sec-fetch-mode': 'cors',
|
||||||
|
'sec-fetch-site': 'same-site',
|
||||||
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
formName: 'request_host',
|
||||||
|
url: '/host/',
|
||||||
|
locale: 'zh-CN',
|
||||||
|
email: randomInstallationEmail(),
|
||||||
|
region: 'us',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'Failed to request Bitwarden push installation:'
|
||||||
|
);
|
||||||
|
if (!response) return null;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to request Bitwarden push installation:', response.status, await response.text().catch(() => ''));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json().catch(() => null)) as { id?: string; key?: string; enabled?: boolean } | null;
|
||||||
|
const id = String(body?.id || '').trim();
|
||||||
|
const key = String(body?.key || '').trim();
|
||||||
|
if (!id || !key) {
|
||||||
|
console.error('Bitwarden push installation response did not include id/key');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
saveConfigValue(db, PUSH_INSTALLATION_ID_KEY, id),
|
||||||
|
saveConfigValue(db, PUSH_INSTALLATION_KEY_KEY, key),
|
||||||
|
]);
|
||||||
|
return { id, key };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPushAccessToken(env: Env): Promise<string | null> {
|
||||||
|
const credentials = await ensurePushInstallationCredentials(env.DB);
|
||||||
|
if (!credentials) return null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (cachedPushAccessToken && cachedPushAccessToken.expiresAt > now + 30_000) {
|
||||||
|
return cachedPushAccessToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
scope: 'api.push',
|
||||||
|
client_id: `installation.${credentials.id}`,
|
||||||
|
client_secret: credentials.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetchPushEndpoint(
|
||||||
|
`${PUSH_IDENTITY_URI}/connect/token`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: params.toString(),
|
||||||
|
},
|
||||||
|
'Failed to get Bitwarden push relay token:'
|
||||||
|
);
|
||||||
|
if (!response) return null;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to get Bitwarden push relay token:', response.status, await response.text().catch(() => ''));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json().catch(() => null)) as { access_token?: string; expires_in?: number } | null;
|
||||||
|
const token = String(body?.access_token || '').trim();
|
||||||
|
if (!token) {
|
||||||
|
console.error('Bitwarden push relay token response did not include an access_token');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresInSeconds = Math.max(60, Number(body?.expires_in || 3600));
|
||||||
|
cachedPushAccessToken = {
|
||||||
|
token,
|
||||||
|
expiresAt: now + Math.floor(expiresInSeconds * 500),
|
||||||
|
};
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postToPushRelay(env: Env, path: string, body?: unknown): Promise<boolean> {
|
||||||
|
const token = await getPushAccessToken(env);
|
||||||
|
if (!token) return false;
|
||||||
|
|
||||||
|
const response = await fetchPushEndpoint(
|
||||||
|
`${PUSH_RELAY_URI}${path}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
},
|
||||||
|
`Bitwarden push relay request failed: ${path}`
|
||||||
|
);
|
||||||
|
if (!response) return false;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Bitwarden push relay request failed:', path, response.status, await response.text().catch(() => ''));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mobilePayloadFromSignalR(updateType: number, userId: string, revisionDate: string, payload: Record<string, unknown> | null | undefined): Record<string, unknown> {
|
||||||
|
const source = payload || {};
|
||||||
|
const id = source.Id ?? source.id;
|
||||||
|
const organizationId = source.OrganizationId ?? source.organizationId ?? null;
|
||||||
|
const collectionIds = source.CollectionIds ?? source.collectionIds ?? null;
|
||||||
|
|
||||||
|
if (id != null) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
userId: source.UserId ?? source.userId ?? userId,
|
||||||
|
organizationId,
|
||||||
|
collectionIds,
|
||||||
|
revisionDate: source.RevisionDate ?? source.revisionDate ?? revisionDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: source.UserId ?? source.userId ?? userId,
|
||||||
|
date: source.Date ?? source.date ?? revisionDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerMobilePushDevice(
|
||||||
|
env: Env,
|
||||||
|
input: {
|
||||||
|
userId: string;
|
||||||
|
deviceIdentifier: string;
|
||||||
|
type: number;
|
||||||
|
pushUuid: string;
|
||||||
|
pushToken: string;
|
||||||
|
}
|
||||||
|
): Promise<boolean> {
|
||||||
|
const credentials = await ensurePushInstallationCredentials(env.DB);
|
||||||
|
if (!credentials) return false;
|
||||||
|
|
||||||
|
return postToPushRelay(env, '/push/register', {
|
||||||
|
deviceId: input.pushUuid,
|
||||||
|
pushToken: input.pushToken,
|
||||||
|
userId: input.userId,
|
||||||
|
type: input.type,
|
||||||
|
identifier: input.deviceIdentifier,
|
||||||
|
installationId: credentials.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
|
||||||
|
const normalized = String(pushUuid || '').trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return postToPushRelay(env, `/push/delete/${encodeURIComponent(normalized)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyMobilePush(
|
||||||
|
env: Env,
|
||||||
|
input: {
|
||||||
|
userId: string;
|
||||||
|
updateType: number;
|
||||||
|
revisionDate: string;
|
||||||
|
contextId: string | null;
|
||||||
|
payload: Record<string, unknown> | null | undefined;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const hasPushDevice = await env.DB
|
||||||
|
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
|
||||||
|
.bind(input.userId, '')
|
||||||
|
.first<{ '1': number }>();
|
||||||
|
if (!hasPushDevice) return;
|
||||||
|
|
||||||
|
let actingPushUuid: string | null = null;
|
||||||
|
if (input.contextId) {
|
||||||
|
const row = await env.DB
|
||||||
|
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||||
|
.bind(input.userId, input.contextId)
|
||||||
|
.first<{ push_uuid: string | null }>();
|
||||||
|
actingPushUuid = row?.push_uuid ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await postToPushRelay(env, '/push/send', {
|
||||||
|
userId: input.userId,
|
||||||
|
organizationId: null,
|
||||||
|
deviceId: actingPushUuid,
|
||||||
|
identifier: input.contextId,
|
||||||
|
type: input.updateType,
|
||||||
|
payload: mobilePayloadFromSignalR(input.updateType, input.userId, input.revisionDate, input.payload),
|
||||||
|
clientType: null,
|
||||||
|
installationId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
|||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
|
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
|
||||||
deletedAt: row.deleted_at ?? null,
|
deletedAt: row.deleted_at ?? parsed.deletedAt ?? parsed.deletedDate ?? null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
console.error('Corrupted cipher data, id:', row.id);
|
console.error('Corrupted cipher data, id:', row.id);
|
||||||
@@ -244,7 +244,9 @@ export async function getCiphersPage(
|
|||||||
limit: number,
|
limit: number,
|
||||||
offset: number
|
offset: number
|
||||||
): Promise<Cipher[]> {
|
): Promise<Cipher[]> {
|
||||||
const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL';
|
const whereDeleted = includeDeleted
|
||||||
|
? ''
|
||||||
|
: "AND deleted_at IS NULL AND json_extract(data, '$.deletedAt') IS NULL AND json_extract(data, '$.deletedDate') IS NULL";
|
||||||
const res = await db
|
const res = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT ${selectCipherColumns()} FROM ciphers
|
`SELECT ${selectCipherColumns()} FROM ciphers
|
||||||
@@ -341,7 +343,10 @@ export async function bulkArchiveCiphers(
|
|||||||
`UPDATE ciphers
|
`UPDATE ciphers
|
||||||
SET archived_at = ?, updated_at = ?,
|
SET archived_at = ?, updated_at = ?,
|
||||||
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
|
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
|
||||||
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
|
WHERE user_id = ? AND id IN (${placeholders})
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND json_extract(data, '$.deletedAt') IS NULL
|
||||||
|
AND json_extract(data, '$.deletedDate') IS NULL`
|
||||||
)
|
)
|
||||||
.bind(now, now, userId, ...chunk)
|
.bind(now, now, userId, ...chunk)
|
||||||
.run();
|
.run();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Device, TrustedDeviceTokenSummary, User } from '../types';
|
import type { Device, TrustedDeviceTokenSummary, User } from '../types';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
|
||||||
type GetUserByEmail = (email: string) => Promise<User | null>;
|
type GetUserByEmail = (email: string) => Promise<User | null>;
|
||||||
type TrustedTokenKeyFn = (token: string) => Promise<string>;
|
type TrustedTokenKeyFn = (token: string) => Promise<string>;
|
||||||
@@ -14,6 +15,8 @@ function mapDeviceRow(row: any): Device {
|
|||||||
encryptedUserKey: row.encrypted_user_key ?? null,
|
encryptedUserKey: row.encrypted_user_key ?? null,
|
||||||
encryptedPublicKey: row.encrypted_public_key ?? null,
|
encryptedPublicKey: row.encrypted_public_key ?? null,
|
||||||
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
||||||
|
pushUuid: row.push_uuid ?? null,
|
||||||
|
pushToken: row.push_token ?? null,
|
||||||
lastSeenAt: row.last_seen_at ?? null,
|
lastSeenAt: row.last_seen_at ?? null,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
@@ -38,13 +41,15 @@ export async function upsertDevice(
|
|||||||
const existingDevice = await getDeviceById(userId, deviceIdentifier);
|
const existingDevice = await getDeviceById(userId, deviceIdentifier);
|
||||||
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
|
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
|
||||||
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
|
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
|
||||||
|
const effectivePushUuid = String(existingDevice?.pushUuid || '').trim() || generateUUID();
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
|
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
|
||||||
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
|
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
|
||||||
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
||||||
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
||||||
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
||||||
|
'push_uuid=COALESCE(push_uuid, excluded.push_uuid), ' +
|
||||||
'last_seen_at=excluded.last_seen_at, ' +
|
'last_seen_at=excluded.last_seen_at, ' +
|
||||||
'updated_at=excluded.updated_at'
|
'updated_at=excluded.updated_at'
|
||||||
)
|
)
|
||||||
@@ -57,6 +62,7 @@ export async function upsertDevice(
|
|||||||
keys?.encryptedUserKey ?? null,
|
keys?.encryptedUserKey ?? null,
|
||||||
keys?.encryptedPublicKey ?? null,
|
keys?.encryptedPublicKey ?? null,
|
||||||
keys?.encryptedPrivateKey ?? null,
|
keys?.encryptedPrivateKey ?? null,
|
||||||
|
effectivePushUuid,
|
||||||
existingDevice?.deviceNote ?? null,
|
existingDevice?.deviceNote ?? null,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
@@ -166,7 +172,7 @@ export async function isKnownDeviceByEmail(
|
|||||||
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
||||||
const res = await db
|
const res = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||||
'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
|
'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
|
||||||
)
|
)
|
||||||
.bind(userId)
|
.bind(userId)
|
||||||
@@ -177,7 +183,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
|
|||||||
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
|
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||||
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
||||||
)
|
)
|
||||||
.bind(userId, deviceIdentifier)
|
.bind(userId, deviceIdentifier)
|
||||||
@@ -185,6 +191,63 @@ export async function getDevice(db: D1Database, userId: string, deviceIdentifier
|
|||||||
return row ? mapDeviceRow(row) : null;
|
return row ? mapDeviceRow(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateDevicePushToken(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
pushUuid: string,
|
||||||
|
pushToken: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE devices SET push_uuid = ?, push_token = ?, updated_at = ? ' +
|
||||||
|
'WHERE user_id = ? AND device_identifier = ?'
|
||||||
|
)
|
||||||
|
.bind(pushUuid, pushToken, now, userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearDevicePushToken(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<{ pushUuid: string | null } | null> {
|
||||||
|
const existing = await db
|
||||||
|
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.first<{ push_uuid: string | null }>();
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE devices SET push_token = NULL, updated_at = ? WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
.bind(new Date().toISOString(), userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { pushUuid: existing.push_uuid ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDevicePushUuid(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.first<{ push_uuid: string | null }>();
|
||||||
|
return row?.push_uuid ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userHasPushDevice(db: D1Database, userId: string): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
|
||||||
|
.bind(userId, '')
|
||||||
|
.first<{ '1': number }>();
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||||
const result = await db
|
const result = await db
|
||||||
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
|
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)',
|
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS devices (' +
|
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
|
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, push_uuid TEXT, push_token TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
|
||||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
'PRIMARY KEY (user_id, device_identifier), ' +
|
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
@@ -103,11 +103,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
|
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
|
||||||
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
|
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
|
||||||
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
|
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN push_uuid TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN push_token TEXT',
|
||||||
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
||||||
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
||||||
'ALTER TABLE devices ADD COLUMN device_note TEXT',
|
'ALTER TABLE devices ADD COLUMN device_note TEXT',
|
||||||
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
|
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
|
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS auth_requests (' +
|
'CREATE TABLE IF NOT EXISTS auth_requests (' +
|
||||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
|
||||||
|
|||||||
+28
-1
@@ -1,5 +1,6 @@
|
|||||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential, AuthRequestRecord } from '../types';
|
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential, AuthRequestRecord } from '../types';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
import { ensurePushInstallationCredentials } from './push-relay';
|
||||||
import { ensureStorageSchema } from './storage-schema';
|
import { ensureStorageSchema } from './storage-schema';
|
||||||
import {
|
import {
|
||||||
getConfigValue as getStoredConfigValue,
|
getConfigValue as getStoredConfigValue,
|
||||||
@@ -87,10 +88,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
deleteDevice as deleteStoredDevice,
|
deleteDevice as deleteStoredDevice,
|
||||||
deleteDevicesByUserId as deleteStoredDevicesByUserId,
|
deleteDevicesByUserId as deleteStoredDevicesByUserId,
|
||||||
|
clearDevicePushToken as clearStoredDevicePushToken,
|
||||||
clearDeviceKeys as clearStoredDeviceKeys,
|
clearDeviceKeys as clearStoredDeviceKeys,
|
||||||
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
|
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
|
||||||
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
|
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
|
||||||
getDevice as findStoredDevice,
|
getDevice as findStoredDevice,
|
||||||
|
getDevicePushUuid as findStoredDevicePushUuid,
|
||||||
getDevicesByUserId as listStoredDevicesByUserId,
|
getDevicesByUserId as listStoredDevicesByUserId,
|
||||||
getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries,
|
getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries,
|
||||||
getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId,
|
getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId,
|
||||||
@@ -101,7 +104,9 @@ import {
|
|||||||
upsertDevice as saveStoredDevice,
|
upsertDevice as saveStoredDevice,
|
||||||
updateDeviceName as updateStoredDeviceName,
|
updateDeviceName as updateStoredDeviceName,
|
||||||
updateDeviceKeys as updateStoredDeviceKeys,
|
updateDeviceKeys as updateStoredDeviceKeys,
|
||||||
|
updateDevicePushToken as updateStoredDevicePushToken,
|
||||||
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
|
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
|
||||||
|
userHasPushDevice as getUserHasPushDevice,
|
||||||
} from './storage-device-repo';
|
} from './storage-device-repo';
|
||||||
import {
|
import {
|
||||||
createAuthRequest as createStoredAuthRequest,
|
createAuthRequest as createStoredAuthRequest,
|
||||||
@@ -143,7 +148,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
|||||||
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||||
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||||
// differs from config.schema.version.
|
// differs from config.schema.version.
|
||||||
const STORAGE_SCHEMA_VERSION = '2026-06-12-auth-requests';
|
const STORAGE_SCHEMA_VERSION = '2026-06-22-push-notifications';
|
||||||
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const;
|
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const;
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
@@ -235,6 +240,7 @@ export class StorageService {
|
|||||||
await ensureStorageSchema(this.db);
|
await ensureStorageSchema(this.db);
|
||||||
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
|
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
|
||||||
}
|
}
|
||||||
|
await ensurePushInstallationCredentials(this.db);
|
||||||
|
|
||||||
StorageService.schemaVerified = true;
|
StorageService.schemaVerified = true;
|
||||||
}
|
}
|
||||||
@@ -713,6 +719,27 @@ export class StorageService {
|
|||||||
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
|
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateDevicePushToken(
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
pushUuid: string,
|
||||||
|
pushToken: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
return updateStoredDevicePushToken(this.db, userId, deviceIdentifier, pushUuid, pushToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearDevicePushToken(userId: string, deviceIdentifier: string): Promise<{ pushUuid: string | null } | null> {
|
||||||
|
return clearStoredDevicePushToken(this.db, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDevicePushUuid(userId: string, deviceIdentifier: string): Promise<string | null> {
|
||||||
|
return findStoredDevicePushUuid(this.db, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
async userHasPushDevice(userId: string): Promise<boolean> {
|
||||||
|
return getUserHasPushDevice(this.db, userId);
|
||||||
|
}
|
||||||
|
|
||||||
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
|
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
|
||||||
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -231,6 +231,8 @@ export interface Device {
|
|||||||
encryptedUserKey: string | null;
|
encryptedUserKey: string | null;
|
||||||
encryptedPublicKey: string | null;
|
encryptedPublicKey: string | null;
|
||||||
encryptedPrivateKey: string | null;
|
encryptedPrivateKey: string | null;
|
||||||
|
pushUuid: string | null;
|
||||||
|
pushToken: string | null;
|
||||||
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
|
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
|
||||||
lastSeenAt: string | null;
|
lastSeenAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -465,7 +467,15 @@ export interface TokenResponse {
|
|||||||
scope: string;
|
scope: string;
|
||||||
unofficialServer: boolean;
|
unofficialServer: boolean;
|
||||||
MasterPasswordPolicy?: {
|
MasterPasswordPolicy?: {
|
||||||
|
minComplexity: number;
|
||||||
|
minLength: number;
|
||||||
|
requireUpper: boolean;
|
||||||
|
requireLower: boolean;
|
||||||
|
requireNumbers: boolean;
|
||||||
|
requireSpecial: boolean;
|
||||||
|
enforceOnLogin: boolean;
|
||||||
Object: string;
|
Object: string;
|
||||||
|
object?: string;
|
||||||
} | null;
|
} | null;
|
||||||
ApiUseKeyConnector?: boolean;
|
ApiUseKeyConnector?: boolean;
|
||||||
AccountKeys?: any | null;
|
AccountKeys?: any | null;
|
||||||
@@ -494,12 +504,13 @@ export interface ProfileResponse {
|
|||||||
accountKeys: any | null;
|
accountKeys: any | null;
|
||||||
securityStamp: string;
|
securityStamp: string;
|
||||||
organizations: any[];
|
organizations: any[];
|
||||||
|
organizationsNew?: any[];
|
||||||
providers: any[];
|
providers: any[];
|
||||||
providerOrganizations: any[];
|
providerOrganizations: any[];
|
||||||
forcePasswordReset: boolean;
|
forcePasswordReset: boolean;
|
||||||
avatarColor: string | null;
|
avatarColor: string | null;
|
||||||
creationDate: string;
|
creationDate: string;
|
||||||
verifyDevices?: boolean;
|
verifyDevices: boolean;
|
||||||
role?: UserRole;
|
role?: UserRole;
|
||||||
status?: UserStatus;
|
status?: UserStatus;
|
||||||
object: string;
|
object: string;
|
||||||
@@ -558,6 +569,7 @@ export interface SyncResponse {
|
|||||||
ciphers: CipherResponse[];
|
ciphers: CipherResponse[];
|
||||||
domains: any;
|
domains: any;
|
||||||
policies: any[];
|
policies: any[];
|
||||||
|
policiesNew?: any[];
|
||||||
sends: SendResponse[];
|
sends: SendResponse[];
|
||||||
UserDecryption?: {
|
UserDecryption?: {
|
||||||
MasterPasswordUnlock: MasterPasswordUnlock | null;
|
MasterPasswordUnlock: MasterPasswordUnlock | null;
|
||||||
@@ -565,6 +577,10 @@ export interface SyncResponse {
|
|||||||
KeyConnectorOption?: null;
|
KeyConnectorOption?: null;
|
||||||
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
|
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
|
||||||
WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[];
|
WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[];
|
||||||
|
V2UpgradeToken?: {
|
||||||
|
WrappedUserKey1: string;
|
||||||
|
WrappedUserKey2: string;
|
||||||
|
} | null;
|
||||||
Object?: string;
|
Object?: string;
|
||||||
} | null;
|
} | null;
|
||||||
// PascalCase for desktop/browser clients
|
// PascalCase for desktop/browser clients
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Env, ProfileResponse, User } from '../types';
|
||||||
|
import { buildAccountKeys } from './user-decryption';
|
||||||
|
|
||||||
|
export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
|
||||||
|
void env;
|
||||||
|
const organizations: any[] = [];
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
emailVerified: true,
|
||||||
|
premium: true,
|
||||||
|
premiumFromOrganization: false,
|
||||||
|
usesKeyConnector: false,
|
||||||
|
masterPasswordHint: user.masterPasswordHint,
|
||||||
|
culture: 'en-US',
|
||||||
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
|
key: user.key,
|
||||||
|
privateKey: user.privateKey,
|
||||||
|
accountKeys,
|
||||||
|
securityStamp: user.securityStamp || user.id,
|
||||||
|
organizations,
|
||||||
|
organizationsNew: organizations,
|
||||||
|
providers: [],
|
||||||
|
providerOrganizations: [],
|
||||||
|
forcePasswordReset: false,
|
||||||
|
avatarColor: null,
|
||||||
|
creationDate: user.createdAt,
|
||||||
|
verifyDevices: user.verifyDevices !== false,
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
object: 'profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>):
|
|||||||
publicKeyEncryptionKeyPair: {
|
publicKeyEncryptionKeyPair: {
|
||||||
wrappedPrivateKey: user.privateKey,
|
wrappedPrivateKey: user.privateKey,
|
||||||
publicKey,
|
publicKey,
|
||||||
|
signedPublicKey: null,
|
||||||
Object: 'publicKeyEncryptionKeyPair',
|
Object: 'publicKeyEncryptionKeyPair',
|
||||||
},
|
},
|
||||||
Object: 'privateKeys',
|
Object: 'privateKeys',
|
||||||
|
|||||||
+287
-17
@@ -20,6 +20,7 @@ import {
|
|||||||
saveProfileSnapshot,
|
saveProfileSnapshot,
|
||||||
revokeCurrentSession,
|
revokeCurrentSession,
|
||||||
getTotpStatus,
|
getTotpStatus,
|
||||||
|
getVaultRevisionDate,
|
||||||
saveSession,
|
saveSession,
|
||||||
stripProfileSecrets,
|
stripProfileSecrets,
|
||||||
} from '@/lib/api/auth';
|
} from '@/lib/api/auth';
|
||||||
@@ -31,9 +32,9 @@ import {
|
|||||||
} from '@/lib/api/auth-requests';
|
} from '@/lib/api/auth-requests';
|
||||||
import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin';
|
import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin';
|
||||||
import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
|
import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
|
||||||
import { getSends } from '@/lib/api/send';
|
import { getSendById, getSends } from '@/lib/api/send';
|
||||||
import { repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault';
|
import { getCipherById, getFolderById, repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault';
|
||||||
import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
|
import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot, saveVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
|
||||||
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
||||||
import {
|
import {
|
||||||
parseSignalRTextFrames,
|
parseSignalRTextFrames,
|
||||||
@@ -134,10 +135,22 @@ function normalizeRoutePath(path: string): string {
|
|||||||
}
|
}
|
||||||
const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1';
|
const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1';
|
||||||
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
|
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE = 0;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE = 1;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE = 3;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_CIPHERS = 4;
|
||||||
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE = 7;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE = 8;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE = 9;
|
||||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
const SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE = 12;
|
||||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
const SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE = 13;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14;
|
||||||
|
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
|
||||||
|
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
|
||||||
|
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101;
|
||||||
|
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
|
||||||
|
|
||||||
type ThemePreference = 'system' | 'light' | 'dark';
|
type ThemePreference = 'system' | 'light' | 'dark';
|
||||||
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
|
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
|
||||||
@@ -249,6 +262,7 @@ export default function App() {
|
|||||||
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
||||||
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
||||||
|
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const repairAttemptRef = useRef<string>('');
|
const repairAttemptRef = useRef<string>('');
|
||||||
const uriChecksumRepairAttemptRef = useRef<string>('');
|
const uriChecksumRepairAttemptRef = useRef<string>('');
|
||||||
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
||||||
@@ -491,7 +505,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [phase, session?.email, location, navigate]);
|
}, [phase, session?.email, location, navigate]);
|
||||||
|
|
||||||
async function finalizeLogin(login: CompletedLogin, successMessage = t('txt_login_success')) {
|
async function finalizeLogin(login: CompletedLogin) {
|
||||||
setSession(login.session);
|
setSession(login.session);
|
||||||
setProfile(login.profile);
|
setProfile(login.profile);
|
||||||
setUnlockPreparing(false);
|
setUnlockPreparing(false);
|
||||||
@@ -505,7 +519,6 @@ export default function App() {
|
|||||||
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
|
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
|
||||||
navigate('/vault');
|
navigate('/vault');
|
||||||
}
|
}
|
||||||
pushToast('success', successMessage);
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const hydratedProfile = await login.profilePromise;
|
const hydratedProfile = await login.profilePromise;
|
||||||
@@ -522,7 +535,7 @@ export default function App() {
|
|||||||
if (IS_DEMO_MODE) {
|
if (IS_DEMO_MODE) {
|
||||||
setPendingAuthAction('login');
|
setPendingAuthAction('login');
|
||||||
try {
|
try {
|
||||||
await finalizeLogin(createDemoCompletedLogin(loginValues.email), t('txt_login_success'));
|
await finalizeLogin(createDemoCompletedLogin(loginValues.email));
|
||||||
} finally {
|
} finally {
|
||||||
setPendingAuthAction(null);
|
setPendingAuthAction(null);
|
||||||
}
|
}
|
||||||
@@ -594,7 +607,7 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const result = await performPasskeyLogin(defaultKdfIterations, expectedEmail);
|
const result = await performPasskeyLogin(defaultKdfIterations, expectedEmail);
|
||||||
if (result.kind === 'success') {
|
if (result.kind === 'success') {
|
||||||
await finalizeLogin(result.login, t('txt_unlocked'));
|
await finalizeLogin(result.login);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.kind === 'password') {
|
if (result.kind === 'password') {
|
||||||
@@ -636,7 +649,7 @@ export default function App() {
|
|||||||
setTotpSubmitting(true);
|
setTotpSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||||
await finalizeLogin(login, pendingTotpMode === 'unlock' ? t('txt_unlocked') : t('txt_login_success'));
|
await finalizeLogin(login);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -776,7 +789,7 @@ export default function App() {
|
|||||||
if (IS_DEMO_MODE) {
|
if (IS_DEMO_MODE) {
|
||||||
setPendingAuthAction('unlock');
|
setPendingAuthAction('unlock');
|
||||||
try {
|
try {
|
||||||
await finalizeLogin(createDemoCompletedLogin(session.email), t('txt_unlocked'));
|
await finalizeLogin(createDemoCompletedLogin(session.email));
|
||||||
} finally {
|
} finally {
|
||||||
setPendingAuthAction(null);
|
setPendingAuthAction(null);
|
||||||
}
|
}
|
||||||
@@ -790,7 +803,7 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const result = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
|
const result = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
|
||||||
if (result.kind === 'success') {
|
if (result.kind === 'success') {
|
||||||
await finalizeLogin(result.login, t('txt_unlocked'));
|
await finalizeLogin(result.login);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.kind === 'totp') {
|
if (result.kind === 'totp') {
|
||||||
@@ -1072,8 +1085,9 @@ export default function App() {
|
|||||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
|
||||||
const pendingAuthRequestsQuery = useQuery({
|
const pendingAuthRequestsQuery = useQuery({
|
||||||
queryKey: ['auth-requests-pending', vaultCacheKey || session?.email],
|
queryKey: pendingAuthRequestsQueryKey,
|
||||||
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
|
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
|
||||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
@@ -1327,6 +1341,193 @@ export default function App() {
|
|||||||
|
|
||||||
silentRefreshVaultRef.current = refreshVaultSilently;
|
silentRefreshVaultRef.current = refreshVaultSilently;
|
||||||
|
|
||||||
|
function normalizeVaultCoreSnapshot(snapshot?: Partial<VaultCoreSnapshot> | null): VaultCoreSnapshot {
|
||||||
|
return {
|
||||||
|
ciphers: Array.isArray(snapshot?.ciphers) ? snapshot.ciphers : [],
|
||||||
|
folders: Array.isArray(snapshot?.folders) ? snapshot.folders : [],
|
||||||
|
sends: Array.isArray(snapshot?.sends) ? snapshot.sends : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertById<T extends { id: string }>(items: T[], nextItem: T): T[] {
|
||||||
|
const nextId = String(nextItem.id || '').trim();
|
||||||
|
if (!nextId) return items;
|
||||||
|
const index = items.findIndex((item) => String(item.id || '').trim() === nextId);
|
||||||
|
if (index < 0) return [...items, nextItem];
|
||||||
|
const next = items.slice();
|
||||||
|
next[index] = nextItem;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeById<T extends { id: string }>(items: T[], id: string): T[] {
|
||||||
|
const normalizedId = String(id || '').trim();
|
||||||
|
if (!normalizedId) return items;
|
||||||
|
return items.filter((item) => String(item.id || '').trim() !== normalizedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revisionStampFromIso(value: unknown): number | null {
|
||||||
|
const stamp = new Date(String(value || '').trim()).getTime();
|
||||||
|
return Number.isFinite(stamp) && stamp > 0 ? stamp : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchVaultCoreSnapshot(
|
||||||
|
updater: (snapshot: VaultCoreSnapshot) => VaultCoreSnapshot,
|
||||||
|
options?: { revisionStamp?: number | null }
|
||||||
|
): void {
|
||||||
|
if (!vaultCacheKey) return;
|
||||||
|
let nextSnapshot: VaultCoreSnapshot | null = null;
|
||||||
|
queryClient.setQueryData(['vault-core', vaultCacheKey], (previous?: VaultCoreSnapshot) => {
|
||||||
|
const base = normalizeVaultCoreSnapshot(previous || cachedVaultCore);
|
||||||
|
nextSnapshot = updater(base);
|
||||||
|
return nextSnapshot;
|
||||||
|
});
|
||||||
|
if (nextSnapshot) {
|
||||||
|
setCachedVaultCore(nextSnapshot);
|
||||||
|
void saveVaultCoreSyncSnapshot(vaultCacheKey, nextSnapshot, options?.revisionStamp ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshVaultCoreRevisionStamp(): Promise<void> {
|
||||||
|
if (!vaultCacheKey || !session?.accessToken) return;
|
||||||
|
try {
|
||||||
|
const revisionStamp = await getVaultRevisionDate(authedFetch);
|
||||||
|
const currentSnapshot = normalizeVaultCoreSnapshot(
|
||||||
|
queryClient.getQueryData<VaultCoreSnapshot>(['vault-core', vaultCacheKey]) || cachedVaultCore
|
||||||
|
);
|
||||||
|
await saveVaultCoreSyncSnapshot(vaultCacheKey, currentSnapshot, revisionStamp);
|
||||||
|
} catch {
|
||||||
|
// A stale revision stamp only affects the next cache validation; the local resource patch remains valid.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertEncryptedCipher(cipher: Cipher, revisionStamp?: number | null): void {
|
||||||
|
patchVaultCoreSnapshot((snapshot) => ({
|
||||||
|
...snapshot,
|
||||||
|
ciphers: upsertById(snapshot.ciphers, cipher),
|
||||||
|
}), { revisionStamp: revisionStamp ?? revisionStampFromIso(cipher.revisionDate) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCipherLocally(cipherId: string, revisionStamp?: number | null): void {
|
||||||
|
const id = String(cipherId || '').trim();
|
||||||
|
if (!id) return;
|
||||||
|
patchVaultCoreSnapshot((snapshot) => ({
|
||||||
|
...snapshot,
|
||||||
|
ciphers: removeById(snapshot.ciphers, id),
|
||||||
|
}), { revisionStamp });
|
||||||
|
setDecryptedCiphers((current) => removeById(current, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertEncryptedFolder(folder: VaultFolder, revisionStamp?: number | null): void {
|
||||||
|
patchVaultCoreSnapshot((snapshot) => ({
|
||||||
|
...snapshot,
|
||||||
|
folders: upsertById(snapshot.folders, folder),
|
||||||
|
}), { revisionStamp: revisionStamp ?? revisionStampFromIso(folder.revisionDate) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteFolderLocally(folderId: string, revisionStamp?: number | null): void {
|
||||||
|
const id = String(folderId || '').trim();
|
||||||
|
if (!id) return;
|
||||||
|
patchVaultCoreSnapshot((snapshot) => ({
|
||||||
|
...snapshot,
|
||||||
|
folders: removeById(snapshot.folders, id),
|
||||||
|
ciphers: snapshot.ciphers.map((cipher) => (
|
||||||
|
String(cipher.folderId || '').trim() === id ? { ...cipher, folderId: null } : cipher
|
||||||
|
)),
|
||||||
|
}), { revisionStamp });
|
||||||
|
setDecryptedFolders((current) => removeById(current, id));
|
||||||
|
setDecryptedCiphers((current) => current.map((cipher) => (
|
||||||
|
String(cipher.folderId || '').trim() === id ? { ...cipher, folderId: null } : cipher
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertEncryptedSend(send: Send, revisionStamp?: number | null): void {
|
||||||
|
patchVaultCoreSnapshot((snapshot) => ({
|
||||||
|
...snapshot,
|
||||||
|
sends: upsertById(snapshot.sends, send),
|
||||||
|
}), { revisionStamp: revisionStamp ?? revisionStampFromIso(send.revisionDate) });
|
||||||
|
queryClient.setQueryData(sendsQueryKey, (previous?: Send[]) => upsertById(Array.isArray(previous) ? previous : [], send));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSendLocally(sendId: string, revisionStamp?: number | null): void {
|
||||||
|
const id = String(sendId || '').trim();
|
||||||
|
if (!id) return;
|
||||||
|
patchVaultCoreSnapshot((snapshot) => ({
|
||||||
|
...snapshot,
|
||||||
|
sends: removeById(snapshot.sends, id),
|
||||||
|
}), { revisionStamp });
|
||||||
|
queryClient.setQueryData(sendsQueryKey, (previous?: Send[]) => removeById(Array.isArray(previous) ? previous : [], id));
|
||||||
|
setDecryptedSends((current) => removeById(current, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertCipherFromNotification(cipherId: string, revisionStamp?: number | null): Promise<void> {
|
||||||
|
const id = String(cipherId || '').trim();
|
||||||
|
if (!id || !session?.symEncKey || !session?.symMacKey) return;
|
||||||
|
try {
|
||||||
|
const encrypted = await getCipherById(authedFetch, id);
|
||||||
|
upsertEncryptedCipher(encrypted, revisionStamp);
|
||||||
|
const result = await decryptVaultCore({
|
||||||
|
folders: [],
|
||||||
|
ciphers: [encrypted],
|
||||||
|
symEncKeyB64: session.symEncKey,
|
||||||
|
symMacKeyB64: session.symMacKey,
|
||||||
|
});
|
||||||
|
const decrypted = result.ciphers[0];
|
||||||
|
if (decrypted) setDecryptedCiphers((current) => upsertById(current, decrypted));
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { status?: number }).status === 404) {
|
||||||
|
deleteCipherLocally(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn('Failed to upsert cipher from notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertFolderFromNotification(folderId: string, revisionStamp?: number | null): Promise<void> {
|
||||||
|
const id = String(folderId || '').trim();
|
||||||
|
if (!id || !session?.symEncKey || !session?.symMacKey) return;
|
||||||
|
try {
|
||||||
|
const encrypted = await getFolderById(authedFetch, id);
|
||||||
|
upsertEncryptedFolder(encrypted, revisionStamp);
|
||||||
|
const result = await decryptVaultCore({
|
||||||
|
folders: [encrypted],
|
||||||
|
ciphers: [],
|
||||||
|
symEncKeyB64: session.symEncKey,
|
||||||
|
symMacKeyB64: session.symMacKey,
|
||||||
|
});
|
||||||
|
const decrypted = result.folders[0];
|
||||||
|
if (decrypted) setDecryptedFolders((current) => upsertById(current, decrypted));
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { status?: number }).status === 404) {
|
||||||
|
deleteFolderLocally(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn('Failed to upsert folder from notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertSendFromNotification(sendId: string, revisionStamp?: number | null): Promise<void> {
|
||||||
|
const id = String(sendId || '').trim();
|
||||||
|
if (!id || !session?.symEncKey || !session?.symMacKey) return;
|
||||||
|
try {
|
||||||
|
const encrypted = await getSendById(authedFetch, id);
|
||||||
|
upsertEncryptedSend(encrypted, revisionStamp);
|
||||||
|
const sends = await decryptSends({
|
||||||
|
sends: [encrypted],
|
||||||
|
symEncKeyB64: session.symEncKey,
|
||||||
|
symMacKeyB64: session.symMacKey,
|
||||||
|
origin: window.location.origin,
|
||||||
|
});
|
||||||
|
const decrypted = sends[0];
|
||||||
|
if (decrypted) setDecryptedSends((current) => upsertById(current, decrypted));
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { status?: number }).status === 404) {
|
||||||
|
deleteSendLocally(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn('Failed to upsert send from notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (IS_DEMO_MODE) return;
|
if (IS_DEMO_MODE) return;
|
||||||
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey || !vaultInitialDecryptDone) return;
|
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey || !vaultInitialDecryptDone) return;
|
||||||
@@ -1403,7 +1604,18 @@ export default function App() {
|
|||||||
const frames = parseSignalRTextFrames(event.data);
|
const frames = parseSignalRTextFrames(event.data);
|
||||||
for (const frame of frames) {
|
for (const frame of frames) {
|
||||||
if (frame.type !== 1 || frame.target !== 'ReceiveMessage') continue;
|
if (frame.type !== 1 || frame.target !== 'ReceiveMessage') continue;
|
||||||
const updateType = Number(frame.arguments?.[0]?.Type || 0);
|
const message = frame.arguments?.[0] as Record<string, unknown> | undefined;
|
||||||
|
const updateType = Number(message?.Type || 0);
|
||||||
|
const contextId = String(message?.ContextId || '').trim();
|
||||||
|
const payload = message?.Payload;
|
||||||
|
const payloadRecord = payload && typeof payload === 'object' ? payload as Record<string, unknown> : null;
|
||||||
|
const resourceId = String(payloadRecord?.Id || payloadRecord?.id || '').trim();
|
||||||
|
const revisionStamp = revisionStampFromIso(
|
||||||
|
payloadRecord?.RevisionDate
|
||||||
|
|| payloadRecord?.revisionDate
|
||||||
|
|| message?.Date
|
||||||
|
|| message?.date
|
||||||
|
);
|
||||||
if (updateType === SIGNALR_UPDATE_TYPE_LOG_OUT) {
|
if (updateType === SIGNALR_UPDATE_TYPE_LOG_OUT) {
|
||||||
logoutNow();
|
logoutNow();
|
||||||
return;
|
return;
|
||||||
@@ -1412,14 +1624,16 @@ export default function App() {
|
|||||||
void refreshAuthorizedDevicesRef.current();
|
void refreshAuthorizedDevicesRef.current();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (updateType === SIGNALR_UPDATE_TYPE_AUTH_REQUEST || updateType === SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE) {
|
||||||
|
void refreshPendingAuthRequestsRef.current();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
|
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
|
||||||
const payload = frame.arguments?.[0]?.Payload;
|
|
||||||
if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload);
|
if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
|
||||||
const contextId = String(frame.arguments?.[0]?.ContextId || '').trim();
|
|
||||||
if (contextId && contextId === getCurrentDeviceIdentifier()) continue;
|
if (contextId && contextId === getCurrentDeviceIdentifier()) continue;
|
||||||
|
if (updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHERS || updateType === SIGNALR_UPDATE_TYPE_SYNC_VAULT) {
|
||||||
if (notificationRefreshTimerRef.current !== null) {
|
if (notificationRefreshTimerRef.current !== null) {
|
||||||
window.clearTimeout(notificationRefreshTimerRef.current);
|
window.clearTimeout(notificationRefreshTimerRef.current);
|
||||||
}
|
}
|
||||||
@@ -1427,6 +1641,32 @@ export default function App() {
|
|||||||
notificationRefreshTimerRef.current = null;
|
notificationRefreshTimerRef.current = null;
|
||||||
void silentRefreshVaultRef.current();
|
void silentRefreshVaultRef.current();
|
||||||
}, 250);
|
}, 250);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE) && resourceId) {
|
||||||
|
void upsertCipherFromNotification(resourceId, revisionStamp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE && resourceId) {
|
||||||
|
deleteCipherLocally(resourceId, revisionStamp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE) && resourceId) {
|
||||||
|
void upsertFolderFromNotification(resourceId, revisionStamp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE && resourceId) {
|
||||||
|
deleteFolderLocally(resourceId, revisionStamp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE) && resourceId) {
|
||||||
|
void upsertSendFromNotification(resourceId, revisionStamp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE && resourceId) {
|
||||||
|
deleteSendLocally(resourceId, revisionStamp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1485,8 +1725,33 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
refetchSends: refetchSendsFromVaultCore,
|
refetchSends: refetchSendsFromVaultCore,
|
||||||
onNotify: pushToast,
|
onNotify: pushToast,
|
||||||
|
patchEncryptedCiphers: (updater) => {
|
||||||
|
patchVaultCoreSnapshot((snapshot) => ({
|
||||||
|
...snapshot,
|
||||||
|
ciphers: updater(snapshot.ciphers),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
patchEncryptedFolders: (updater) => {
|
||||||
|
patchVaultCoreSnapshot((snapshot) => ({
|
||||||
|
...snapshot,
|
||||||
|
folders: updater(snapshot.folders),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
patchEncryptedSends: (updater) => {
|
||||||
|
let nextSends: Send[] = [];
|
||||||
|
patchVaultCoreSnapshot((snapshot) => {
|
||||||
|
nextSends = updater(snapshot.sends);
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
sends: nextSends,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
queryClient.setQueryData(sendsQueryKey, nextSends);
|
||||||
|
},
|
||||||
patchDecryptedCiphers: setDecryptedCiphers,
|
patchDecryptedCiphers: setDecryptedCiphers,
|
||||||
patchDecryptedFolders: setDecryptedFolders,
|
patchDecryptedFolders: setDecryptedFolders,
|
||||||
|
patchDecryptedSends: setDecryptedSends,
|
||||||
|
refreshVaultRevisionStamp: refreshVaultCoreRevisionStamp,
|
||||||
});
|
});
|
||||||
const accountSecurityActions = useAccountSecurityActions({
|
const accountSecurityActions = useAccountSecurityActions({
|
||||||
authedFetch,
|
authedFetch,
|
||||||
@@ -1517,6 +1782,11 @@ export default function App() {
|
|||||||
if (!vaultInitialDecryptDone) return;
|
if (!vaultInitialDecryptDone) return;
|
||||||
await authorizedDevicesQuery.refetch();
|
await authorizedDevicesQuery.refetch();
|
||||||
};
|
};
|
||||||
|
refreshPendingAuthRequestsRef.current = async () => {
|
||||||
|
if (!vaultInitialDecryptDone || !(profile?.email || session?.email)) return;
|
||||||
|
setAuthRequestDialogDismissedId(null);
|
||||||
|
await pendingAuthRequestsQuery.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
||||||
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface ThemeSwitchProps {
|
|||||||
export default function ThemeSwitch(props: ThemeSwitchProps) {
|
export default function ThemeSwitch(props: ThemeSwitchProps) {
|
||||||
return (
|
return (
|
||||||
<div className="theme-switch-wrap" title={props.title}>
|
<div className="theme-switch-wrap" title={props.title}>
|
||||||
<label className="theme-switch" aria-label={props.title}>
|
<label className={`theme-switch ${props.checked ? 'checked' : 'unchecked'}`} aria-label={props.title}>
|
||||||
<span className="sun" aria-hidden="true">
|
<span className="sun" aria-hidden="true">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
<g fill="#ffd43b">
|
<g fill="#ffd43b">
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
downloadCipherAttachmentDecrypted,
|
downloadCipherAttachmentDecrypted,
|
||||||
encryptFolderImportName,
|
encryptFolderImportName,
|
||||||
getAttachmentDownloadInfo,
|
getAttachmentDownloadInfo,
|
||||||
|
getCipherById,
|
||||||
importCiphers,
|
importCiphers,
|
||||||
permanentDeleteCipher,
|
permanentDeleteCipher,
|
||||||
type CiphersImportPayload,
|
type CiphersImportPayload,
|
||||||
@@ -69,8 +70,13 @@ interface UseVaultSendActionsOptions {
|
|||||||
refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>;
|
refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>;
|
||||||
refetchSends: () => Promise<unknown>;
|
refetchSends: () => Promise<unknown>;
|
||||||
onNotify: Notify;
|
onNotify: Notify;
|
||||||
|
patchEncryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void;
|
||||||
|
patchEncryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void;
|
||||||
|
patchEncryptedSends: (updater: (prev: Send[]) => Send[]) => void;
|
||||||
patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void;
|
patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void;
|
||||||
patchDecryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void;
|
patchDecryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void;
|
||||||
|
patchDecryptedSends: (updater: (prev: Send[]) => Send[]) => void;
|
||||||
|
refreshVaultRevisionStamp: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
|
function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
|
||||||
@@ -288,8 +294,13 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
refetchFolders,
|
refetchFolders,
|
||||||
refetchSends,
|
refetchSends,
|
||||||
onNotify,
|
onNotify,
|
||||||
|
patchEncryptedCiphers,
|
||||||
|
patchEncryptedFolders,
|
||||||
|
patchEncryptedSends,
|
||||||
patchDecryptedCiphers,
|
patchDecryptedCiphers,
|
||||||
patchDecryptedFolders,
|
patchDecryptedFolders,
|
||||||
|
patchDecryptedSends,
|
||||||
|
refreshVaultRevisionStamp,
|
||||||
} = options;
|
} = options;
|
||||||
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState('');
|
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState('');
|
||||||
const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null);
|
const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null);
|
||||||
@@ -308,21 +319,20 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
throw new Error(t('txt_offline_vault_readonly'));
|
throw new Error(t('txt_offline_vault_readonly'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => {
|
|
||||||
const tasks: Promise<unknown>[] = [Promise.resolve(refetchCiphers())];
|
|
||||||
if (options?.includeFolders) {
|
|
||||||
tasks.push(Promise.resolve(refetchFolders()));
|
|
||||||
}
|
|
||||||
void Promise.all(tasks).catch((err) => {
|
|
||||||
console.warn('Background vault sync failed:', err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
async function decryptAndPatch(encrypted: Cipher) {
|
async function decryptAndPatch(encrypted: Cipher) {
|
||||||
if (!session?.symEncKey || !session?.symMacKey) {
|
if (!session?.symEncKey || !session?.symMacKey) {
|
||||||
await refetchCiphers();
|
await refetchCiphers();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
patchEncryptedCiphers((prev) => {
|
||||||
|
const idx = prev.findIndex((c) => c.id === encrypted.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = encrypted;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return [encrypted, ...prev];
|
||||||
|
});
|
||||||
const encKey = base64ToBytes(session.symEncKey);
|
const encKey = base64ToBytes(session.symEncKey);
|
||||||
const macKey = base64ToBytes(session.symMacKey);
|
const macKey = base64ToBytes(session.symMacKey);
|
||||||
const decrypted = await decryptSingleCipher(encrypted, encKey, macKey);
|
const decrypted = await decryptSingleCipher(encrypted, encKey, macKey);
|
||||||
@@ -342,6 +352,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
await refetchCiphers();
|
await refetchCiphers();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
patchEncryptedCiphers((prev) => [encrypted, ...prev.filter((cipher) => cipher.id !== optimisticId && cipher.id !== encrypted.id)]);
|
||||||
const encKey = base64ToBytes(session.symEncKey);
|
const encKey = base64ToBytes(session.symEncKey);
|
||||||
const macKey = base64ToBytes(session.symMacKey);
|
const macKey = base64ToBytes(session.symMacKey);
|
||||||
const decrypted = await decryptSingleCipher(encrypted, encKey, macKey);
|
const decrypted = await decryptSingleCipher(encrypted, encKey, macKey);
|
||||||
@@ -352,12 +363,36 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeCipherFromState(id: string) {
|
function removeCipherFromState(id: string) {
|
||||||
|
patchEncryptedCiphers((prev) => prev.filter((c) => c.id !== id));
|
||||||
patchDecryptedCiphers((prev) => prev.filter((c) => c.id !== id));
|
patchDecryptedCiphers((prev) => prev.filter((c) => c.id !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchCipherBatch(ids: string[], updater: (cipher: Cipher) => Cipher | null) {
|
function patchCipherBatch(
|
||||||
|
ids: string[],
|
||||||
|
updater: (cipher: Cipher) => Cipher | null,
|
||||||
|
options?: { patchEncrypted?: boolean; patchDecrypted?: boolean }
|
||||||
|
) {
|
||||||
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
|
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
|
||||||
if (!idSet.size) return;
|
if (!idSet.size) return;
|
||||||
|
const shouldPatchEncrypted = options?.patchEncrypted !== false;
|
||||||
|
const shouldPatchDecrypted = options?.patchDecrypted !== false;
|
||||||
|
if (shouldPatchEncrypted) {
|
||||||
|
patchEncryptedCiphers((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next: Cipher[] = [];
|
||||||
|
for (const cipher of prev) {
|
||||||
|
if (!idSet.has(cipher.id)) {
|
||||||
|
next.push(cipher);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const updated = updater(cipher);
|
||||||
|
changed = true;
|
||||||
|
if (updated) next.push(updated);
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (shouldPatchDecrypted) {
|
||||||
patchDecryptedCiphers((prev) => {
|
patchDecryptedCiphers((prev) => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const next: Cipher[] = [];
|
const next: Cipher[] = [];
|
||||||
@@ -373,10 +408,25 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
return changed ? next : prev;
|
return changed ? next : prev;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function patchFolderBatch(ids: string[], updater: (folder: VaultFolder) => VaultFolder | null) {
|
function patchFolderBatch(ids: string[], updater: (folder: VaultFolder) => VaultFolder | null) {
|
||||||
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
|
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
|
||||||
if (!idSet.size) return;
|
if (!idSet.size) return;
|
||||||
|
patchEncryptedFolders((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next: VaultFolder[] = [];
|
||||||
|
for (const folder of prev) {
|
||||||
|
if (!idSet.has(folder.id)) {
|
||||||
|
next.push(folder);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const updated = updater(folder);
|
||||||
|
changed = true;
|
||||||
|
if (updated) next.push(updated);
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
patchDecryptedFolders((prev) => {
|
patchDecryptedFolders((prev) => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const next: VaultFolder[] = [];
|
const next: VaultFolder[] = [];
|
||||||
@@ -393,6 +443,31 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upsertEncryptedFolder(folder: VaultFolder) {
|
||||||
|
patchEncryptedFolders((prev) => {
|
||||||
|
const index = prev.findIndex((item) => item.id === folder.id);
|
||||||
|
if (index < 0) return [folder, ...prev];
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = folder;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertSend(send: Send) {
|
||||||
|
patchEncryptedSends((prev) => {
|
||||||
|
const index = prev.findIndex((item) => item.id === send.id);
|
||||||
|
if (index < 0) return [send, ...prev];
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = send;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSend(id: string) {
|
||||||
|
patchEncryptedSends((prev) => prev.filter((send) => send.id !== id));
|
||||||
|
patchDecryptedSends((prev) => prev.filter((send) => send.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
const uploadImportedAttachments = async (
|
const uploadImportedAttachments = async (
|
||||||
attachments: ImportAttachmentFile[],
|
attachments: ImportAttachmentFile[],
|
||||||
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
|
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
|
||||||
@@ -468,8 +543,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
setAttachmentUploadPercent(0);
|
setAttachmentUploadPercent(0);
|
||||||
await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent);
|
await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent);
|
||||||
}
|
}
|
||||||
await decryptAndReplaceOptimistic(optimistic.id, created);
|
const finalCipher = attachments.length ? await getCipherById(authedFetch, created.id) : created;
|
||||||
syncVaultCoreInBackground({ includeFolders: !!draft.folderId || attachments.length > 0 });
|
await decryptAndReplaceOptimistic(optimistic.id, finalCipher);
|
||||||
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_item_created'));
|
onNotify('success', t('txt_item_created'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
patchDecryptedCiphers((prev) => prev.filter((cipher) => cipher.id !== optimistic.id));
|
patchDecryptedCiphers((prev) => prev.filter((cipher) => cipher.id !== optimistic.id));
|
||||||
@@ -511,7 +587,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
.filter((attachment) => !removedSet.has(String(attachment?.id || '').trim()))
|
.filter((attachment) => !removedSet.has(String(attachment?.id || '').trim()))
|
||||||
.map((attachment) => ({ ...attachment }));
|
.map((attachment) => ({ ...attachment }));
|
||||||
}
|
}
|
||||||
patchCipherBatch([cipher.id], () => optimistic);
|
patchCipherBatch([cipher.id], () => optimistic, { patchEncrypted: false });
|
||||||
try {
|
try {
|
||||||
const updated = await updateCipher(authedFetch, session, cipher, draft);
|
const updated = await updateCipher(authedFetch, session, cipher, draft);
|
||||||
for (const attachmentId of removeAttachmentIds) {
|
for (const attachmentId of removeAttachmentIds) {
|
||||||
@@ -524,16 +600,14 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
setAttachmentUploadPercent(0);
|
setAttachmentUploadPercent(0);
|
||||||
await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent);
|
await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent);
|
||||||
}
|
}
|
||||||
await decryptAndPatch(updated);
|
const finalCipher = addFiles.length || removeAttachmentIds.length
|
||||||
syncVaultCoreInBackground({
|
? await getCipherById(authedFetch, cipher.id)
|
||||||
includeFolders:
|
: updated;
|
||||||
draft.folderId !== (cipher.folderId || '')
|
await decryptAndPatch(finalCipher);
|
||||||
|| addFiles.length > 0
|
void refreshVaultRevisionStamp();
|
||||||
|| removeAttachmentIds.length > 0,
|
|
||||||
});
|
|
||||||
onNotify('success', t('txt_item_updated'));
|
onNotify('success', t('txt_item_updated'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
patchCipherBatch([cipher.id], () => previousCipher);
|
patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false });
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -572,7 +646,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
try {
|
try {
|
||||||
await permanentDeleteCipher(authedFetch, cipher.id);
|
await permanentDeleteCipher(authedFetch, cipher.id);
|
||||||
patchCipherBatch([cipher.id], () => null);
|
patchCipherBatch([cipher.id], () => null);
|
||||||
syncVaultCoreInBackground({ includeFolders: true });
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_item_deleted_permanently'));
|
onNotify('success', t('txt_item_deleted_permanently'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_permanent_delete_item_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_permanent_delete_item_failed'));
|
||||||
@@ -585,10 +659,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
try {
|
try {
|
||||||
const deleted = await deleteCipher(authedFetch, cipher.id);
|
const deleted = await deleteCipher(authedFetch, cipher.id);
|
||||||
await decryptAndPatch(deleted);
|
await decryptAndPatch(deleted);
|
||||||
syncVaultCoreInBackground({ includeFolders: true });
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_item_deleted'));
|
onNotify('success', t('txt_item_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
patchCipherBatch([cipher.id], () => previousCipher);
|
patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false });
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -607,10 +681,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
try {
|
try {
|
||||||
const archived = await archiveCipher(authedFetch, cipher.id);
|
const archived = await archiveCipher(authedFetch, cipher.id);
|
||||||
await decryptAndPatch(archived);
|
await decryptAndPatch(archived);
|
||||||
syncVaultCoreInBackground({ includeFolders: true });
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_item_archived'));
|
onNotify('success', t('txt_item_archived'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
patchCipherBatch([cipher.id], () => previousCipher);
|
patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false });
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed'));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -629,10 +703,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
try {
|
try {
|
||||||
const unarchived = await unarchiveCipher(authedFetch, cipher.id);
|
const unarchived = await unarchiveCipher(authedFetch, cipher.id);
|
||||||
await decryptAndPatch(unarchived);
|
await decryptAndPatch(unarchived);
|
||||||
syncVaultCoreInBackground({ includeFolders: true });
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_item_unarchived'));
|
onNotify('success', t('txt_item_unarchived'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
patchCipherBatch([cipher.id], () => previousCipher);
|
patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false });
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed'));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -649,7 +723,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
await bulkDeleteCiphers(authedFetch, ids);
|
await bulkDeleteCiphers(authedFetch, ids);
|
||||||
const deletedDate = new Date().toISOString();
|
const deletedDate = new Date().toISOString();
|
||||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate, archivedDate: null }));
|
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate, archivedDate: null }));
|
||||||
syncVaultCoreInBackground({ includeFolders: true });
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_deleted_selected_items'));
|
onNotify('success', t('txt_deleted_selected_items'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed'));
|
||||||
@@ -668,7 +742,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
await bulkArchiveCiphers(authedFetch, ids);
|
await bulkArchiveCiphers(authedFetch, ids);
|
||||||
const archivedDate = new Date().toISOString();
|
const archivedDate = new Date().toISOString();
|
||||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate, deletedDate: null }));
|
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate, deletedDate: null }));
|
||||||
syncVaultCoreInBackground({ includeFolders: true });
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_archived_selected_items'));
|
onNotify('success', t('txt_archived_selected_items'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed'));
|
||||||
@@ -686,7 +760,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
try {
|
try {
|
||||||
await bulkUnarchiveCiphers(authedFetch, ids);
|
await bulkUnarchiveCiphers(authedFetch, ids);
|
||||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null }));
|
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null }));
|
||||||
syncVaultCoreInBackground({ includeFolders: true });
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_unarchived_selected_items'));
|
onNotify('success', t('txt_unarchived_selected_items'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed'));
|
||||||
@@ -704,7 +778,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
try {
|
try {
|
||||||
await bulkMoveCiphers(authedFetch, ids, folderId);
|
await bulkMoveCiphers(authedFetch, ids, folderId);
|
||||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId }));
|
patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId }));
|
||||||
syncVaultCoreInBackground({ includeFolders: true });
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_moved_selected_items'));
|
onNotify('success', t('txt_moved_selected_items'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed'));
|
||||||
@@ -727,15 +801,18 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
try {
|
try {
|
||||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
const created = await createFolder(authedFetch, session, folderName);
|
const created = await createFolder(authedFetch, session, folderName);
|
||||||
|
upsertEncryptedFolder(created);
|
||||||
patchDecryptedFolders((prev) => [
|
patchDecryptedFolders((prev) => [
|
||||||
{
|
{
|
||||||
id: created.id,
|
id: created.id,
|
||||||
name: created.name || folderName,
|
name: created.name || folderName,
|
||||||
decName: folderName,
|
decName: folderName,
|
||||||
|
revisionDate: created.revisionDate,
|
||||||
|
creationDate: created.creationDate,
|
||||||
},
|
},
|
||||||
...prev,
|
...prev,
|
||||||
]);
|
]);
|
||||||
syncVaultCoreInBackground({ includeFolders: true });
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_folder_created'));
|
onNotify('success', t('txt_folder_created'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed'));
|
||||||
@@ -758,8 +835,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
try {
|
try {
|
||||||
await deleteFolder(authedFetch, id);
|
await deleteFolder(authedFetch, id);
|
||||||
patchFolderBatch([id], () => null);
|
patchFolderBatch([id], () => null);
|
||||||
|
patchEncryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId === id ? { ...cipher, folderId: null } : cipher)));
|
||||||
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId === id ? { ...cipher, folderId: null } : cipher)));
|
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId === id ? { ...cipher, folderId: null } : cipher)));
|
||||||
syncVaultCoreInBackground({ includeFolders: true });
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_folder_deleted'));
|
onNotify('success', t('txt_folder_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed'));
|
||||||
@@ -786,9 +864,14 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
await updateFolder(authedFetch, session, id, nextName);
|
const updated = await updateFolder(authedFetch, session, id, nextName);
|
||||||
patchFolderBatch([id], (folder) => ({ ...folder, decName: nextName }));
|
upsertEncryptedFolder(updated);
|
||||||
syncVaultCoreInBackground({ includeFolders: true });
|
patchDecryptedFolders((prev) => prev.map((folder) => (
|
||||||
|
folder.id === id
|
||||||
|
? { ...folder, name: updated.name || folder.name, decName: nextName, revisionDate: updated.revisionDate }
|
||||||
|
: folder
|
||||||
|
)));
|
||||||
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_folder_updated'));
|
onNotify('success', t('txt_folder_updated'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed'));
|
||||||
@@ -806,7 +889,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
try {
|
try {
|
||||||
await bulkRestoreCiphers(authedFetch, ids);
|
await bulkRestoreCiphers(authedFetch, ids);
|
||||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null }));
|
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null }));
|
||||||
syncVaultCoreInBackground({ includeFolders: true });
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_restored_selected_items'));
|
onNotify('success', t('txt_restored_selected_items'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed'));
|
||||||
@@ -824,7 +907,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
try {
|
try {
|
||||||
await bulkPermanentDeleteCiphers(authedFetch, ids);
|
await bulkPermanentDeleteCiphers(authedFetch, ids);
|
||||||
patchCipherBatch(ids, () => null);
|
patchCipherBatch(ids, () => null);
|
||||||
syncVaultCoreInBackground({ includeFolders: true });
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_deleted_selected_items_permanently'));
|
onNotify('success', t('txt_deleted_selected_items_permanently'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed'));
|
||||||
@@ -844,9 +927,11 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
try {
|
try {
|
||||||
await bulkDeleteFolders(authedFetch, ids);
|
await bulkDeleteFolders(authedFetch, ids);
|
||||||
const removedIds = new Set(ids);
|
const removedIds = new Set(ids);
|
||||||
|
patchEncryptedFolders((prev) => prev.filter((folder) => !removedIds.has(folder.id)));
|
||||||
|
patchEncryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId && removedIds.has(cipher.folderId) ? { ...cipher, folderId: null } : cipher)));
|
||||||
patchDecryptedFolders((prev) => prev.filter((folder) => !removedIds.has(folder.id)));
|
patchDecryptedFolders((prev) => prev.filter((folder) => !removedIds.has(folder.id)));
|
||||||
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId && removedIds.has(cipher.folderId) ? { ...cipher, folderId: null } : cipher)));
|
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId && removedIds.has(cipher.folderId) ? { ...cipher, folderId: null } : cipher)));
|
||||||
syncVaultCoreInBackground({ includeFolders: true });
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_folders_deleted'));
|
onNotify('success', t('txt_folders_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed'));
|
||||||
@@ -874,7 +959,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
setSendUploadPercent(0);
|
setSendUploadPercent(0);
|
||||||
}
|
}
|
||||||
const created = await createSend(authedFetch, session, draft, fileName ? setSendUploadPercent : undefined);
|
const created = await createSend(authedFetch, session, draft, fileName ? setSendUploadPercent : undefined);
|
||||||
await refetchSends();
|
upsertSend(created);
|
||||||
|
void refreshVaultRevisionStamp();
|
||||||
if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) {
|
if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) {
|
||||||
const keyPart = await buildSendShareKey(created.key, session.symEncKey, session.symMacKey);
|
const keyPart = await buildSendShareKey(created.key, session.symEncKey, session.symMacKey);
|
||||||
const shareUrl = buildPublicSendUrl(window.location.origin, created.accessId, keyPart);
|
const shareUrl = buildPublicSendUrl(window.location.origin, created.accessId, keyPart);
|
||||||
@@ -900,7 +986,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const updated = await updateSend(authedFetch, session, send, draft);
|
const updated = await updateSend(authedFetch, session, send, draft);
|
||||||
await refetchSends();
|
upsertSend(updated);
|
||||||
|
void refreshVaultRevisionStamp();
|
||||||
if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) {
|
if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) {
|
||||||
const keyPart = await buildSendShareKey(updated.key, session.symEncKey, session.symMacKey);
|
const keyPart = await buildSendShareKey(updated.key, session.symEncKey, session.symMacKey);
|
||||||
const shareUrl = buildPublicSendUrl(window.location.origin, updated.accessId, keyPart);
|
const shareUrl = buildPublicSendUrl(window.location.origin, updated.accessId, keyPart);
|
||||||
@@ -922,7 +1009,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await deleteSend(authedFetch, send.id);
|
await deleteSend(authedFetch, send.id);
|
||||||
await refetchSends();
|
removeSend(send.id);
|
||||||
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_send_deleted'));
|
onNotify('success', t('txt_send_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_send_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_delete_send_failed'));
|
||||||
@@ -939,7 +1027,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await bulkDeleteSends(authedFetch, ids);
|
await bulkDeleteSends(authedFetch, ids);
|
||||||
await refetchSends();
|
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
|
||||||
|
patchEncryptedSends((prev) => prev.filter((send) => !idSet.has(send.id)));
|
||||||
|
patchDecryptedSends((prev) => prev.filter((send) => !idSet.has(send.id)));
|
||||||
|
void refreshVaultRevisionStamp();
|
||||||
onNotify('success', t('txt_deleted_selected_sends'));
|
onNotify('success', t('txt_deleted_selected_sends'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_sends_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_sends_failed'));
|
||||||
@@ -1299,10 +1390,17 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
encryptedFolders,
|
encryptedFolders,
|
||||||
importAuthedFetch,
|
importAuthedFetch,
|
||||||
onNotify,
|
onNotify,
|
||||||
|
patchDecryptedCiphers,
|
||||||
|
patchDecryptedFolders,
|
||||||
|
patchDecryptedSends,
|
||||||
|
patchEncryptedCiphers,
|
||||||
|
patchEncryptedFolders,
|
||||||
|
patchEncryptedSends,
|
||||||
profile,
|
profile,
|
||||||
refetchCiphers,
|
refetchCiphers,
|
||||||
refetchFolders,
|
refetchFolders,
|
||||||
refetchSends,
|
refetchSends,
|
||||||
|
refreshVaultRevisionStamp,
|
||||||
session,
|
session,
|
||||||
sendUploadPercent,
|
sendUploadPercent,
|
||||||
uploadingAttachmentName,
|
uploadingAttachmentName,
|
||||||
|
|||||||
@@ -500,7 +500,6 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
if (!session?.accessToken) throw new Error(t('txt_offline_vault_readonly'));
|
if (!session?.accessToken) throw new Error(t('txt_offline_vault_readonly'));
|
||||||
const headers = new Headers(init.headers || {});
|
const headers = new Headers(init.headers || {});
|
||||||
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||||
headers.set('X-NodeWarden-Web', '1');
|
|
||||||
|
|
||||||
let resp = await retryableRequest(headers);
|
let resp = await retryableRequest(headers);
|
||||||
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
||||||
@@ -509,7 +508,6 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
if (latest?.accessToken && latest.accessToken !== session.accessToken) {
|
if (latest?.accessToken && latest.accessToken !== session.accessToken) {
|
||||||
const latestHeaders = new Headers(init.headers || {});
|
const latestHeaders = new Headers(init.headers || {});
|
||||||
latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`);
|
latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`);
|
||||||
latestHeaders.set('X-NodeWarden-Web', '1');
|
|
||||||
resp = await retryableRequest(latestHeaders);
|
resp = await retryableRequest(latestHeaders);
|
||||||
if (resp.status !== 401) return resp;
|
if (resp.status !== 401) return resp;
|
||||||
}
|
}
|
||||||
@@ -535,7 +533,6 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
|
|
||||||
const retryHeaders = new Headers(init.headers || {});
|
const retryHeaders = new Headers(init.headers || {});
|
||||||
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
|
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
|
||||||
retryHeaders.set('X-NodeWarden-Web', '1');
|
|
||||||
resp = await retryableRequest(retryHeaders);
|
resp = await retryableRequest(retryHeaders);
|
||||||
return resp;
|
return resp;
|
||||||
};
|
};
|
||||||
@@ -599,14 +596,35 @@ export async function changeMasterPassword(
|
|||||||
const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32);
|
const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32);
|
||||||
const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32);
|
const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32);
|
||||||
const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac);
|
const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac);
|
||||||
|
const newMasterPasswordHash = bytesToBase64(nextHash);
|
||||||
|
|
||||||
const resp = await authedFetch('/api/accounts/password', {
|
const resp = await authedFetch('/api/accounts/password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
currentPasswordHash: current.hash,
|
masterPasswordHash: current.hash,
|
||||||
newMasterPasswordHash: bytesToBase64(nextHash),
|
newMasterPasswordHash,
|
||||||
newKey,
|
key: newKey,
|
||||||
|
authenticationData: {
|
||||||
|
kdf: {
|
||||||
|
kdfType: 0,
|
||||||
|
iterations: current.kdfIterations,
|
||||||
|
memory: null,
|
||||||
|
parallelism: null,
|
||||||
|
},
|
||||||
|
masterPasswordAuthenticationHash: newMasterPasswordHash,
|
||||||
|
salt: args.email.trim().toLowerCase(),
|
||||||
|
},
|
||||||
|
unlockData: {
|
||||||
|
kdf: {
|
||||||
|
kdfType: 0,
|
||||||
|
iterations: current.kdfIterations,
|
||||||
|
memory: null,
|
||||||
|
parallelism: null,
|
||||||
|
},
|
||||||
|
masterKeyWrappedUserKey: newKey,
|
||||||
|
salt: args.email.trim().toLowerCase(),
|
||||||
|
},
|
||||||
kdf: 0,
|
kdf: 0,
|
||||||
kdfIterations: current.kdfIterations,
|
kdfIterations: current.kdfIterations,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -67,6 +67,17 @@ export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
|
|||||||
return body?.data || [];
|
return body?.data || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSendById(authedFetch: AuthedFetch, sendId: string): Promise<Send> {
|
||||||
|
const id = String(sendId || '').trim();
|
||||||
|
if (!id) throw new Error('Send id is required');
|
||||||
|
const resp = await authedFetch(`/api/sends/${encodeURIComponent(id)}`);
|
||||||
|
if (resp.status === 404) throw createApiError('Send not found', 404);
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Load send failed'));
|
||||||
|
const body = await parseJson<Send>(resp);
|
||||||
|
if (!body?.id) throw new Error('Load send failed');
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSend(
|
export async function createSend(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
session: SessionState,
|
session: SessionState,
|
||||||
|
|||||||
@@ -51,6 +51,29 @@ export async function invalidateVaultCoreSyncSnapshot(cacheKey: string): Promise
|
|||||||
await clearCachedVaultCoreSnapshot(normalizedKey);
|
await clearCachedVaultCoreSnapshot(normalizedKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveVaultCoreSyncSnapshot(
|
||||||
|
cacheKey: string,
|
||||||
|
snapshot: VaultCoreSnapshot,
|
||||||
|
revisionStamp?: number | null
|
||||||
|
): Promise<void> {
|
||||||
|
const normalizedKey = String(cacheKey || '').trim();
|
||||||
|
if (!normalizedKey) return;
|
||||||
|
|
||||||
|
const normalizedSnapshot = normalizeCachedSnapshot(snapshot);
|
||||||
|
const currentMemory = memoryVaultCoreCache.get(normalizedKey);
|
||||||
|
let nextRevisionStamp = Number(revisionStamp);
|
||||||
|
if (!Number.isFinite(nextRevisionStamp) || nextRevisionStamp <= 0) {
|
||||||
|
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
||||||
|
nextRevisionStamp = currentMemory?.revisionStamp || cached?.revisionStamp || Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryVaultCoreCache.set(normalizedKey, {
|
||||||
|
revisionStamp: nextRevisionStamp,
|
||||||
|
snapshot: normalizedSnapshot,
|
||||||
|
});
|
||||||
|
await saveCachedVaultCoreSnapshot(normalizedKey, nextRevisionStamp, normalizedSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
|
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
|
||||||
const normalizedKey = String(cacheKey || '').trim();
|
const normalizedKey = String(cacheKey || '').trim();
|
||||||
if (!normalizedKey) return { ciphers: [], folders: [], sends: [] };
|
if (!normalizedKey) return { ciphers: [], folders: [], sends: [] };
|
||||||
|
|||||||
+46
-10
@@ -10,6 +10,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
BULK_API_CHUNK_SIZE,
|
BULK_API_CHUNK_SIZE,
|
||||||
chunkArray,
|
chunkArray,
|
||||||
|
createApiError,
|
||||||
parseErrorMessage,
|
parseErrorMessage,
|
||||||
parseJson,
|
parseJson,
|
||||||
uploadDirectEncryptedPayload,
|
uploadDirectEncryptedPayload,
|
||||||
@@ -20,17 +21,29 @@ import { readResponseBytesWithProgress } from '../download';
|
|||||||
import { loadVaultCoreSyncSnapshot } from './vault-sync';
|
import { loadVaultCoreSyncSnapshot } from './vault-sync';
|
||||||
|
|
||||||
type CipherLoginData = NonNullable<Cipher['login']>;
|
type CipherLoginData = NonNullable<Cipher['login']>;
|
||||||
|
const NODEWARDEN_WEB_REPAIR_HEADER = 'X-NodeWarden-Web';
|
||||||
|
|
||||||
export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Promise<Folder[]> {
|
export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Promise<Folder[]> {
|
||||||
const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey);
|
const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey);
|
||||||
return body.folders || [];
|
return body.folders || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getFolderById(authedFetch: AuthedFetch, folderId: string): Promise<Folder> {
|
||||||
|
const id = String(folderId || '').trim();
|
||||||
|
if (!id) throw new Error('Folder id is required');
|
||||||
|
const resp = await authedFetch(`/api/folders/${encodeURIComponent(id)}`);
|
||||||
|
if (resp.status === 404) throw createApiError('Folder not found', 404);
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Load folder failed'));
|
||||||
|
const body = await parseJson<Folder>(resp);
|
||||||
|
if (!body?.id) throw new Error('Load folder failed');
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createFolder(
|
export async function createFolder(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
session: SessionState,
|
session: SessionState,
|
||||||
name: string
|
name: string
|
||||||
): Promise<{ id: string; name?: string | null }> {
|
): Promise<Folder> {
|
||||||
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||||
const enc = base64ToBytes(session.symEncKey);
|
const enc = base64ToBytes(session.symEncKey);
|
||||||
const mac = base64ToBytes(session.symMacKey);
|
const mac = base64ToBytes(session.symMacKey);
|
||||||
@@ -41,9 +54,9 @@ export async function createFolder(
|
|||||||
body: JSON.stringify({ name: encryptedName }),
|
body: JSON.stringify({ name: encryptedName }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error('Create folder failed');
|
if (!resp.ok) throw new Error('Create folder failed');
|
||||||
const body = await parseJson<{ id?: string; name?: string | null }>(resp);
|
const body = await parseJson<Folder>(resp);
|
||||||
if (!body?.id) throw new Error('Create folder failed');
|
if (!body?.id) throw new Error('Create folder failed');
|
||||||
return { id: body.id, name: body.name ?? null };
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function encryptFolderImportName(session: SessionState, name: string): Promise<string> {
|
export async function encryptFolderImportName(session: SessionState, name: string): Promise<string> {
|
||||||
@@ -79,7 +92,7 @@ export async function updateFolder(
|
|||||||
session: SessionState,
|
session: SessionState,
|
||||||
folderId: string,
|
folderId: string,
|
||||||
name: string
|
name: string
|
||||||
): Promise<void> {
|
): Promise<Folder> {
|
||||||
const id = String(folderId || '').trim();
|
const id = String(folderId || '').trim();
|
||||||
if (!id) throw new Error('Folder id is required');
|
if (!id) throw new Error('Folder id is required');
|
||||||
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||||
@@ -92,6 +105,9 @@ export async function updateFolder(
|
|||||||
body: JSON.stringify({ name: encryptedName }),
|
body: JSON.stringify({ name: encryptedName }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error('Update folder failed');
|
if (!resp.ok) throw new Error('Update folder failed');
|
||||||
|
const body = await parseJson<Folder>(resp);
|
||||||
|
if (!body?.id) throw new Error('Update folder failed');
|
||||||
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCiphers(authedFetch: AuthedFetch, cacheKey: string): Promise<Cipher[]> {
|
export async function getCiphers(authedFetch: AuthedFetch, cacheKey: string): Promise<Cipher[]> {
|
||||||
@@ -99,6 +115,17 @@ export async function getCiphers(authedFetch: AuthedFetch, cacheKey: string): Pr
|
|||||||
return body.ciphers || [];
|
return body.ciphers || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCipherById(authedFetch: AuthedFetch, cipherId: string): Promise<Cipher> {
|
||||||
|
const id = String(cipherId || '').trim();
|
||||||
|
if (!id) throw new Error('Cipher id is required');
|
||||||
|
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}`);
|
||||||
|
if (resp.status === 404) throw createApiError('Cipher not found', 404);
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Load cipher failed'));
|
||||||
|
const body = await parseJson<Cipher>(resp);
|
||||||
|
if (!body?.id) throw new Error('Load cipher failed');
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CiphersImportPayload {
|
export interface CiphersImportPayload {
|
||||||
ciphers: Array<Record<string, unknown>>;
|
ciphers: Array<Record<string, unknown>>;
|
||||||
folders: Array<{ name: string }>;
|
folders: Array<{ name: string }>;
|
||||||
@@ -933,7 +960,7 @@ export async function repairCipherUriChecksums(
|
|||||||
|
|
||||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
|
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', [NODEWARDEN_WEB_REPAIR_HEADER]: '1' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Repair URI checksum failed'));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Repair URI checksum failed'));
|
||||||
@@ -1092,9 +1119,14 @@ export async function repairCipherKeyMismatches(
|
|||||||
if (!cipher?.id || !looksLikeCipherString(cipher.key)) continue;
|
if (!cipher?.id || !looksLikeCipherString(cipher.key)) continue;
|
||||||
if (!(await hasItemKeyFieldMismatch(cipher, userEnc, userMac))) continue;
|
if (!(await hasItemKeyFieldMismatch(cipher, userEnc, userMac))) continue;
|
||||||
if (hasUnresolvedEncryptedFields(cipher)) continue;
|
if (hasUnresolvedEncryptedFields(cipher)) continue;
|
||||||
await updateCipher(authedFetch, session, cipher, draftFromDecryptedCipher(cipher), {
|
await updateCipher(
|
||||||
preserveRevisionDate: true,
|
authedFetch,
|
||||||
});
|
session,
|
||||||
|
cipher,
|
||||||
|
draftFromDecryptedCipher(cipher),
|
||||||
|
{ preserveRevisionDate: true },
|
||||||
|
{ webRepair: true }
|
||||||
|
);
|
||||||
repaired += 1;
|
repaired += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1229,7 +1261,8 @@ export async function updateCipher(
|
|||||||
session: SessionState,
|
session: SessionState,
|
||||||
cipher: Cipher,
|
cipher: Cipher,
|
||||||
draft: VaultDraft,
|
draft: VaultDraft,
|
||||||
extraPayload?: Record<string, unknown>
|
extraPayload?: Record<string, unknown>,
|
||||||
|
options?: { webRepair?: boolean }
|
||||||
): Promise<Cipher> {
|
): Promise<Cipher> {
|
||||||
const payload = await buildCipherPayload(session, draft, cipher);
|
const payload = await buildCipherPayload(session, draft, cipher);
|
||||||
if (extraPayload) {
|
if (extraPayload) {
|
||||||
@@ -1238,7 +1271,10 @@ export async function updateCipher(
|
|||||||
|
|
||||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
|
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options?.webRepair ? { [NODEWARDEN_WEB_REPAIR_HEADER]: '1' } : {}),
|
||||||
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error('Update item failed');
|
if (!resp.ok) throw new Error('Update item failed');
|
||||||
|
|||||||
@@ -133,7 +133,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topbar-actions > .network-status-badge {
|
.topbar-actions > .network-status-badge {
|
||||||
@apply h-8 px-2 text-[0];
|
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 rounded-xl p-0 text-[0];
|
||||||
|
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||||
|
border-color: var(--line);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-actions > .network-status-badge svg {
|
.topbar-actions > .network-status-badge svg {
|
||||||
@@ -142,7 +145,10 @@
|
|||||||
|
|
||||||
.mobile-sidebar-toggle,
|
.mobile-sidebar-toggle,
|
||||||
.mobile-lock-btn {
|
.mobile-lock-btn {
|
||||||
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 p-0 text-[0];
|
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 rounded-xl p-0 text-[0];
|
||||||
|
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||||
|
border-color: var(--line);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-sidebar-toggle .btn-icon,
|
.mobile-sidebar-toggle .btn-icon,
|
||||||
@@ -155,10 +161,46 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-theme-btn .theme-switch {
|
.mobile-theme-btn .theme-switch {
|
||||||
transform: scale(0.8);
|
@apply h-9 w-9 rounded-xl border;
|
||||||
|
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||||
|
border-color: var(--line);
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-theme-btn .theme-switch-slider,
|
||||||
|
.mobile-theme-btn .theme-switch-slider::before {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-theme-btn .theme-switch .sun svg,
|
||||||
|
.mobile-theme-btn .theme-switch .moon svg {
|
||||||
|
@apply h-[18px] w-[18px];
|
||||||
|
top: 7px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-theme-btn .theme-switch.unchecked .sun svg,
|
||||||
|
.mobile-theme-btn .theme-switch.checked .moon svg {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-theme-btn .theme-switch.checked .moon svg {
|
||||||
|
fill: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions > .network-status-badge:hover,
|
||||||
|
.mobile-sidebar-toggle:hover,
|
||||||
|
.mobile-lock-btn:hover,
|
||||||
|
.mobile-theme-btn .theme-switch:hover {
|
||||||
|
background: var(--panel-subtle);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 28%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
@apply flex min-h-0 flex-col;
|
@apply flex min-h-0 flex-col;
|
||||||
}
|
}
|
||||||
@@ -352,11 +394,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sort-trigger.sort-trigger-labeled {
|
.sort-trigger.sort-trigger-labeled {
|
||||||
@apply h-[34px] w-[34px] min-w-[34px] gap-0 px-0 text-[0];
|
@apply h-[34px] w-auto min-w-0 gap-1.5 px-3 text-[13px];
|
||||||
}
|
}
|
||||||
|
|
||||||
.sort-trigger.sort-trigger-labeled .btn-icon {
|
.sort-trigger.sort-trigger-labeled .btn-icon {
|
||||||
@apply m-0;
|
@apply mr-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-create-menu-wrap {
|
.desktop-create-menu-wrap {
|
||||||
|
|||||||
@@ -256,6 +256,20 @@ select.input.duplicate-mode-toolbar-select {
|
|||||||
@apply h-[34px] whitespace-nowrap rounded-[10px] px-3 py-0 text-[13px];
|
@apply h-[34px] whitespace-nowrap rounded-[10px] px-3 py-0 text-[13px];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-head .btn,
|
||||||
|
.list-head .search-input,
|
||||||
|
.list-head .sort-trigger,
|
||||||
|
.list-head .list-icon-btn {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head .btn:hover:not(:disabled),
|
||||||
|
.list-head .btn:active:not(:disabled) {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-vault-filter-row {
|
.mobile-vault-filter-row {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user