Compare commits

..
42 Commits
Author SHA1 Message Date
dependabot[bot]andGitHub a21cc81da0 chore(deps-dev): bump typescript from 6.0.3 to 7.0.2
Bumps [typescript](https://github.com/microsoft/TypeScript) from 6.0.3 to 7.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/commits)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 21:05:34 +00:00
shuaiplus b731a014f1 fix(auth): prevent unexpected session logout 2026-07-13 02:11:12 +08:00
shuaiplus e25ec159bb feat: enhance navigation layout and improve mobile settings UI 2026-07-12 23:16:01 +08:00
shuaiplus b093c01fd7 chore: update version to 1.7.4 in package.json, package-lock.json, and app-version.ts 2026-07-12 22:13:33 +08:00
shuaiplus fa611dc843 fix: require master password for admin and wipe-device actions
Gate invite management, user ban/delete, and delete-all-devices behind
masterPasswordHash verification, matching backup step-up auth. The web UI
prompts for the master password in the shared confirm dialog.
2026-07-12 20:43:27 +08:00
shuaiplus 3c581d1fb1 fix: block IPv6 loopback in backup destination URL checks
Expand compressed IPv6 hostnames before the private-address allowlist so
forms like ::1 cannot bypass SSRF protection for WebDAV/S3 backup endpoints.
Also reject IPv4-mapped addresses written as ::ffff:hex:hex.
2026-07-12 20:21:45 +08:00
shuaiplus fb376797d2 feat: update PasswordGeneratorPage styles and improve layout responsiveness 2026-07-12 01:59:15 +08:00
shuaiplus 99b50275a6 feat: add Password Security feature with scanning and reporting capabilities 2026-07-12 01:50:21 +08:00
shuaiplus dfc98008cb Add password generator feature and update localization files 2026-07-11 18:49:26 +08:00
shuaiplus b472121f43 feat: add contributors section and star history chart to README files 2026-07-11 17:25:53 +08:00
DomainmasteriandGitHub 9caa064488 Add German (de), French (fr), Italian (it), and Swedish (sv) initial translations (#303) 2026-07-11 17:06:41 +08:00
shuaiplus aae614a079 feat: add offline mode notice and related styles to enhance user experience during offline access 2026-07-10 22:24:06 +08:00
shuaiplus 0e46cd371f Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-10 14:24:15 +08:00
shuaiplus db31792cef feat: add fill-assist, assetlinks check, and web-bootstrap to worker-handled paths 2026-07-10 14:23:45 +08:00
shuaiplus 8c65cb2e80 feat: update FIDO2 origins and enable direct unlock for account passkeys 2026-07-10 14:22:24 +08:00
shuaiplus 14dff8ee6a feat: disable new-device verification and update related logic across services 2026-07-10 13:00:32 +08:00
dependabot[bot]andShuai 8d399f431b chore(deps-dev): bump vite from 7.3.5 to 8.1.3
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.5 to 8.1.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.1.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 23:53:06 +08:00
shuaiplus bb3f866220 feat: add audit log demo data and passkey/API stub overrides 2026-07-09 20:51:12 +08:00
shuaiplusandShuai 39d9df78ea feat: add functionality to select unique items from duplicates in Vault components 2026-07-09 20:21:45 +08:00
rootphantomerandShuai a1b12fc447 feat: add duplicate detection demo data to dev:demo mode 2026-07-09 20:21:45 +08:00
rootphantomerandShuai 099217062a fix: group duplicates by color and sort A-Z within groups 2026-07-09 20:21:45 +08:00
DomainmasteriandGitHub dd90d7b8b8 Add Finnish (fi) translation (#299)
* Add Finnish (fi) locale file for nodewarden web vault translation
2026-07-09 19:00:33 +08:00
rootphantomerandShuai 525b773cf4 fix: preserve dialog title during closing animation to prevent flash 2026-07-09 18:53:01 +08:00
shuaiplus 04cb475935 feat: enhance date formatting in PublicSendPage and SendsPage components 2026-07-08 19:31:11 +08:00
ShuaiandGitHub e063f45cd9 Update FUNDING.yml 2026-07-08 11:59:17 +08:00
ShuaiandGitHub e10920d142 Create FUNDING.yml 2026-07-07 19:55:15 +08:00
shuaiplus b07edb0850 fix: correct directory name in .gitignore and remove nodewarden-wiki subproject 2026-07-07 19:48:40 +08:00
shuaiplus 58a86ae8fd docs: update feature comparison table for clarity and consistency 2026-07-07 18:36:35 +08:00
shuaiplus b986af86dc docs: add Fill-assist row to feature comparison table
Align English README with README_ZH for POST /fill-assist client support.
2026-07-07 14:18:19 +08:00
shuaiplus 8e33f92b33 docs: make English the default README, move Chinese to README_ZH
- README.md is now the English version (GitHub default)
- Add README_ZH.md for Chinese documentation
- Remove redundant README_EN.md
2026-07-07 13:53:11 +08:00
shuaiplus a0f832e8a5 chore: release v1.7.3 2026-07-07 01:43:14 +08:00
shuaiplus 8a5b210a1d Improve Bitwarden-compatible TOTP handling 2026-07-07 01:27:55 +08:00
shuaiplus ebc8e8e340 Replace remove button with trash icon positioned inside password field 2026-07-07 00:35:16 +08:00
shuaiplus a870142b7b Show password indicator and masked password field for encrypted sends
- Add password and authType fields to Send type
- Show lock icon in send list for password-protected sends
- Display masked dots in password field when editing a send that has a password set
- Add Remove button to clear existing password
2026-07-07 00:31:56 +08:00
shuaiplus a366acbac0 Revert redirect guard in backup uploader 2026-07-06 23:54:17 +08:00
shuaiplus 57c5ef9da6 Remove WEBSITE_ICONS_ENABLED and always enable website icons 2026-07-06 22:56:36 +08:00
shuaiplus f532d3ace3 Preserve WebAuthn credential purpose in backups 2026-07-06 22:25:02 +08:00
shuaiplus cc4a830be8 Harden backup and download token flows 2026-07-06 19:06:24 +08:00
shuaiplus c6438747e3 Harden 2FA disable and website icon privacy 2026-07-06 18:45:54 +08:00
shuaiplus 5c8f01be59 Harden backup blob and remote endpoint handling 2026-07-06 18:17:55 +08:00
shuaiplus 7ac6ae50bb Harden auth requests and backup endpoints 2026-07-06 18:04:23 +08:00
shuaiplus ace00e8e74 Harden WebAuthn extension origins 2026-07-06 17:43:57 +08:00
96 changed files with 19780 additions and 1571 deletions
+2
View File
@@ -0,0 +1,2 @@
custom:
- https://nodewarden.app/sponsor
+1 -1
View File
@@ -43,7 +43,7 @@ tmp/
.tmp/ .tmp/
.tmp-bitwarden-clients/ .tmp-bitwarden-clients/
nodewarden.wiki/ nodewarden-wiki/
wiki/ wiki/
AGENTS.md AGENTS.md
settings.json settings.json
+76 -110
View File
@@ -3,95 +3,100 @@
</p> </p>
<p align="center"> <p align="center">
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端 Bitwarden-compatible server running on Cloudflare Workers
</p> </p>
<p align="center"> <p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a> <a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a> <a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a> <a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> | <a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a> <a href="https://t.me/NodeWarden_Official">Telegram Group</a>
</p> </p>
<p align="center"> <p align="center">
<a href="./README_EN.md">English</a> | <a href="./README_ZH.md">中文</a> |
<a href="./CONTRIBUTING.md">贡献指南</a> <a href="./CONTRIBUTING.md">Contributing</a> |
<a href="https://nodewarden.app">Official wiki</a>
</p> </p>
> **免责声明** > **Disclaimer**
> 本项目仅供学习与交流使用,请定期备份你的密码库。 > This project is for learning and discussion purposes only. Please back up your vault regularly.
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。 > This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
--- ---
## Bitwarden 官方服务端能力对比 ## Feature comparison with the official Bitwarden server
| 能力 | Bitwarden | NodeWarden | 说明 | | Feature | Bitwarden Free | NodeWarden | Notes |
|---|---|---|---| |---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** | | Web vault | ✅ | ✅ | **Original Web Vault UI** |
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** | | TOTP | ❌ | ✅ | Includes `steam://` support |
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** | | **PWA / offline** | ❌ | ✅ | **Installable, offline** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** | | **Passkey login** | ✅ | ✅ | **passwordless auth** |
| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 | | API keys | ✅ | ✅ | CLI keys; create and rotate |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV | | Login 2FA | ✅ | ✅ | TOTP, YubiKey, Passkey |
| Send | ✅ | ✅ | 支持文本与文件 Send | | 2FA recovery codes | ✅ | ✅ | One-time 2FA disable codes |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** | | Real-time push sync | ✅ | ✅ | All device sync |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份(OneDrive/Google Drive等)** | | Attachments / Send | ✅ | ✅ | Cloudflare R2 or KV |
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** | | Import / export | ✅ | ✅ | Bitwarden JSON / CSV / **ZIP** |
| TOTP / Steam TOTP | | ✅ | `steam://` 支持 | | **Cloud backup center** | | ✅ | **Scheduled WebDAV / S3 incrementals** |
| 多用户 | ✅ | ✅ | 支持邀请码注册 | | Device management | ✅ | ✅ | **Remove devices; trust controls** |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 | | Login requests | ✅ | ✅ | **Cross-device login approval/unlock** |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) | | **Multi-user** | ✅ | ✅ | Invite-code registration |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 | | Domain rules | ✅ | ✅ | Equivalent domains, global exclusions |
| Fill-assist | ✅ | ✅ | `POST /fill-assist`|
| Organizations / collections / roles | ✅ | ❌ | Not implemented |
| SSO / SCIM / directory | ✅ | ❌ | Not implemented |
--- ---
## 已测试客户端 ## Tested clients
- ✅ Windows 桌面端 - ✅ Windows desktop
-手机 App -Mobile app
-浏览器扩展 -Browser extension
- ✅ Linux 桌面端 - ✅ Linux desktop
- ⚠️ macOS 桌面端尚未完整验证 - ⚠️ macOS desktop not fully verified yet
--- ---
## 可视化快速部署 ## Visual quick deploy
1. Fork NodeWarden 仓库到自己的 GitHub 账号 1. Fork the NodeWarden repository to your GitHub account
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) 2. Open [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 Continue with GitHub 并选择你的仓库 3. Choose **Continue with GitHub** and select your fork
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy` 4. Set **build command** to `npm run build` and **deploy command** to `npm run deploy`
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv` - For KV mode, change the deploy command to `npm run deploy:kv`
5. 等部署完成后,打开生成的 Workers 域名 5. After deployment finishes, open the generated Workers URL
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。 - The default Workers hostname may be unreachable on some networks. To use a custom domain, add it in [Workers settings](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings).
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。 - If the site reports a missing `JWT_SECRET`, add it as a **Secret** in Workers settings. In production use a random string of at least 32 characters; do not use temporary or example values.
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。 - In this flow you hand code to Cloudflare to build and deploy. `wrangler.toml` or `wrangler.kv.toml` in the repo defines binding names; the Worker initializes the D1 schema on first request—no manual SQL upload.
> [!TIP] > [!TIP]
> 默认R2与可选KV的区别: > Default R2 vs optional KV:
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 | > | Storage | Card required | Max single attachment / Send file | Free tier |
> |---|---|---|---| > |---|---|---|---|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB | > | R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
> | KV | 不需要 | 25 MiBCloudflare限制) | 1 GB | > | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
## 更新方法: ## How to update
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork``Update branch`
- 自动:进入你的 Fork 仓库 ➜ `Actions``Sync upstream``Enable workflow`,会在每天凌晨 3 点自动同步上游。 - Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork****Update branch**
## CLI 部署
## CLI deploy
```powershell ```powershell
git clone https://github.com/shuaiplus/NodeWarden.git git clone https://github.com/shuaiplus/NodeWarden.git
@@ -100,85 +105,46 @@ cd NodeWarden
npm install npm install
npx wrangler login npx wrangler login
# 默认:R2 模式 # Default: R2 mode
npm run deploy npm run deploy
# 可选:KV 模式 # Optional: KV mode
npm run deploy:kv npm run deploy:kv
# 本地开发 # Local development
npm run dev npm run dev
npm run dev:kv npm run dev:kv
``` ```
--- ---
## 主要特性
### PWA 渐进式 Web 应用 ## License
-**可安装到桌面** - 像原生应用一样运行
-**离线使用** - Service Worker 缓存,离线也能查看密码
-**App 快捷方式** - 快速启动保险库、TOTP代码
-**后台解密** - Web Worker 处理解密,不阻塞UI
### Passkey 无密码登录
-**WebAuthn/FIDO2 支持** - 使用指纹、Face ID等登录
-**PRF 密钥解锁** - Passkey 可直接解锁保险库
-**官方客户端兼容** - Chromium系浏览器扩展可用Passkey登录
-**多设备同步** - 支持iCloud、Google Password Manager等
### 云端备份说明
- 远程备份支持 **WebDAV****S3**
- 支持 **OneDrive**(通过Koofr)、**Google Drive**(通过Koofr)、**Cloudflare R2**、**Backblaze B2** 等
- 勾选”包含附件”后:
- ZIP 内仍只包含 `db.json``manifest.json`
- 真实附件单独存放在 `attachments/`
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
- 远程还原时:
- 会从 `attachments/` 目录按需读取附件
- 缺失的附件会被安全跳过
- 被跳过的附件不会在恢复后的数据库中留下脏记录
---
## 导入 / 导出
当前支持的导入来源包括:
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden 密码库 + 附件 ZIP
- NodeWarden JSON
- 网页导入器里可见的多种浏览器 / 密码管理器格式
当前支持的导出方式包括:
- Bitwarden JSON
- Bitwarden 加密 JSON
- 带附件的 ZIP 导出
- NodeWarden JSON 系列
- 备份中心中的实例级完整手动导出
---
## 开源协议
LGPL-3.0 License LGPL-3.0 License
--- ---
## 致谢 ## Credits
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端 - [Bitwarden](https://bitwarden.com/) - Original design and clients
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考 - [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台 - [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
--- ---
## Contributors
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
</a>
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left) <a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
</picture>
</a>
-172
View File
@@ -1,172 +0,0 @@
<p align="center">
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
<p align="center">
Bitwarden-compatible server running on Cloudflare Workers
</p>
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
</p>
<p align="center">
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
</p>
<p align="center">
<a href="./README.md">中文说明</a> |
<a href="./CONTRIBUTING.md">Contributing</a>
</p>
> **Disclaimer**
>
> This project is for learning and discussion purposes only. Please back up your vault regularly.
>
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
---
## Feature Comparison with the Official Bitwarden Server
| Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---|
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
| Real-time sync | ✅ | ✅ | Web, browser extension, desktop, and mobile clients stay in sync in real time |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
| **Cloud Backup Center** | ❌ | ✅ | **WebDAV / S3 scheduled backup (OneDrive/Google Drive etc.)** |
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Invite-based registration |
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
| Login 2FA | ✅ | ⚠️ Partial | TOTP and Passkey (as second factor) |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
---
## Tested Clients
- ✅ Windows desktop client
- ✅ Mobile app
- ✅ Browser extension
- ✅ Linux desktop client
- ⚠️ macOS desktop client has not been fully verified yet
---
## Web Deploy
1. Fork this repository. If this project helps you, consider giving it a Star.
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> continue.
3. R2 is used by default. If R2 is not enabled on your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
4. Deploy and open the generated URL.
| Storage | Card required | Single attachment / Send file limit | Free tier |
|---|---|---|---|
| R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
> [!TIP]
> How to keep your fork updated:
> - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
> - Automatic: go to your fork -> `Actions` -> `Sync upstream` -> `Enable workflow`; it will sync upstream automatically every day at 3 AM
## CLI Deploy
```powershell
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm install
npx wrangler login
# Default: R2 mode
npm run deploy
# Optional: KV mode
npm run deploy:kv
# Local development
npm run dev
npm run dev:kv
```
---
## Key Features
### PWA Progressive Web App
-**Install to desktop** - Runs like a native app
-**Offline usage** - Service Worker caching, view passwords offline
-**App shortcuts** - Quick launch vault, TOTP codes
-**Background decryption** - Web Worker handles decryption without blocking UI
### Passkey Passwordless Login
-**WebAuthn/FIDO2 support** - Login with fingerprint, Face ID, etc.
-**PRF key unlock** - Passkey can unlock vault directly
-**Official client compatibility** - Chromium browser extension supports Passkey login
-**Multi-device sync** - Supports iCloud, Google Password Manager, etc.
### Cloud Backup Notes
- Remote backup supports **WebDAV** and **S3**
- Supports **OneDrive** (via Koofr), **Google Drive** (via Koofr), **Cloudflare R2**, **Backblaze B2**, etc.
- When `Include attachments` is enabled:
- the ZIP still contains only `db.json` and `manifest.json`
- actual attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
- During remote restore:
- required attachment files are loaded from `attachments/` on demand
- missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database
---
## Import / Export
Current supported import sources include:
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden vault + attachments ZIP
- NodeWarden JSON
- Multiple browser / password-manager formats available in the web import selector
Current supported export formats include:
- Bitwarden JSON
- Bitwarden encrypted JSON
- ZIP export with attachments
- NodeWarden JSON variants
- Full manual instance export from the backup center
---
## License
LGPL-3.0 License
---
## Credits
- [Bitwarden](https://bitwarden.com/) - Original design and clients
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
+149
View File
@@ -0,0 +1,149 @@
<p align="center">
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
<p align="center">
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
</p>
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
</p>
<p align="center">
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
</p>
<p align="center">
<a href="./README.md">English</a> |
<a href="./CONTRIBUTING.md">贡献指南</a> |
<a href="https://nodewarden.app">官方wiki</a>
</p>
> **免责声明**
> 本项目仅供学习与交流使用,请定期备份你的密码库。
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
---
## 与 Bitwarden 官方服务端能力对比
| 能力 | Bitwarden免费版 | NodeWarden | 说明 |
|---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| TOTP | ❌ | ✅ | 包括 `steam://` 支持 |
| **PWA / 离线使用** | ❌ | ✅ | **可安装、离线使用、App快捷方式** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| API 密钥 | ✅ | ✅ | 供bitwarden cli使用,支持获取和轮换 |
| 登录 2FA | ✅ | ✅ | 支持 TOTP、YubiKey、Passkey |
| 2FA 恢复码 | ✅ | ✅ | 一次性恢复码用于禁用 2FA |
| 实时推送同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
| 附件 / Send| ✅ | ✅ | Cloudflare R2 或 KV |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时增量备份** |
| 设备管理 | ✅ | ✅ | **删除设备、撤销信任、永久信任** |
| 登录请求 | ✅ | ✅ | **多端免密登录审批、跨设备解锁请求** |
| **多用户使用** | ✅ | ✅ | 支持邀请码注册 |
| 域名规则 | ✅ | ✅ | 自定义等效域名、全局域名排除 |
| Fill-assist | ✅ | ✅ | `POST /fill-assist` 辅助客户端自动填充;不能绕过保险库解锁 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
---
## 已测试客户端
- ✅ Windows 桌面端
- ✅ 手机 App
- ✅ 浏览器扩展
- ✅ Linux 桌面端
- ⚠️ macOS 桌面端尚未完整验证
---
## 可视化快速部署
1. Fork NodeWarden 仓库到自己的 GitHub 账号
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 Continue with GitHub 并选择你的仓库
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
5. 等部署完成后,打开生成的 Workers 域名
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml``wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
> [!TIP]
> 默认R2与可选KV的区别:
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
> |---|---|---|---|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
> | KV | 不需要 | 25 MiBCloudflare限制) | 1 GB |
## 更新方法:
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork``Update branch`
## CLI 部署
```powershell
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm install
npx wrangler login
# 默认:R2 模式
npm run deploy
# 可选:KV 模式
npm run deploy:kv
# 本地开发
npm run dev
npm run dev:kv
```
---
## 开源协议
LGPL-3.0 License
---
## 致谢
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
---
## 贡献者
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
</a>
## Star History
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
</picture>
</a>
+6 -1
View File
@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
security_stamp TEXT NOT NULL, security_stamp TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
verify_devices INTEGER NOT NULL DEFAULT 1, verify_devices INTEGER NOT NULL DEFAULT 0,
totp_secret TEXT, totp_secret TEXT,
totp_recovery_code TEXT, totp_recovery_code TEXT,
api_key TEXT, api_key TEXT,
@@ -132,6 +132,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
device_identifier TEXT, device_identifier TEXT,
device_session_stamp TEXT, device_session_stamp TEXT,
security_stamp TEXT,
created_at INTEGER,
last_used_at INTEGER,
absolute_expires_at INTEGER,
client_type TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
+1040 -419
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.2", "version": "1.7.4",
"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",
@@ -57,8 +57,8 @@
"postcss": "^8.5.16", "postcss": "^8.5.16",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"tsx": "^4.22.4", "tsx": "^4.22.4",
"typescript": "^6.0.3", "typescript": "^7.0.2",
"vite": "^7.3.1", "vite": "^8.1.3",
"wrangler": "^4.105.0" "wrangler": "^4.105.0"
}, },
"dependencies": { "dependencies": {
+5
View File
@@ -13,6 +13,11 @@ const localeFiles = [
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'], ['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
['ru', 'ru.ts', 'ru', 'Russian'], ['ru', 'ru.ts', 'ru', 'Russian'],
['es', 'es.ts', 'es', 'Spanish'], ['es', 'es.ts', 'es', 'Spanish'],
['fi', 'fi.ts', 'fi', 'Finnish'],
['de', 'de.ts', 'de', 'German'],
['fr', 'fr.ts', 'fr', 'French'],
['it', 'it.ts', 'it', 'Italian'],
['sv', 'sv.ts', 'sv', 'Swedish'],
]; ];
function readLocale(fileName, variableName) { function readLocale(fileName, variableName) {
@@ -0,0 +1,38 @@
import { normalizeBackupEndpointUrl } from '../src/services/backup-config.ts';
import fs from 'node:fs';
const scratch = process.env.SCRATCH || '.';
const cases = [
'http://127.0.0.1',
'http://169.254.169.254',
'http://[::1]',
'http://[0:0:0:0:0:0:0:1]',
'http://[::2]',
'http://[::]',
'http://[fe80::1]',
'http://[fc00::1]',
'https://example.com',
];
const out = [];
for (const url of cases) {
try {
const normalized = normalizeBackupEndpointUrl(url, 'WebDAV server URL');
out.push({ url, allowed: true, normalized });
} catch (e) {
out.push({ url, allowed: false, error: e instanceof Error ? e.message : String(e) });
}
}
const path = `${scratch}/poc-normalizeBackupEndpointUrl.json`;
fs.writeFileSync(path, JSON.stringify(out, null, 2));
console.log(JSON.stringify(out, null, 2));
// Security expectation: IPv6 loopback must NOT be allowed.
const loopback = out.find((row) => row.url === 'http://[::1]');
if (loopback?.allowed) {
console.error('FINDING_CONFIRMED: normalizeBackupEndpointUrl accepts http://[::1]');
process.exitCode = 2;
} else {
console.log('IPv6 loopback rejected as expected');
}
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.7.2'; export const APP_VERSION = '1.7.4';
+18 -6
View File
@@ -3,12 +3,14 @@
// Access token lifetime in seconds. // Access token lifetime in seconds.
// 访问令牌有效期(秒)。 // 访问令牌有效期(秒)。
accessTokenTtlSeconds: 7200, accessTokenTtlSeconds: 7200,
// Refresh token lifetime in milliseconds. // Refresh sessions use a reusable opaque token with a sliding idle lifetime.
// 刷新令牌有效期(毫秒) // 刷新会话使用可复用的随机令牌,并按客户端采用滑动空闲期限
refreshTokenTtlMs: 365 * 24 * 60 * 60 * 1000, refreshTokenWebSlidingTtlMs: 30 * 24 * 60 * 60 * 1000,
// Grace window for previous refresh token after rotation (ms). refreshTokenDefaultSlidingTtlMs: 30 * 24 * 60 * 60 * 1000,
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。 refreshTokenMobileSlidingTtlMs: 90 * 24 * 60 * 60 * 1000,
refreshTokenOverlapGraceMs: 30 * 60 * 1000, // Hard upper bound for one login session, regardless of sliding refreshes.
// 单次登录会话的绝对最长寿命,不因滑动续期突破该上限。
refreshTokenAbsoluteTtlMs: 365 * 24 * 60 * 60 * 1000,
// Refresh token random byte length. // Refresh token random byte length.
// 刷新令牌随机字节长度。 // 刷新令牌随机字节长度。
refreshTokenRandomBytes: 32, refreshTokenRandomBytes: 32,
@@ -62,6 +64,9 @@
// Refresh-token grant budget per IP per minute. // Refresh-token grant budget per IP per minute.
// refresh_token 授权每 IP 每分钟请求配额。 // refresh_token 授权每 IP 每分钟请求配额。
refreshTokenRequestsPerMinute: 30, refreshTokenRequestsPerMinute: 30,
// Coarser IP budget; the per-session budget above remains the primary guard.
// 更宽松的 IP 总预算;主要保护仍由每个 refresh session 的预算承担。
refreshTokenRequestsPerIpMinute: 300,
// Passwordless/auth-request creation budget per IP/email/device per minute. // Passwordless/auth-request creation budget per IP/email/device per minute.
// 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。 // 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。
authRequestRequestsPerMinute: 5, authRequestRequestsPerMinute: 5,
@@ -159,3 +164,10 @@
cipherKeyEncryptionFeatureEnabled: true, cipherKeyEncryptionFeatureEnabled: true,
}, },
} as const; } as const;
export function getRefreshTokenSlidingTtlMs(clientType?: string | null): number {
const normalized = String(clientType || '').trim().toLowerCase();
if (normalized === 'web') return LIMITS.auth.refreshTokenWebSlidingTtlMs;
if (normalized === 'mobile') return LIMITS.auth.refreshTokenMobileSlidingTtlMs;
return LIMITS.auth.refreshTokenDefaultSlidingTtlMs;
}
+32 -59
View File
@@ -352,7 +352,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
securityStamp: generateUUID(), securityStamp: generateUUID(),
role: 'user', role: 'user',
status: 'active', status: 'active',
verifyDevices: true, verifyDevices: false, // new-device verification requires email delivery (not available)
totpSecret: null, totpSecret: null,
totpRecoveryCode: null, totpRecoveryCode: null,
yubikeyKey1: null, yubikeyKey1: null,
@@ -553,51 +553,31 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
} }
// PUT/POST /api/accounts/verify-devices // PUT/POST /api/accounts/verify-devices
// New-device verification requires an email delivery channel which NodeWarden
// does not provide. This endpoint always rejects the request so clients receive
// clear feedback that the feature is unavailable rather than silently ignoring
// the user's preference.
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const auth = new AuthService(env); const auth = new AuthService(env);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
let body: { // Log the attempt for audit purposes, but do not change state.
secret?: string;
masterPasswordHash?: string;
verifyDevices?: boolean;
VerifyDevices?: boolean;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const verifyDevices = typeof body.verifyDevices === 'boolean' ? body.verifyDevices : body.VerifyDevices;
if (typeof verifyDevices !== 'boolean') {
return errorResponse('verifyDevices must be true or false', 400);
}
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
if (!verified) {
return errorResponse('User verification failed.', 400);
}
user.verifyDevices = verifyDevices;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
actorUserId: user.id, actorUserId: user.id,
action: 'account.verify_devices.update', action: 'account.verify_devices.update.rejected',
category: 'security', category: 'security',
level: 'security', level: 'info',
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user.id,
metadata: { metadata: {
verifyDevices: user.verifyDevices, reason: 'new-device verification is not supported (no email delivery channel)',
...auditRequestMetadata(request), ...auditRequestMetadata(request),
}, },
}); });
return new Response(null, { status: 200 }); return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
} }
// GET /api/accounts/keys // GET /api/accounts/keys
@@ -819,13 +799,16 @@ function yubiKeyResponse(user: User): Record<string, unknown> {
}; };
} }
function deviceVerificationSettingsResponse(user: User): Record<string, unknown> { // New-device verification requires an email delivery channel to send OTP
const enabled = user.verifyDevices !== false; // challenges to unknown devices. NodeWarden does not integrate with an email
// provider, so this feature is intentionally unavailable. The settings
// response always reports disabled regardless of any legacy DB value.
function deviceVerificationSettingsResponse(_user: User): Record<string, unknown> {
return { return {
Enabled: enabled, Enabled: false,
enabled, enabled: false,
VerifyDevices: enabled, VerifyDevices: false,
verifyDevices: enabled, verifyDevices: false,
Object: 'deviceVerificationSettings', Object: 'deviceVerificationSettings',
object: 'deviceVerificationSettings', object: 'deviceVerificationSettings',
}; };
@@ -915,9 +898,10 @@ export async function handleGetDeviceVerificationSettings(request: Request, env:
} }
// PUT/POST /api/two-factor/device-verification-settings // PUT/POST /api/two-factor/device-verification-settings
// New-device verification is not supported (no email delivery channel).
// Reject any attempt to enable it; always return disabled state.
export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> { export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
@@ -929,31 +913,28 @@ export async function handlePutDeviceVerificationSettings(request: Request, env:
} }
const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices; const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices;
if (typeof rawEnabled !== 'boolean') {
return errorResponse('enabled must be true or false', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']); // Log the attempt for audit purposes — never change state.
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
user.verifyDevices = rawEnabled;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
actorUserId: user.id, actorUserId: user.id,
action: 'account.verify_devices.update', action: 'account.verify_devices.update.rejected',
category: 'security', category: 'security',
level: 'security', level: 'info',
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user.id,
metadata: { metadata: {
verifyDevices: user.verifyDevices, requested: rawEnabled,
reason: 'new-device verification is not supported (no email delivery channel)',
source: 'two-factor.device-verification-settings', source: 'two-factor.device-verification-settings',
...auditRequestMetadata(request), ...auditRequestMetadata(request),
}, },
}); });
if (rawEnabled === true) {
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
}
// Setting to false is the only supported state — return it.
return jsonResponse(deviceVerificationSettingsResponse(user)); return jsonResponse(deviceVerificationSettingsResponse(user));
} }
@@ -1161,16 +1142,8 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
return errorResponse('Two-factor provider is not supported by this server.', 400); return errorResponse('Two-factor provider is not supported by this server.', 400);
} }
const key = normalizeTotpSecret(readBodyString(body, ['key', 'Key']));
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']); const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
let verified = false; const verified = await verifyUserSecret(auth, user, secret);
if (key && userVerificationToken) {
verified = await verifyTotpUserVerificationToken(env, user, key, userVerificationToken);
}
if (!verified) {
verified = await verifyUserSecret(auth, user, secret);
}
if (!verified) return errorResponse('User verification failed.', 400); if (!verified) return errorResponse('User verification failed.', 400);
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) { if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
+47 -14
View File
@@ -9,6 +9,34 @@ function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active'; return user.role === 'admin' && user.status === 'active';
} }
async function requireMasterPasswordHash(
env: Env,
actorUser: User,
masterPasswordHash: unknown
): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim();
if (!normalized) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
return null;
}
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
try {
const body = await request.json();
return body && typeof body === 'object' && !Array.isArray(body)
? body as Record<string, unknown>
: {};
} catch {
return {};
}
}
function randomHex(bytes: number): string { function randomHex(bytes: number): string {
const data = crypto.getRandomValues(new Uint8Array(bytes)); const data = crypto.getRandomValues(new Uint8Array(bytes));
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join(''); return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
@@ -204,14 +232,11 @@ export async function handleAdminCreateInvite(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
let body: { expiresInHours?: number } = {}; const body = await readJsonBody(request);
try { const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
body = await request.json(); if (passwordError) return passwordError;
} catch {
body = {};
}
const expiresInHours = Number.isFinite(body.expiresInHours) const expiresInHours = Number.isFinite(Number(body.expiresInHours))
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours)))) ? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
: 24 * 7; : 24 * 7;
const now = new Date(); const now = new Date();
@@ -266,6 +291,10 @@ export async function handleAdminDeleteInvite(
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const deleted = await storage.deleteInvite(code); const deleted = await storage.deleteInvite(code);
if (!deleted) { if (!deleted) {
@@ -288,6 +317,10 @@ export async function handleAdminDeleteAllInvites(
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const url = new URL(request.url); const url = new URL(request.url);
if (url.searchParams.get('scope') === 'invalid') { if (url.searchParams.get('scope') === 'invalid') {
@@ -318,12 +351,9 @@ export async function handleAdminSetUserStatus(
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
let body: { status?: string }; const body = await readJsonBody(request);
try { const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
body = await request.json(); if (passwordError) return passwordError;
} catch {
return errorResponse('Invalid JSON', 400);
}
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null; const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
if (!nextStatus) { if (!nextStatus) {
@@ -366,7 +396,6 @@ export async function handleAdminDeleteUser(
actorUser: User, actorUser: User,
targetUserId: string targetUserId: string
): Promise<Response> { ): Promise<Response> {
void request;
if (!isAdmin(actorUser)) { if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
@@ -374,6 +403,10 @@ export async function handleAdminDeleteUser(
return errorResponse('You cannot delete yourself', 400); return errorResponse('You cannot delete yourself', 400);
} }
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const target = await storage.getUserById(targetUserId); const target = await storage.getUserById(targetUserId);
if (!target) { if (!target) {
+5 -6
View File
@@ -439,17 +439,16 @@ export async function handlePublicDownloadAttachment(
} }
const path = getAttachmentObjectKey(cipherId, attachmentId); const path = getAttachmentObjectKey(cipherId, attachmentId);
const object = await getBlobObject(env, path);
if (!object) {
return errorResponse('Attachment file not found', 404);
}
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp); const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
if (!firstUse) { if (!firstUse) {
return errorResponse('Invalid or expired token', 401); return errorResponse('Invalid or expired token', 401);
} }
const object = await getBlobObject(env, path);
if (!object) {
return errorResponse('Attachment file not found', 404);
}
return new Response(object.body, { return new Response(object.body, {
headers: { headers: {
'Content-Type': sanitizeDownloadContentType(object.contentType), 'Content-Type': sanitizeDownloadContentType(object.contentType),
+3 -4
View File
@@ -96,8 +96,8 @@ function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord,
RequestCountryName: authRequest.requestCountryName, RequestCountryName: authRequest.requestCountryName,
key: authRequest.key, key: authRequest.key,
Key: authRequest.key, Key: authRequest.key,
masterPasswordHash: authRequest.masterPasswordHash, masterPasswordHash: null,
MasterPasswordHash: authRequest.masterPasswordHash, MasterPasswordHash: null,
creationDate: authRequest.creationDate, creationDate: authRequest.creationDate,
CreationDate: authRequest.creationDate, CreationDate: authRequest.creationDate,
responseDate: authRequest.responseDate, responseDate: authRequest.responseDate,
@@ -349,7 +349,6 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved'])); const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000); const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
const masterPasswordHash = normalizeText(readBodyValue(body, ['masterPasswordHash', 'MasterPasswordHash']), 20000) || null;
const responseDeviceIdentifier = const responseDeviceIdentifier =
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) || normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
readActingDeviceIdentifier(request) || readActingDeviceIdentifier(request) ||
@@ -366,7 +365,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
approved, approved,
responseDeviceIdentifier, responseDeviceIdentifier,
key, key,
masterPasswordHash, masterPasswordHash: null,
}); });
if (!updated) return errorResponse('Auth request has already been answered.', 409); if (!updated) return errorResponse('Auth request has already been answered.', 409);
const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId); const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
+34 -4
View File
@@ -1062,12 +1062,21 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup integrity payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try { try {
const settings = await loadBackupSettings(storage, env, 'UTC'); const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url); const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || ''); const destination = requireBackupDestination(settings, body.destinationId || null);
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const remoteFile = await downloadRemoteBackupFile(destination, path); const remoteFile = await downloadRemoteBackupFile(destination, path);
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path); const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
return jsonResponse({ return jsonResponse({
@@ -1239,7 +1248,28 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
try { try {
const url = new URL(request.url); const url = new URL(request.url);
const blobName = ensureBackupBlobName(url.searchParams.get('blobName') || ''); let input: { blobName?: unknown; masterPasswordHash?: unknown } = {};
if (request.method === 'POST') {
try {
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
} catch {
return errorResponse('Backup attachment download payload is invalid', 400);
}
} else {
input = {
blobName: url.searchParams.get('blobName') || '',
masterPasswordHash: url.searchParams.get('masterPasswordHash') || '',
};
}
const verificationError = await requireBackupUserVerification(
actorUser,
String(input.masterPasswordHash || ''),
env
);
if (verificationError) return verificationError;
const blobName = ensureBackupBlobName(String(input.blobName || ''));
const object = await getBlobObject(env, blobName); const object = await getBlobObject(env, blobName);
if (!object) { if (!object) {
return errorResponse('Backup attachment blob not found', 404); return errorResponse('Backup attachment blob not found', 404);
+16 -1
View File
@@ -464,11 +464,26 @@ export async function handleUpdateDeviceName(
// DELETE /api/devices // DELETE /api/devices
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> { export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
let masterPasswordHash = '';
try {
const body = await request.json() as { masterPasswordHash?: string };
masterPasswordHash = String(body?.masterPasswordHash || '').trim();
} catch {
masterPasswordHash = '';
}
if (!masterPasswordHash) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const passwordValid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
if (!passwordValid) {
return errorResponse('Invalid password', 400);
}
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([ const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
storage.deleteTrustedTwoFactorTokensByUserId(userId), storage.deleteTrustedTwoFactorTokensByUserId(userId),
storage.deleteRefreshTokensByUserId(userId), storage.deleteRefreshTokensByUserId(userId),
+138 -99
View File
@@ -3,7 +3,7 @@ import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
import { LIMITS } from '../config/limits'; import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp'; import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
import { createRefreshToken } from '../utils/jwt'; import { createRefreshToken } from '../utils/jwt';
import { readAuthRequestDeviceInfo } from '../utils/device'; import { readAuthRequestDeviceInfo } from '../utils/device';
@@ -42,6 +42,10 @@ const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1'; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
function identityJsonResponse(data: unknown, status: number = 200): Response {
return jsonResponse(data, status, { 'Cache-Control': 'no-store', Pragma: 'no-cache' });
}
function resolveTotpSecret(userSecret: string | null): string | null { function resolveTotpSecret(userSecret: string | null): string | null {
if (userSecret && isTotpEnabled(userSecret)) { if (userSecret && isTotpEnabled(userSecret)) {
return userSecret; return userSecret;
@@ -60,6 +64,33 @@ async function resolveDeviceSession(
return { identifier: deviceInfo.deviceIdentifier, sessionStamp }; return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
} }
function resolveRefreshClientType(request: Request, body: Record<string, string>): string {
if (shouldUseWebSession(request)) return 'web';
const clientId = String(body.client_id || '').trim().toLowerCase();
if (clientId === 'mobile') return 'mobile';
if (clientId === 'browser' || clientId === 'desktop' || clientId === 'cli') return clientId;
return clientId || 'other';
}
async function persistAndResolveDeviceSession(
storage: StorageService,
userId: string,
deviceInfo: ReturnType<typeof readAuthRequestDeviceInfo>
): Promise<{ identifier: string; sessionStamp: string } | null> {
const candidate = await resolveDeviceSession(storage, userId, deviceInfo);
if (!candidate) return null;
await storage.upsertDevice(
userId,
candidate.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
candidate.sessionStamp
);
const persisted = await storage.getDevice(userId, candidate.identifier);
if (!persisted?.sessionStamp) throw new Error('Failed to persist device session');
return { identifier: persisted.deviceIdentifier, sessionStamp: persisted.sessionStamp };
}
function readDevicePushToken(body: Record<string, string>): string { function readDevicePushToken(body: Record<string, string>): string {
return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim(); return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim();
} }
@@ -163,7 +194,7 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken
headers.append( headers.append(
'Set-Cookie', 'Set-Cookie',
refreshToken refreshToken
? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000)) ? buildRefreshCookie(request, refreshToken, Math.floor(getRefreshTokenSlidingTtlMs('web') / 1000))
: buildClearedRefreshCookie(request) : buildClearedRefreshCookie(request)
); );
return new Response(response.body, { return new Response(response.body, {
@@ -173,30 +204,6 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken
}); });
} }
async function revokePresentedAccessTokenSession(request: Request, env: Env, storage: StorageService): Promise<void> {
const authHeader = request.headers.get('Authorization');
if (!authHeader) return;
const auth = new AuthService(env);
const verified = await auth.verifyAccessTokenWithUser(authHeader);
if (!verified) return;
const deviceIdentifier = String(verified.payload.did || '').trim();
if (deviceIdentifier) {
const nextSessionStamp = generateUUID();
await storage.rotateDeviceSessionStamp(verified.user.id, deviceIdentifier, nextSessionStamp);
await storage.deleteRefreshTokensByDevice(verified.user.id, deviceIdentifier);
AuthService.invalidateDeviceCache(verified.user.id, deviceIdentifier);
return;
}
verified.user.securityStamp = generateUUID();
verified.user.updatedAt = new Date().toISOString();
await storage.saveUser(verified.user);
await storage.deleteRefreshTokensByUserId(verified.user.id);
AuthService.invalidateUserCache(verified.user.id);
}
function buildPreloginResponse( function buildPreloginResponse(
email: string, email: string,
kdfType: number, kdfType: number,
@@ -267,7 +274,7 @@ async function twoFactorRequiredResponse(
}; };
// Bitwarden clients rely on these fields to trigger the 2FA UI flow. // Bitwarden clients rely on these fields to trigger the 2FA UI flow.
return jsonResponse( return identityJsonResponse(
{ {
error: 'invalid_grant', error: 'invalid_grant',
error_description: message, error_description: message,
@@ -341,8 +348,20 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const grantType = body.grant_type; const grantType = body.grant_type;
const clientIdentifier = getClientIdentifier(request); const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) { if (!clientIdentifier && grantType !== 'refresh_token') {
return identityErrorResponse('Client IP is required', 'invalid_request', 403); await safeWriteAuditEvent(env, {
action: 'auth.client_ip.missing',
category: 'auth',
level: 'error',
targetType: 'tokenEndpoint',
metadata: { grantType, reason: 'client_ip_missing', ...auditRequestMetadata(request) },
});
return identityErrorResponse(
'Authentication is temporarily unavailable',
'temporarily_unavailable',
503,
{ 'Retry-After': '5' }
);
} }
if (grantType === 'password') { if (grantType === 'password') {
@@ -359,7 +378,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Bitwarden clients expect OAuth-style error fields. // Bitwarden clients expect OAuth-style error fields.
return identityErrorResponse('Email and password are required', 'invalid_request', 400); return identityErrorResponse('Email and password are required', 'invalid_request', 400);
} }
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, email); const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, email);
// Check login lockout before user lookup to reduce user-enumeration signal // Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -550,15 +569,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// Persist device only after successful password + (optional) 2FA verification. // Persist device only after successful password + (optional) 2FA verification.
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
@@ -569,7 +581,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
@@ -612,14 +624,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
} else if (grantType === 'webauthn') { } else if (grantType === 'webauthn') {
const token = String(body.token || '').trim(); const token = String(body.token || '').trim();
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, token || 'missing-token'); const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, token || 'missing-token');
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) { if (!loginCheck.allowed) {
return identityErrorResponse( return identityErrorResponse(
@@ -673,22 +685,15 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
await rateLimit.clearLoginAttempts(loginIdentifier); await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair'); const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential); const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
@@ -734,7 +739,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
@@ -751,7 +756,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Parameter error', 'invalid_request', 400); return identityErrorResponse('Parameter error', 'invalid_request', 400);
} }
const uid = clientId.slice(5); const uid = clientId.slice(5);
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, uid); const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, uid);
// Check login lockout before user lookup to reduce user-enumeration signal // Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -805,15 +810,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// Persist device only after successful client credential verification. // Persist device only after successful client credential verification.
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
@@ -821,7 +819,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
await rateLimit.clearLoginAttempts(loginIdentifier); await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
@@ -863,7 +861,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
@@ -880,7 +878,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const sendId = String(body.send_id || body.sendId || '').trim(); const sendId = String(body.send_id || body.sendId || '').trim();
if (!sendId) { if (!sendId) {
return jsonResponse( return identityJsonResponse(
{ {
error: 'invalid_request', error: 'invalid_request',
error_description: 'send_id is required', error_description: 'send_id is required',
@@ -905,13 +903,13 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
passwordHashB64, passwordHashB64,
password, password,
rateLimit, rateLimit,
clientIdentifier clientIdentifier || undefined
); );
if ('error' in result) { if ('error' in result) {
return result.error; return result.error;
} }
return jsonResponse({ return identityJsonResponse({
access_token: result.token, access_token: result.token,
expires_in: LIMITS.auth.sendAccessTokenTtlSeconds, expires_in: LIMITS.auth.sendAccessTokenTtlSeconds,
token_type: 'Bearer', token_type: 'Bearer',
@@ -919,19 +917,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
unofficialServer: true, unofficialServer: true,
}); });
} else if (grantType === 'refresh_token') { } else if (grantType === 'refresh_token') {
const refreshLimit = await rateLimit.consumeBudget(
`${clientIdentifier}:identity-refresh`,
LIMITS.rateLimit.refreshTokenRequestsPerMinute
);
if (!refreshLimit.allowed) {
return identityErrorResponse(
`Rate limit exceeded. Try again in ${refreshLimit.retryAfterSeconds} seconds.`,
'TooManyRequests',
429
);
}
// Refresh token
const refreshToken = String(body.refresh_token || '').trim() || ( const refreshToken = String(body.refresh_token || '').trim() || (
shouldUseWebSession(request) shouldUseWebSession(request)
? parseCookieValue(request, WEB_REFRESH_COOKIE) ? parseCookieValue(request, WEB_REFRESH_COOKIE)
@@ -941,7 +926,72 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Refresh token is required', 'invalid_request', 400); return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
} }
const result = await auth.refreshAccessTokenDetailed(refreshToken); const refreshTokenHash = await sha256Hex(refreshToken);
try {
const sessionLimit = await rateLimit.consumeBudget(
`refresh-session:${refreshTokenHash}`,
LIMITS.rateLimit.refreshTokenRequestsPerMinute
);
const ipLimit = clientIdentifier
? await rateLimit.consumeBudget(
`refresh-ip:${clientIdentifier}`,
LIMITS.rateLimit.refreshTokenRequestsPerIpMinute
)
: null;
const rejected = !sessionLimit.allowed ? sessionLimit : (ipLimit && !ipLimit.allowed ? ipLimit : null);
if (rejected) {
const retryAfter = Math.max(1, rejected.retryAfterSeconds || 1);
return identityErrorResponse(
`Rate limit exceeded. Try again in ${retryAfter} seconds.`,
'temporarily_unavailable',
429,
{ 'Retry-After': String(retryAfter) }
);
}
} catch (error) {
await safeWriteAuditEvent(env, {
action: 'auth.refresh.failed.rate_limit_unavailable',
category: 'auth',
level: 'error',
targetType: 'refreshToken',
metadata: { grantType, reason: 'rate_limit_unavailable', error: error instanceof Error ? error.message : String(error), ...auditRequestMetadata(request) },
});
return identityErrorResponse(
'Session refresh is temporarily unavailable',
'temporarily_unavailable',
503,
{ 'Retry-After': '5' }
);
}
if (!clientIdentifier) {
await safeWriteAuditEvent(env, {
action: 'auth.client_ip.missing',
category: 'auth',
level: 'warn',
targetType: 'refreshToken',
metadata: { grantType, reason: 'client_ip_missing', webSession: shouldUseWebSession(request), ...auditRequestMetadata(request) },
});
}
let result: Awaited<ReturnType<AuthService['refreshAccessTokenDetailed']>>;
try {
result = await auth.refreshAccessTokenDetailed(refreshToken);
} catch (error) {
await safeWriteAuditEvent(env, {
action: 'auth.refresh.failed.temporarily_unavailable',
category: 'auth',
level: 'error',
targetType: 'refreshToken',
metadata: { grantType, reason: 'storage_or_worker_error', error: error instanceof Error ? error.message : String(error), webSession: shouldUseWebSession(request), ...auditRequestMetadata(request) },
});
return identityErrorResponse(
'Session refresh is temporarily unavailable',
'temporarily_unavailable',
503,
{ 'Retry-After': '5' }
);
}
if (!result.ok) { if (!result.ok) {
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
actorUserId: result.userId ?? null, actorUserId: result.userId ?? null,
@@ -963,18 +1013,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
: invalidResponse; : invalidResponse;
} }
// Keep a short overlap window for old refresh token to absorb
// concurrent refresh requests from multiple client contexts.
await storage.constrainRefreshTokenExpiry(
refreshToken,
Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs
);
const { accessToken, user, device } = result; const { accessToken, user, device } = result;
if (device?.identifier) { if (device?.identifier) {
await storage.touchDeviceLastSeen(user.id, device.identifier); await storage.touchDeviceLastSeen(user.id, device.identifier);
} }
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
@@ -982,7 +1024,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
access_token: accessToken, access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds, expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer', token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }), ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
Key: user.key, Key: user.key,
PrivateKey: user.privateKey, PrivateKey: user.privateKey,
AccountKeys: accountKeys, AccountKeys: accountKeys,
@@ -1001,9 +1043,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, newRefreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
} }
@@ -1036,7 +1078,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
const kdfMemory = user?.kdfMemory ?? null; const kdfMemory = user?.kdfMemory ?? null;
const kdfParallelism = user?.kdfParallelism ?? null; const kdfParallelism = user?.kdfParallelism ?? null;
return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism)); return identityJsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
} }
// POST /identity/connect/revocation // POST /identity/connect/revocation
@@ -1044,12 +1086,6 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
// RFC 7009 allows returning 200 even if token is unknown. // RFC 7009 allows returning 200 even if token is unknown.
export async function handleRevocation(request: Request, env: Env): Promise<Response> { export async function handleRevocation(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try {
await revokePresentedAccessTokenSession(request, env, storage);
} catch {
// RFC 7009 revocation is best-effort and should not reveal token state.
}
let body: Record<string, string>; let body: Record<string, string>;
const contentType = request.headers.get('content-type') || ''; const contentType = request.headers.get('content-type') || '';
try { try {
@@ -1060,7 +1096,7 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
body = await request.json(); body = await request.json();
} }
} catch { } catch {
return new Response(null, { status: 200 }); return new Response(null, { status: 200, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } });
} }
const token = String(body.token || '').trim() || ( const token = String(body.token || '').trim() || (
@@ -1072,7 +1108,10 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
await storage.deleteRefreshToken(token); await storage.deleteRefreshToken(token);
} }
const baseResponse = new Response(null, { status: 200 }); const baseResponse = new Response(null, {
status: 200,
headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' },
});
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, null) ? withWebRefreshCookie(request, baseResponse, null)
: baseResponse; : baseResponse;
+5 -5
View File
@@ -300,17 +300,17 @@ export async function handleDownloadSendFile(
return errorResponse(SEND_INACCESSIBLE_MSG, 404); return errorResponse(SEND_INACCESSIBLE_MSG, 404);
} }
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
if (!firstUse) {
return errorResponse('Invalid or expired token', 401);
}
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId)); const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
if (!object) { if (!object) {
return errorResponse('Send file not found', 404); return errorResponse('Send file not found', 404);
} }
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId; const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
if (!firstUse) {
return errorResponse('Invalid or expired token', 401);
}
return new Response(object.body, { return new Response(object.body, {
headers: { headers: {
'Content-Type': sanitizeDownloadContentType(object.contentType), 'Content-Type': sanitizeDownloadContentType(object.contentType),
+9 -1
View File
@@ -155,7 +155,15 @@ export function formatSize(bytes: number): string {
export function parseDate(raw: unknown): Date | null { export function parseDate(raw: unknown): Date | null {
if (typeof raw !== 'string' || !raw.trim()) return null; if (typeof raw !== 'string' || !raw.trim()) return null;
const date = new Date(raw); let value = raw.trim();
if (!/[zZ]$/.test(value) && !/[+\-]\d{2}:?\d{2}$/.test(value)) {
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) {
value += 'Z';
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value)) {
value = value.replace(' ', 'T') + 'Z';
}
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null; if (Number.isNaN(date.getTime())) return null;
return date; return date;
} }
+6 -3
View File
@@ -24,8 +24,11 @@ function isWorkerHandledPath(path: string): boolean {
path.startsWith('/api/') || path.startsWith('/api/') ||
path.startsWith('/identity/') || path.startsWith('/identity/') ||
path.startsWith('/icons/') || path.startsWith('/icons/') ||
path.startsWith('/fill-assist/') ||
path.startsWith('/notifications/') || path.startsWith('/notifications/') ||
path.startsWith('/.well-known/') || path.startsWith('/.well-known/') ||
path === '/v1/assetlinks:check' ||
path === '/web-bootstrap' ||
path === '/config' || path === '/config' ||
path === '/api/config' || path === '/api/config' ||
path === '/api/version' path === '/api/version'
@@ -89,7 +92,7 @@ export default {
const normalizedRequest = normalizeRequestUrl(request); const normalizedRequest = normalizeRequestUrl(request);
const assetResponse = await maybeServeAsset(normalizedRequest, env); const assetResponse = await maybeServeAsset(normalizedRequest, env);
if (assetResponse) { if (assetResponse) {
return applyCors(normalizedRequest, assetResponse); return applyCors(normalizedRequest, assetResponse, env);
} }
await ensureDatabaseInitialized(env); await ensureDatabaseInitialized(env);
@@ -107,11 +110,11 @@ export default {
}, },
500 500
); );
return applyCors(normalizedRequest, resp); return applyCors(normalizedRequest, resp, env);
} }
const resp = await handleRequest(normalizedRequest, env); const resp = await handleRequest(normalizedRequest, env);
return applyCors(normalizedRequest, resp); return applyCors(normalizedRequest, resp, env);
}, },
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> { async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
+2 -2
View File
@@ -26,7 +26,7 @@ export async function handleAdminBackupRoute(
return handleAdminExportBackup(request, env, actorUser); return handleAdminExportBackup(request, env, actorUser);
} }
if (path === '/api/admin/backup/blob' && method === 'GET') { if (path === '/api/admin/backup/blob' && (method === 'GET' || method === 'POST')) {
return handleDownloadAdminBackupAttachment(request, env, actorUser); return handleDownloadAdminBackupAttachment(request, env, actorUser);
} }
@@ -54,7 +54,7 @@ export async function handleAdminBackupRoute(
return handleDownloadAdminRemoteBackup(request, env, actorUser); return handleDownloadAdminRemoteBackup(request, env, actorUser);
} }
if (path === '/api/admin/backup/remote/integrity' && method === 'GET') { if (path === '/api/admin/backup/remote/integrity' && method === 'POST') {
return handleInspectAdminRemoteBackup(request, env, actorUser); return handleInspectAdminRemoteBackup(request, env, actorUser);
} }
+15 -2
View File
@@ -35,6 +35,7 @@ import { isSafeWebsiteIconContentType } from './utils/content-type';
import { jsonResponse, unsupportedResponse } from './utils/response'; import { jsonResponse, unsupportedResponse } from './utils/response';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import type { Env } from './types'; import type { Env } from './types';
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>; type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
type JwtUnsafeReason = 'missing' | 'too_short' | null; type JwtUnsafeReason = 'missing' | 'too_short' | null;
@@ -44,6 +45,12 @@ export interface WebBootstrapResponse {
jwtUnsafeReason: JwtUnsafeReason; jwtUnsafeReason: JwtUnsafeReason;
jwtSecretMinLength: number; jwtSecretMinLength: number;
registrationInviteRequired: boolean; registrationInviteRequired: boolean;
webAuthnAllowedOrigins: string[];
websiteIconsEnabled: boolean;
}
function isWebsiteIconProxyEnabled(env: Env): boolean {
return true;
} }
function isSameOriginWriteRequest(request: Request): boolean { function isSameOriginWriteRequest(request: Request): boolean {
@@ -255,7 +262,11 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
}); });
} }
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> { async function handleWebsiteIcon(env: Env, host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
if (!isWebsiteIconProxyEnabled(env)) {
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
}
const normalizedHost = normalizeIconHost(host); const normalizedHost = normalizeIconHost(host);
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon(); if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
@@ -322,6 +333,8 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
jwtUnsafeReason, jwtUnsafeReason,
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength, jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
registrationInviteRequired: userCount > 0, registrationInviteRequired: userCount > 0,
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
}; };
} }
@@ -372,7 +385,7 @@ export async function handlePublicRoute(
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default'; const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
return handleWebsiteIcon(iconMatch[1], fallbackMode); return handleWebsiteIcon(env, iconMatch[1], fallbackMode);
} }
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i); const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
+1 -1
View File
@@ -148,7 +148,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
if (method === 'OPTIONS') { if (method === 'OPTIONS') {
return handleCors(request); return handleCors(request, env);
} }
try { try {
+41 -8
View File
@@ -1,5 +1,6 @@
import { Env, JWTPayload, User } from '../types'; import { Env, JWTPayload, User } from '../types';
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt'; import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
import { StorageService } from './storage'; import { StorageService } from './storage';
// Server-side iterations for second-layer hashing. // Server-side iterations for second-layer hashing.
@@ -28,11 +29,12 @@ export type RefreshAccessTokenFailureReason =
| 'token_not_found_or_expired' | 'token_not_found_or_expired'
| 'user_missing' | 'user_missing'
| 'user_inactive' | 'user_inactive'
| 'security_stamp_mismatch'
| 'device_missing' | 'device_missing'
| 'device_session_mismatch'; | 'device_session_mismatch';
export type RefreshAccessTokenResult = export type RefreshAccessTokenResult =
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null; expiresAt: number }
| { | {
ok: false; ok: false;
reason: RefreshAccessTokenFailureReason; reason: RefreshAccessTokenFailureReason;
@@ -190,9 +192,23 @@ export class AuthService {
} }
// Generate refresh token // Generate refresh token
async generateRefreshToken(userId: string, device?: { identifier: string; sessionStamp: string } | null): Promise<string> { async generateRefreshToken(
user: User,
device?: { identifier: string; sessionStamp: string } | null,
clientType: string = 'other'
): Promise<string> {
const token = createRefreshToken(); const token = createRefreshToken();
await this.storage.saveRefreshToken(token, userId, undefined, device?.identifier ?? null, device?.sessionStamp ?? null); const now = Date.now();
await this.storage.saveRefreshToken(
token,
user.id,
now + getRefreshTokenSlidingTtlMs(clientType),
device?.identifier ?? null,
device?.sessionStamp ?? null,
user.securityStamp,
clientType,
now + LIMITS.auth.refreshTokenAbsoluteTtlMs
);
return token; return token;
} }
@@ -251,25 +267,42 @@ export class AuthService {
return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
} }
let device: { identifier: string; sessionStamp: string } | null = null; if (record.securityStamp && record.securityStamp !== user.securityStamp) {
if (!record.deviceIdentifier || !record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken); await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'security_stamp_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (!record.securityStamp) {
await this.storage.bindRefreshTokenSecurityStamp(refreshToken, user.securityStamp);
} }
let device: { identifier: string; sessionStamp: string } | null = null;
if (record.deviceIdentifier) {
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier); const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
if (!boundDevice) { if (!boundDevice) {
await this.storage.deleteRefreshToken(refreshToken); await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
} }
if (boundDevice.sessionStamp !== record.deviceSessionStamp) { if (record.deviceSessionStamp && boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken); await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
} }
if (!record.deviceSessionStamp) {
await this.storage.bindRefreshTokenDeviceStamp(refreshToken, boundDevice.sessionStamp);
}
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp }; device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
}
const now = Date.now();
const expiresAt = Math.min(
now + getRefreshTokenSlidingTtlMs(record.clientType),
record.absoluteExpiresAt || (now + LIMITS.auth.refreshTokenAbsoluteTtlMs)
);
const extended = await this.storage.extendRefreshTokenExpiry(refreshToken, expiresAt, now);
if (!extended) {
return { ok: false, reason: 'token_not_found_or_expired', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
const accessToken = await this.generateAccessToken(user, device); const accessToken = await this.generateAccessToken(user, device);
return { ok: true, accessToken, user, device }; return { ok: true, accessToken, user, device, expiresAt };
} }
async refreshAccessToken( async refreshAccessToken(
+3 -2
View File
@@ -442,9 +442,10 @@ export function validateBackupPayloadContents(
for (const row of accountPasskeyRows) { for (const row of accountPasskeyRows) {
const id = String(row.id || '').trim(); const id = String(row.id || '').trim();
const userId = String(row.user_id || '').trim(); const userId = String(row.user_id || '').trim();
const purpose = row.purpose == null ? 'login' : String(row.purpose || '').trim();
const credentialId = String(row.credential_id || '').trim(); const credentialId = String(row.credential_id || '').trim();
const publicKey = String(row.public_key || '').trim(); const publicKey = String(row.public_key || '').trim();
if (!id || !userIds.has(userId) || !credentialId || !publicKey) { if (!id || !userIds.has(userId) || !credentialId || !publicKey || (purpose !== 'login' && purpose !== 'twoFactor')) {
throw new Error('Backup archive contains an invalid account passkey row'); throw new Error('Backup archive contains an invalid account passkey row');
} }
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`); if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
@@ -493,7 +494,7 @@ export async function buildBackupArchive(
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'), queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()), queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
]); ]);
const exportedConfigRows = sanitizeConfigRowsForExport(configRows); const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
+161 -4
View File
@@ -68,6 +68,163 @@ function normalizePath(value: unknown): string {
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
} }
function normalizeHostnameForPolicy(hostname: string): string {
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
}
function parseIpv4Address(hostname: string): number[] | null {
const parts = hostname.split('.');
if (parts.length !== 4) return null;
const octets = parts.map((part) => {
if (!/^\d{1,3}$/.test(part)) return -1;
const value = Number(part);
return Number.isInteger(value) && value >= 0 && value <= 255 ? value : -1;
});
return octets.every((value) => value >= 0) ? octets : null;
}
function isBlockedIpv4Address(octets: number[]): boolean {
const [a, b, c] = octets;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && (b === 0 || b === 168)) ||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
(a === 203 && b === 0 && c === 113) ||
a >= 224
);
}
/**
* Expand a hostname-form IPv6 literal to eight 4-digit hextets.
* Needed so compressed forms like "::1" are not misclassified by a naive
* "first non-empty hextet" check (which would read "1" and miss loopback).
*/
function expandIpv6Address(hostname: string): string[] | null {
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, '');
if (!normalized.includes(':')) return null;
if (normalized.includes('.')) {
// IPv4-embedded forms are handled separately by the caller.
return null;
}
if ((normalized.match(/::/g) || []).length > 1) return null;
const sides = normalized.split('::');
const left = sides[0] ? sides[0].split(':').filter((part) => part.length > 0) : [];
const right = sides.length > 1 && sides[1] ? sides[1].split(':').filter((part) => part.length > 0) : [];
if (left.length + right.length > 8) return null;
if (sides.length === 1 && left.length !== 8) return null;
const missing = 8 - left.length - right.length;
if (sides.length > 1 && missing < 0) return null;
const middle = sides.length > 1 ? Array.from({ length: missing }, () => '0') : [];
const parts = [...left, ...middle, ...right];
if (parts.length !== 8) return null;
const hextets: string[] = [];
for (const part of parts) {
if (!/^[0-9a-f]{1,4}$/i.test(part)) return null;
hextets.push(part.padStart(4, '0'));
}
return hextets;
}
function isBlockedIpv6Address(hostname: string): boolean {
if (!hostname.includes(':')) return false;
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
// IPv4-mapped dotted form: ::ffff:127.0.0.1
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
if (mappedIpv4) {
const octets = parseIpv4Address(mappedIpv4[1]);
return !octets || isBlockedIpv4Address(octets);
}
// IPv4-mapped hex form produced by some URL parsers: ::ffff:7f00:1
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
if (mappedHex) {
const hi = Number.parseInt(mappedHex[1], 16);
const lo = Number.parseInt(mappedHex[2], 16);
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return true;
const octets = [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff];
return isBlockedIpv4Address(octets);
}
const hextets = expandIpv6Address(normalized);
if (!hextets) return true;
const firstHextet = Number.parseInt(hextets[0], 16);
if (!Number.isFinite(firstHextet)) return true;
// After expansion, loopback (::1) and unspecified (::) have first hextet 0.
return (
firstHextet === 0 ||
(firstHextet & 0xfe00) === 0xfc00 ||
(firstHextet & 0xffc0) === 0xfe80 ||
(firstHextet & 0xff00) === 0xff00 ||
hextets.join(':').startsWith('2001:0db8:')
);
}
function assertBackupEndpointHostAllowed(hostname: string, label: string): void {
const normalized = normalizeHostnameForPolicy(hostname);
if (!normalized) throw new Error(`${label} host is required`);
if (
normalized === 'localhost' ||
normalized === 'localhost.localdomain' ||
normalized.endsWith('.localhost.localdomain') ||
normalized.endsWith('.localhost') ||
normalized.endsWith('.local') ||
normalized.endsWith('.home.arpa') ||
normalized.endsWith('.internal') ||
normalized.endsWith('.lan') ||
normalized === 'metadata.google.internal' ||
normalized === 'localtest.me' ||
normalized.endsWith('.localtest.me') ||
normalized === 'lvh.me' ||
normalized.endsWith('.lvh.me') ||
normalized === 'vcap.me' ||
normalized.endsWith('.vcap.me') ||
normalized === 'nip.io' ||
normalized.endsWith('.nip.io') ||
normalized === 'sslip.io' ||
normalized.endsWith('.sslip.io') ||
normalized === 'xip.io' ||
normalized.endsWith('.xip.io')
) {
throw new Error(`${label} host is not allowed`);
}
const ipv4 = parseIpv4Address(normalized);
if (ipv4 && isBlockedIpv4Address(ipv4)) {
throw new Error(`${label} host is not allowed`);
}
if (isBlockedIpv6Address(normalized)) {
throw new Error(`${label} host is not allowed`);
}
}
export function normalizeBackupEndpointUrl(value: string, label: string): string {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`${label} must be a valid URL`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`${label} must start with http:// or https://`);
}
if (parsed.username || parsed.password) {
throw new Error(`${label} must not include credentials`);
}
if (parsed.search || parsed.hash) {
throw new Error(`${label} must not include query or fragment`);
}
assertBackupEndpointHostAllowed(parsed.hostname, label);
return parsed.toString().replace(/\/+$/, '');
}
function assertValidTimeZone(timezone: string): string { function assertValidTimeZone(timezone: string): string {
try { try {
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date()); new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
@@ -123,7 +280,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
if (!allowIncomplete || endpoint) { if (!allowIncomplete || endpoint) {
if (!endpoint) throw new Error('S3 endpoint is required'); if (!endpoint) throw new Error('S3 endpoint is required');
if (!/^https?:\/\//i.test(endpoint)) throw new Error('S3 endpoint must start with http:// or https://'); normalizeBackupEndpointUrl(endpoint, 'S3 endpoint');
} }
if (!allowIncomplete || bucket) { if (!allowIncomplete || bucket) {
if (!bucket) throw new Error('S3 bucket is required'); if (!bucket) throw new Error('S3 bucket is required');
@@ -136,7 +293,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
} }
return { return {
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '', endpoint: endpoint ? normalizeBackupEndpointUrl(endpoint, 'S3 endpoint') : '',
bucket, bucket,
addressingStyle, addressingStyle,
region, region,
@@ -155,7 +312,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
if (!allowIncomplete || baseUrl) { if (!allowIncomplete || baseUrl) {
if (!baseUrl) throw new Error('WebDAV server URL is required'); if (!baseUrl) throw new Error('WebDAV server URL is required');
if (!/^https?:\/\//i.test(baseUrl)) throw new Error('WebDAV server URL must start with http:// or https://'); normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL');
} }
if (!allowIncomplete || username) { if (!allowIncomplete || username) {
if (!username) throw new Error('WebDAV username is required'); if (!username) throw new Error('WebDAV username is required');
@@ -165,7 +322,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
} }
return { return {
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '', baseUrl: baseUrl ? normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL') : '',
username, username,
password, password,
remotePath, remotePath,
+10 -3
View File
@@ -254,6 +254,10 @@ function cloneRows(rows: SqlRow[]): SqlRow[] {
return rows.map((row) => ({ ...row })); return rows.map((row) => ({ ...row }));
} }
function normalizeAccountPasskeyPurpose(value: unknown): 'login' | 'twoFactor' {
return value == null ? 'login' : String(value).trim() === 'twoFactor' ? 'twoFactor' : 'login';
}
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] { function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
let replaced = false; let replaced = false;
const nextRows = rows.map((row) => { const nextRows = rows.map((row) => {
@@ -297,13 +301,16 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []), config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
users: cloneRows(payload.users || []).map((row) => ({ users: cloneRows(payload.users || []).map((row) => ({
...row, ...row,
verify_devices: row.verify_devices ?? 1, verify_devices: row.verify_devices ?? 0,
yubikey_nfc: row.yubikey_nfc ?? 0, yubikey_nfc: row.yubikey_nfc ?? 0,
})), })),
domain_settings: cloneRows(payload.domain_settings || []), domain_settings: cloneRows(payload.domain_settings || []),
user_revisions: cloneRows(payload.user_revisions || []), user_revisions: cloneRows(payload.user_revisions || []),
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []), trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
webauthn_credentials: cloneRows(payload.webauthn_credentials || []), webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({
...row,
purpose: normalizeAccountPasskeyPurpose(row.purpose),
})),
folders: cloneRows(payload.folders || []), folders: cloneRows(payload.folders || []),
ciphers: cloneRows(payload.ciphers || []).map((row) => ({ ciphers: cloneRows(payload.ciphers || []).map((row) => ({
...row, ...row,
@@ -668,7 +675,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
buildInsertStatements( buildInsertStatements(
db, db,
tableName('webauthn_credentials'), tableName('webauthn_credentials'),
['id', 'user_id', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'], ['id', 'user_id', 'purpose', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
payload.webauthn_credentials || [] payload.webauthn_credentials || []
) )
); );
+6 -5
View File
@@ -3,6 +3,7 @@ import {
BackupDestinationType, BackupDestinationType,
S3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
normalizeBackupEndpointUrl,
} from './backup-config'; } from './backup-config';
export interface BackupUploadResult { export interface BackupUploadResult {
@@ -215,7 +216,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
if (destination.type === 'webdav') { if (destination.type === 'webdav') {
const config = destination.destination as WebDavBackupDestination; const config = destination.destination as WebDavBackupDestination;
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required'); if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
if (!/^https?:\/\//i.test(String(config.baseUrl || '').trim())) throw new Error('WebDAV server URL must start with http:// or https://'); normalizeBackupEndpointUrl(String(config.baseUrl || '').trim(), 'WebDAV server URL');
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required'); if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
if (!String(config.password || '')) throw new Error('WebDAV password is required'); if (!String(config.password || '')) throw new Error('WebDAV password is required');
return; return;
@@ -223,7 +224,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
if (destination.type === 's3') { if (destination.type === 's3') {
const config = destination.destination as S3BackupDestination; const config = destination.destination as S3BackupDestination;
if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required'); if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('S3 endpoint must start with http:// or https://'); normalizeBackupEndpointUrl(String(config.endpoint || '').trim(), 'S3 endpoint');
if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required'); if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required'); if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required'); if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required');
@@ -252,7 +253,7 @@ async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, aut
Authorization: authHeader, Authorization: authHeader,
}, },
}); });
if ([200, 201, 204, 301, 302, 405].includes(response.status)) continue; if ([200, 201, 204, 405].includes(response.status)) continue;
throw new Error(`WebDAV directory creation failed: ${response.status}`); throw new Error(`WebDAV directory creation failed: ${response.status}`);
} }
} }
@@ -275,7 +276,7 @@ async function ensureWebDavDirectoryCached(
Authorization: authHeader, Authorization: authHeader,
}, },
}); });
if ([200, 201, 204, 301, 302, 405].includes(response.status)) { if ([200, 201, 204, 405].includes(response.status)) {
ensuredDirectories.add(current); ensuredDirectories.add(current);
continue; continue;
} }
@@ -518,7 +519,7 @@ async function signedS3Request(
config.region || 'auto' config.region || 'auto'
); );
return fetch(url.toString(), { return fetch(url, {
method, method,
headers: { headers: {
Authorization: authorization, Authorization: authorization,
+4 -2
View File
@@ -45,7 +45,8 @@ export async function upsertDevice(
await db await db
.prepare( .prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, ' +
'session_stamp=CASE WHEN devices.session_stamp IS NULL OR devices.session_stamp = ? THEN excluded.session_stamp ELSE devices.session_stamp END, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' + 'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' + 'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' + 'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
@@ -66,7 +67,8 @@ export async function upsertDevice(
existingDevice?.deviceNote ?? null, existingDevice?.deviceNote ?? null,
now, now,
now, now,
now now,
''
) )
.run(); .run();
} }
+91 -34
View File
@@ -11,16 +11,34 @@ export async function saveRefreshToken(
userId: string, userId: string,
expiresAtMs: number, expiresAtMs: number,
deviceIdentifier?: string | null, deviceIdentifier?: string | null,
deviceSessionStamp?: string | null deviceSessionStamp?: string | null,
securityStamp?: string | null,
clientType?: string | null,
absoluteExpiresAtMs?: number | null
): Promise<void> { ): Promise<void> {
await maybeCleanupExpiredRefreshTokens(Date.now()); await maybeCleanupExpiredRefreshTokens(Date.now());
const tokenKey = await refreshTokenKey(token); const tokenKey = await refreshTokenKey(token);
const now = Date.now();
await db await db
.prepare( .prepare(
'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' + 'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp' 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, ' +
'device_session_stamp=excluded.device_session_stamp, security_stamp=excluded.security_stamp, last_used_at=excluded.last_used_at, ' +
'absolute_expires_at=excluded.absolute_expires_at, client_type=excluded.client_type'
)
.bind(
tokenKey,
userId,
expiresAtMs,
deviceIdentifier ?? null,
deviceSessionStamp ?? null,
securityStamp ?? null,
now,
now,
absoluteExpiresAtMs ?? null,
clientType ?? null
) )
.bind(tokenKey, userId, expiresAtMs, deviceIdentifier ?? null, deviceSessionStamp ?? null)
.run(); .run();
} }
@@ -36,12 +54,25 @@ export async function getRefreshTokenRecord(
const tokenKey = await refreshTokenKey(token); const tokenKey = await refreshTokenKey(token);
const row = await db const row = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?') .prepare(
'SELECT user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type ' +
'FROM refresh_tokens WHERE token = ?'
)
.bind(tokenKey) .bind(tokenKey)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>(); .first<{
user_id: string;
expires_at: number;
device_identifier: string | null;
device_session_stamp: string | null;
security_stamp: string | null;
created_at: number | null;
last_used_at: number | null;
absolute_expires_at: number | null;
client_type: string | null;
}>();
if (!row) return null; if (!row) return null;
if (row.expires_at && row.expires_at < now) { if ((row.expires_at && row.expires_at < now) || (row.absolute_expires_at && row.absolute_expires_at < now)) {
await deleteRefreshTokenRecord(token); await deleteRefreshTokenRecord(token);
return null; return null;
} }
@@ -50,9 +81,62 @@ export async function getRefreshTokenRecord(
expiresAt: row.expires_at, expiresAt: row.expires_at,
deviceIdentifier: row.device_identifier ?? null, deviceIdentifier: row.device_identifier ?? null,
deviceSessionStamp: row.device_session_stamp ?? null, deviceSessionStamp: row.device_session_stamp ?? null,
securityStamp: row.security_stamp ?? null,
createdAt: row.created_at ?? null,
lastUsedAt: row.last_used_at ?? null,
absoluteExpiresAt: row.absolute_expires_at ?? null,
clientType: row.client_type ?? null,
}; };
} }
export async function extendRefreshTokenExpiry(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
requestedExpiresAtMs: number,
nowMs: number
): Promise<boolean> {
const tokenKey = await refreshTokenKey(token);
const result = await db
.prepare(
'UPDATE refresh_tokens SET ' +
'expires_at = CASE ' +
'WHEN absolute_expires_at IS NOT NULL AND absolute_expires_at < ? THEN absolute_expires_at ' +
'ELSE ? END, ' +
'last_used_at = ? ' +
'WHERE token = ? AND expires_at >= ? AND (absolute_expires_at IS NULL OR absolute_expires_at >= ?)'
)
.bind(requestedExpiresAtMs, requestedExpiresAtMs, nowMs, tokenKey, nowMs, nowMs)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function bindRefreshTokenSecurityStamp(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
securityStamp: string
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare('UPDATE refresh_tokens SET security_stamp = ? WHERE token = ? AND (security_stamp IS NULL OR security_stamp = ?)')
.bind(securityStamp, tokenKey, '')
.run();
}
export async function bindRefreshTokenDeviceStamp(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
deviceSessionStamp: string
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare('UPDATE refresh_tokens SET device_session_stamp = ? WHERE token = ? AND (device_session_stamp IS NULL OR device_session_stamp = ?)')
.bind(deviceSessionStamp, tokenKey, '')
.run();
}
export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> { export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> {
const tokenKey = await refreshTokenKey(token); const tokenKey = await refreshTokenKey(token);
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run(); await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
@@ -71,30 +155,3 @@ export async function deleteRefreshTokensByDevice(db: D1Database, userId: string
.run(); .run();
return Number(result.meta.changes ?? 0); return Number(result.meta.changes ?? 0);
} }
export async function constrainRefreshTokenExpiry(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
maxExpiresAtMs: number
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare(
'UPDATE refresh_tokens ' +
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
'WHERE token = ?'
)
.bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey)
.run();
await db
.prepare(
'UPDATE refresh_tokens ' +
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
'WHERE token = ?'
)
.bind(maxExpiresAtMs, maxExpiresAtMs, token)
.run();
}
+14 -3
View File
@@ -14,11 +14,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' + 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 0, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN master_password_hint TEXT', 'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', 'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'', 'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1', 'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE users ADD COLUMN totp_secret TEXT', 'ALTER TABLE users ADD COLUMN totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT', 'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT', 'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
@@ -74,11 +74,20 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE sends ADD COLUMN emails TEXT', 'ALTER TABLE sends ADD COLUMN emails TEXT',
'CREATE TABLE IF NOT EXISTS refresh_tokens (' + 'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, ' + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, security_stamp TEXT, created_at INTEGER, last_used_at INTEGER, absolute_expires_at INTEGER, client_type TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)', 'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT', 'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT', 'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN security_stamp TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN created_at INTEGER',
'ALTER TABLE refresh_tokens ADD COLUMN last_used_at INTEGER',
'ALTER TABLE refresh_tokens ADD COLUMN absolute_expires_at INTEGER',
'ALTER TABLE refresh_tokens ADD COLUMN client_type TEXT',
"UPDATE refresh_tokens SET security_stamp = (SELECT users.security_stamp FROM users WHERE users.id = refresh_tokens.user_id) WHERE security_stamp IS NULL OR security_stamp = ''",
"UPDATE refresh_tokens SET created_at = CAST(strftime('%s','now') AS INTEGER) * 1000 WHERE created_at IS NULL",
"UPDATE refresh_tokens SET last_used_at = created_at WHERE last_used_at IS NULL",
'UPDATE refresh_tokens SET absolute_expires_at = expires_at WHERE absolute_expires_at IS NULL',
'CREATE TABLE IF NOT EXISTS invites (' + 'CREATE TABLE IF NOT EXISTS invites (' +
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
@@ -118,6 +127,8 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT', 'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)',
"UPDATE refresh_tokens SET device_session_stamp = (SELECT devices.session_stamp FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier) WHERE device_identifier IS NOT NULL AND (device_session_stamp IS NULL OR device_session_stamp = '') AND EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier)",
"UPDATE refresh_tokens SET client_type = CASE WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type IN (0, 1)) THEN 'mobile' WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type = 14) THEN 'web' ELSE 'other' END WHERE client_type IS NULL OR client_type = ''",
'CREATE TABLE IF NOT EXISTS auth_requests (' + 'CREATE TABLE IF NOT EXISTS auth_requests (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
+1 -1
View File
@@ -23,7 +23,7 @@ function mapUserRow(row: any): User {
securityStamp: row.security_stamp, securityStamp: row.security_stamp,
role: row.role === 'admin' ? 'admin' : 'user', role: row.role === 'admin' ? 'admin' : 'user',
status: row.status === 'banned' ? 'banned' : 'active', status: row.status === 'banned' ? 'banned' : 'active',
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices, verifyDevices: row.verify_devices == null ? false : !!row.verify_devices,
totpSecret: row.totp_secret ?? null, totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null, totpRecoveryCode: row.totp_recovery_code ?? null,
yubikeyKey1: row.yubikey_key1 ?? null, yubikeyKey1: row.yubikey_key1 ?? null,
+24 -10
View File
@@ -87,10 +87,12 @@ import {
saveSend as saveStoredSend, saveSend as saveStoredSend,
} from './storage-send-repo'; } from './storage-send-repo';
import { import {
constrainRefreshTokenExpiry as constrainStoredRefreshTokenExpiry, bindRefreshTokenDeviceStamp as bindStoredRefreshTokenDeviceStamp,
bindRefreshTokenSecurityStamp as bindStoredRefreshTokenSecurityStamp,
deleteRefreshToken as deleteStoredRefreshToken, deleteRefreshToken as deleteStoredRefreshToken,
deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice, deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice,
deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId, deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId,
extendRefreshTokenExpiry as extendStoredRefreshTokenExpiry,
getRefreshTokenRecord as findStoredRefreshTokenRecord, getRefreshTokenRecord as findStoredRefreshTokenRecord,
saveRefreshToken as saveStoredRefreshToken, saveRefreshToken as saveStoredRefreshToken,
} from './storage-refresh-token-repo'; } from './storage-refresh-token-repo';
@@ -162,7 +164,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value // changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version. // differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-07-05-passkey-2fa'; const STORAGE_SCHEMA_VERSION = '2026-07-13-refresh-session-reuse';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
// D1-backed storage. // D1-backed storage.
@@ -632,9 +634,13 @@ export class StorageService {
userId: string, userId: string,
expiresAtMs?: number, expiresAtMs?: number,
deviceIdentifier?: string | null, deviceIdentifier?: string | null,
deviceSessionStamp?: string | null deviceSessionStamp?: string | null,
securityStamp?: string | null,
clientType?: string | null,
absoluteExpiresAtMs?: number | null
): Promise<void> { ): Promise<void> {
const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs); const now = Date.now();
const expiresAt = expiresAtMs ?? (now + LIMITS.auth.refreshTokenDefaultSlidingTtlMs);
await saveStoredRefreshToken( await saveStoredRefreshToken(
this.db, this.db,
this.refreshTokenKey.bind(this), this.refreshTokenKey.bind(this),
@@ -643,7 +649,10 @@ export class StorageService {
userId, userId,
expiresAt, expiresAt,
deviceIdentifier, deviceIdentifier,
deviceSessionStamp deviceSessionStamp,
securityStamp,
clientType,
absoluteExpiresAtMs ?? (now + LIMITS.auth.refreshTokenAbsoluteTtlMs)
); );
} }
@@ -717,11 +726,16 @@ export class StorageService {
return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier); return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier);
} }
// Keep a short overlap window for rotated refresh token to reduce async extendRefreshTokenExpiry(token: string, requestedExpiresAtMs: number, nowMs: number = Date.now()): Promise<boolean> {
// multi-context refresh races (e.g. browser extension popup/background). return extendStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, requestedExpiresAtMs, nowMs);
// Expiry is only tightened, never extended. }
async constrainRefreshTokenExpiry(token: string, maxExpiresAtMs: number): Promise<void> {
await constrainStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, maxExpiresAtMs); async bindRefreshTokenSecurityStamp(token: string, securityStamp: string): Promise<void> {
await bindStoredRefreshTokenSecurityStamp(this.db, this.refreshTokenKey.bind(this), token, securityStamp);
}
async bindRefreshTokenDeviceStamp(token: string, deviceSessionStamp: string): Promise<void> {
await bindStoredRefreshTokenDeviceStamp(this.db, this.refreshTokenKey.bind(this), token, deviceSessionStamp);
} }
private async trustedTwoFactorTokenKey(token: string): Promise<string> { private async trustedTwoFactorTokenKey(token: string): Promise<string> {
+5
View File
@@ -402,6 +402,11 @@ export interface RefreshTokenRecord {
expiresAt: number; expiresAt: number;
deviceIdentifier: string | null; deviceIdentifier: string | null;
deviceSessionStamp: string | null; deviceSessionStamp: string | null;
securityStamp: string | null;
createdAt: number | null;
lastUsedAt: number | null;
absoluteExpiresAt: number | null;
clientType: string | null;
} }
export interface TrustedDeviceTokenSummary { export interface TrustedDeviceTokenSummary {
+2 -15
View File
@@ -12,6 +12,7 @@ import type {
WebAuthnPrfDecryptionOption, WebAuthnPrfDecryptionOption,
} from '../types'; } from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey'; import { base64UrlToBytes, bytesToBase64Url } from './passkey';
import { getConfiguredWebAuthnAllowedOrigins } from './origins';
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1'; const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000; const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
@@ -159,22 +160,8 @@ export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: s
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim(); const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
const rpId = configuredRpId || url.hostname; const rpId = configuredRpId || url.hostname;
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME; const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
const configuredOrigins = String(env.WEBAUTHN_ALLOWED_ORIGINS || '') const configuredOrigins = getConfiguredWebAuthnAllowedOrigins(env);
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const origins = new Set<string>([url.origin, ...configuredOrigins]); const origins = new Set<string>([url.origin, ...configuredOrigins]);
const requestOrigin = request.headers.get('Origin');
if (
requestOrigin
&& (
requestOrigin.startsWith('chrome-extension://')
|| requestOrigin.startsWith('moz-extension://')
|| requestOrigin.startsWith('safari-web-extension://')
)
) {
origins.add(requestOrigin);
}
return { rpId, rpName, origins: Array.from(origins) }; return { rpId, rpName, origins: Array.from(origins) };
} }
+50
View File
@@ -0,0 +1,50 @@
import type { Env } from '../types';
// Keep this list aligned with Bitwarden server's default FIDO2 origins.
// These are the stable store IDs for the official Chromium-based extensions.
export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
'chrome-extension://nngceckbapebfimnlniiiahkandclblb',
'chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh',
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
] as const;
export function normalizeOrigin(value: unknown): string | null {
const raw = String(value || '').trim();
if (!raw) return null;
try {
const url = new URL(raw);
if (!url.protocol || !url.host) return null;
return `${url.protocol}//${url.host}`;
} catch {
return null;
}
}
export function isBrowserExtensionOrigin(origin: unknown): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && (
normalized.startsWith('chrome-extension://')
|| normalized.startsWith('moz-extension://')
|| normalized.startsWith('safari-web-extension://')
);
}
export function getConfiguredWebAuthnAllowedOrigins(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
): string[] {
const seen = new Set<string>(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS);
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
const origin = normalizeOrigin(item);
if (origin) seen.add(origin);
}
return Array.from(seen);
}
export function isConfiguredWebAuthnAllowedOrigin(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>,
origin: unknown
): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && getConfiguredWebAuthnAllowedOrigins(env).includes(normalized);
}
+3 -1
View File
@@ -30,7 +30,9 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
forcePasswordReset: false, forcePasswordReset: false,
avatarColor: null, avatarColor: null,
creationDate: user.createdAt, creationDate: user.createdAt,
verifyDevices: user.verifyDevices !== false, // New-device verification is not supported without an email delivery channel.
// Always report disabled so clients do not present a false security posture.
verifyDevices: false,
role: user.role, role: user.role,
status: user.status, status: user.status,
object: 'profile', object: 'profile',
+26 -20
View File
@@ -1,4 +1,10 @@
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import type { Env } from '../types';
import {
isBrowserExtensionOrigin,
isConfiguredWebAuthnAllowedOrigin,
normalizeOrigin,
} from './origins';
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS'; const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
const DEFAULT_CORS_HEADERS = [ const DEFAULT_CORS_HEADERS = [
@@ -18,14 +24,6 @@ const DEFAULT_CORS_HEADERS = [
'X-NodeWarden-Web-Session', 'X-NodeWarden-Web-Session',
]; ];
function isExtensionOrigin(origin: string): boolean {
return (
origin.startsWith('chrome-extension://')
|| origin.startsWith('moz-extension://')
|| origin.startsWith('safari-web-extension://')
);
}
function isWildcardCorsPath(path: string): boolean { function isWildcardCorsPath(path: string): boolean {
return ( return (
path.startsWith('/icons/') path.startsWith('/icons/')
@@ -38,18 +36,19 @@ function isWildcardCorsPath(path: string): boolean {
); );
} }
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } { function getCorsPolicy(request: Request, env: Env): { allowOrigin: string | null; allowCredentials: boolean } {
const url = new URL(request.url); const url = new URL(request.url);
const origin = request.headers.get('Origin'); const originHeader = request.headers.get('Origin');
if (!origin) { if (!originHeader) {
return isWildcardCorsPath(url.pathname) return isWildcardCorsPath(url.pathname)
? { allowOrigin: '*', allowCredentials: false } ? { allowOrigin: '*', allowCredentials: false }
: { allowOrigin: null, allowCredentials: false }; : { allowOrigin: null, allowCredentials: false };
} }
const origin = normalizeOrigin(originHeader);
if (origin === url.origin) { if (origin === url.origin) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
} }
if (isExtensionOrigin(origin)) { if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
} }
if (isWildcardCorsPath(url.pathname)) { if (isWildcardCorsPath(url.pathname)) {
@@ -58,7 +57,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
return { allowOrigin: null, allowCredentials: false }; return { allowOrigin: null, allowCredentials: false };
} }
function buildCorsHeaders(request: Request): Record<string, string> { function buildCorsHeaders(request: Request, env: Env): Record<string, string> {
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '') const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
.split(',') .split(',')
.map((value) => value.trim()) .map((value) => value.trim())
@@ -72,7 +71,7 @@ function buildCorsHeaders(request: Request): Record<string, string> {
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds), 'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
}; };
const corsPolicy = getCorsPolicy(request); const corsPolicy = getCorsPolicy(request, env);
if (corsPolicy.allowOrigin) { if (corsPolicy.allowOrigin) {
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin; headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
if (corsPolicy.allowCredentials) { if (corsPolicy.allowCredentials) {
@@ -86,7 +85,8 @@ function buildCorsHeaders(request: Request): Record<string, string> {
export function applyCors( export function applyCors(
request: Request, request: Request,
response: Response response: Response,
env: Env
): Response { ): Response {
// WebSocket upgrade responses must be returned untouched. // WebSocket upgrade responses must be returned untouched.
const webSocket = (response as Response & { webSocket?: unknown }).webSocket; const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
@@ -95,7 +95,7 @@ export function applyCors(
} }
const headers = new Headers(response.headers); const headers = new Headers(response.headers);
const corsHeaders = buildCorsHeaders(request); const corsHeaders = buildCorsHeaders(request, env);
for (const [k, v] of Object.entries(corsHeaders)) { for (const [k, v] of Object.entries(corsHeaders)) {
headers.set(k, v); headers.set(k, v);
} }
@@ -144,7 +144,12 @@ export function unsupportedResponse(message: string = 'This feature is not suppo
} }
// Identity endpoint error response (for /identity/connect/token) // Identity endpoint error response (for /identity/connect/token)
export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response { export function identityErrorResponse(
message: string,
error: string = 'invalid_grant',
status: number = 400,
headers: Record<string, string> = {}
): Response {
return jsonResponse( return jsonResponse(
{ {
error: error, error: error,
@@ -154,15 +159,16 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
Object: 'error', Object: 'error',
}, },
}, },
status status,
{ 'Cache-Control': 'no-store', Pragma: 'no-cache', ...headers }
); );
} }
// Handle CORS preflight // Handle CORS preflight
export function handleCors(request: Request): Response { export function handleCors(request: Request, env: Env): Response {
return new Response(null, { return new Response(null, {
status: 204, status: 204,
headers: buildCorsHeaders(request), headers: buildCorsHeaders(request, env),
}); });
} }
+1 -1
View File
@@ -9,7 +9,7 @@
script-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';
img-src 'self' data:; img-src 'self' data:;
connect-src 'self'; connect-src 'self' https://api.pwnedpasswords.com;
font-src 'self'; font-src 'self';
form-action 'self'; form-action 'self';
base-uri 'self'; base-uri 'self';
+49 -17
View File
@@ -170,6 +170,7 @@
(function () { (function () {
var params = new URLSearchParams(window.location.search); var params = new URLSearchParams(window.location.search);
var sentSuccess = false; var sentSuccess = false;
var allowedParentOriginsPromise = null;
var text = pickText(params.get("locale") || navigator.language || "en"); var text = pickText(params.get("locale") || navigator.language || "en");
document.documentElement.lang = params.get("locale") || navigator.language || "en"; document.documentElement.lang = params.get("locale") || navigator.language || "en";
@@ -227,24 +228,55 @@
return out; return out;
} }
function trustedParentOrigin() { function normalizeOrigin(value) {
var parent = decodeRepeated(params.get("parent")); if (!value) return "";
if (!parent) return "";
try { try {
var parentUrl = new URL(parent); var url = new URL(value);
if ( if (!url.protocol || !url.host) return "";
parentUrl.protocol === "chrome-extension:" || return url.protocol + "//" + url.host;
parentUrl.protocol === "moz-extension:" ||
parentUrl.protocol === "safari-web-extension:"
) {
return parentUrl.protocol + "//" + parentUrl.host;
}
if (parentUrl.origin === window.location.origin) {
return parentUrl.origin;
}
} catch (_error) { } catch (_error) {
return ""; return "";
} }
}
function isExtensionOrigin(origin) {
return (
origin.indexOf("chrome-extension://") === 0 ||
origin.indexOf("moz-extension://") === 0 ||
origin.indexOf("safari-web-extension://") === 0
);
}
function allowedParentOrigins() {
if (allowedParentOriginsPromise) return allowedParentOriginsPromise;
allowedParentOriginsPromise = fetch("/api/web-bootstrap", {
headers: { Accept: "application/json" },
credentials: "omit",
}).then(function (response) {
if (!response.ok) return [];
return response.json();
}).then(function (body) {
var origins = Array.isArray(body && body.webAuthnAllowedOrigins)
? body.webAuthnAllowedOrigins
: [];
return origins.map(normalizeOrigin).filter(Boolean);
}).catch(function () {
return [];
});
return allowedParentOriginsPromise;
}
function trustedParentOrigin(allowedOrigins) {
var parent = decodeRepeated(params.get("parent"));
if (!parent) return "";
var parentOrigin = normalizeOrigin(parent);
if (!parentOrigin) return "";
if (parentOrigin === window.location.origin) {
return parentOrigin;
}
if (isExtensionOrigin(parentOrigin) && allowedOrigins.indexOf(parentOrigin) >= 0) {
return parentOrigin;
}
return ""; return "";
} }
@@ -258,8 +290,8 @@
return copy; return copy;
} }
function postResult(message) { async function postResult(message) {
var parentOrigin = trustedParentOrigin(); var parentOrigin = trustedParentOrigin(await allowedParentOrigins());
if (parentOrigin) { if (parentOrigin) {
if (window.opener && !window.opener.closed) { if (window.opener && !window.opener.closed) {
window.opener.postMessage(message, parentOrigin); window.opener.postMessage(message, parentOrigin);
@@ -371,7 +403,7 @@
if (!(credential instanceof PublicKeyCredential)) { if (!(credential instanceof PublicKeyCredential)) {
throw new Error("No security key was selected."); throw new Error("No security key was selected.");
} }
postResult({ await postResult({
command: "webAuthnResult", command: "webAuthnResult",
data: credentialToDataString(credential), data: credentialToDataString(credential),
remember: rememberEl.checked, remember: rememberEl.checked,
+66 -4
View File
@@ -68,6 +68,7 @@ import { t } from '@/lib/i18n';
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify'; import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress'; import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
import { clearOfflineUnlockRecord } from '@/lib/offline-auth'; import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
import { clearPasswordSecurityCache } from '@/lib/password-security-cache';
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt'; import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker'; import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
import { import {
@@ -111,6 +112,8 @@ const APP_ROUTE_PATHS = [
'/', '/',
'/vault', '/vault',
'/vault/totp', '/vault/totp',
'/security/password-health',
'/generator',
'/sends', '/sends',
'/admin', '/admin',
'/logs', '/logs',
@@ -250,6 +253,8 @@ export default function App() {
const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes()); const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes());
const [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction()); const [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction());
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email); const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email);
const [lockedSessionRefreshError, setLockedSessionRefreshError] = useState('');
const [lockedSessionRetryKey, setLockedSessionRetryKey] = useState(0);
const [confirm, setConfirm] = useState<AppConfirmState | null>(null); const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
const [mobileLayout, setMobileLayout] = useState(false); const [mobileLayout, setMobileLayout] = useState(false);
@@ -266,6 +271,7 @@ export default function App() {
const [vaultDecryptError, setVaultDecryptError] = useState(''); const [vaultDecryptError, setVaultDecryptError] = useState('');
const [sendsDecryptDone, setSendsDecryptDone] = useState(false); const [sendsDecryptDone, setSendsDecryptDone] = useState(false);
const sessionRef = useRef<SessionState | null>(initialBootstrap.session); const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
const lockedSessionRetryAttemptRef = useRef(0);
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {}); const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {}); const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
@@ -385,6 +391,10 @@ export default function App() {
} }
}, [phase, profile, session]); }, [phase, profile, session]);
useEffect(() => {
if (phase !== 'app') clearPasswordSecurityCache();
}, [phase]);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes)); window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
@@ -496,13 +506,15 @@ export default function App() {
if (phase !== 'locked' || !session) return; if (phase !== 'locked' || !session) return;
if (IS_DEMO_MODE) return; if (IS_DEMO_MODE) return;
let cancelled = false; let cancelled = false;
let retryTimerId: number | null = null;
void (async () => { void (async () => {
const result = await hydrateLockedSession(session, profile); const result = await hydrateLockedSession(session, profile);
if (cancelled) return; if (cancelled) return;
if (!result.session) { if (result.kind === 'expired') {
setSession(null); setSession(null);
setProfile(null); setProfile(null);
setUnlockPreparing(false); setUnlockPreparing(false);
setLockedSessionRefreshError('');
setPhase('login'); setPhase('login');
if (location !== '/login') navigate('/login'); if (location !== '/login') navigate('/login');
return; return;
@@ -511,11 +523,43 @@ export default function App() {
if (result.profile) { if (result.profile) {
setProfile(stripProfileSecrets(result.profile)); setProfile(stripProfileSecrets(result.profile));
} }
if (result.kind === 'transient') {
setUnlockPreparing(false);
setLockedSessionRefreshError(result.message || t('txt_session_refresh_temporarily_unavailable'));
const retrySchedule = [2_000, 5_000, 15_000, 30_000, 60_000];
const scheduledDelay = retrySchedule[Math.min(lockedSessionRetryAttemptRef.current, retrySchedule.length - 1)];
lockedSessionRetryAttemptRef.current += 1;
const retryAfterMs = Math.min(60_000, Math.max(scheduledDelay, result.retryAfterMs || 0));
retryTimerId = window.setTimeout(() => {
setLockedSessionRetryKey((value) => value + 1);
}, retryAfterMs);
return;
}
lockedSessionRetryAttemptRef.current = 0;
setLockedSessionRefreshError('');
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
if (retryTimerId !== null) window.clearTimeout(retryTimerId);
}; };
}, [phase, session?.email, location, navigate]); }, [phase, session?.email, location, navigate, lockedSessionRetryKey]);
useEffect(() => {
if (!lockedSessionRefreshError || phase !== 'locked') return;
const retryNow = () => {
lockedSessionRetryAttemptRef.current = 0;
setLockedSessionRetryKey((value) => value + 1);
};
const handleVisibility = () => {
if (document.visibilityState === 'visible') retryNow();
};
window.addEventListener('online', retryNow);
document.addEventListener('visibilitychange', handleVisibility);
return () => {
window.removeEventListener('online', retryNow);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [lockedSessionRefreshError, phase]);
async function finalizeLogin(login: CompletedLogin) { async function finalizeLogin(login: CompletedLogin) {
loginScopedBackupRepairAuthRef.current = loginScopedBackupRepairAuthRef.current =
@@ -529,6 +573,7 @@ export default function App() {
setSession(login.session); setSession(login.session);
setProfile(login.profile); setProfile(login.profile);
setUnlockPreparing(false); setUnlockPreparing(false);
setLockedSessionRefreshError('');
setPendingTotp(null); setPendingTotp(null);
setPendingTotpMode(null); setPendingTotpMode(null);
setPendingPasskeyPassword(null); setPendingPasskeyPassword(null);
@@ -871,11 +916,13 @@ export default function App() {
setDecryptedFolders([]); setDecryptedFolders([]);
setDecryptedCiphers([]); setDecryptedCiphers([]);
setDecryptedSends([]); setDecryptedSends([]);
clearPasswordSecurityCache();
setUnlockPassword(''); setUnlockPassword('');
setPendingTotp(null); setPendingTotp(null);
setPendingTotpMode(null); setPendingTotpMode(null);
setTotpCode(''); setTotpCode('');
setUnlockPreparing(false); setUnlockPreparing(false);
setLockedSessionRefreshError('');
setPhase('locked'); setPhase('locked');
navigate('/lock'); navigate('/lock');
} }
@@ -892,6 +939,7 @@ export default function App() {
setSession(null); setSession(null);
clearProfileSnapshot(); clearProfileSnapshot();
clearOfflineUnlockRecord(); clearOfflineUnlockRecord();
clearPasswordSecurityCache();
setProfile(null); setProfile(null);
setUnlockPreparing(false); setUnlockPreparing(false);
setPendingTotp(null); setPendingTotp(null);
@@ -1167,7 +1215,6 @@ export default function App() {
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest); const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
await respondToAuthRequest(authedFetch, authRequest.id, { await respondToAuthRequest(authedFetch, authRequest.id, {
key, key,
masterPasswordHash: null,
deviceIdentifier: getCurrentDeviceIdentifier(), deviceIdentifier: getCurrentDeviceIdentifier(),
requestApproved: true, requestApproved: true,
}); });
@@ -1848,6 +1895,8 @@ export default function App() {
}); });
const adminActions = useAdminActions({ const adminActions = useAdminActions({
authedFetch, authedFetch,
email: String(profile?.email || session?.email || ''),
defaultKdfIterations,
onNotify: pushToast, onNotify: pushToast,
onSetConfirm: setConfirm, onSetConfirm: setConfirm,
refetchUsers: usersQuery.refetch, refetchUsers: usersQuery.refetch,
@@ -1902,13 +1951,17 @@ export default function App() {
const mobilePrimaryRoute = const mobilePrimaryRoute =
location === '/sends' location === '/sends'
? '/sends' ? '/sends'
: location === '/generator'
? '/generator'
: location === '/vault/totp' : location === '/vault/totp'
? '/vault/totp' ? '/vault/totp'
: location === '/vault' : location === '/vault'
? '/vault' ? '/vault'
: '/settings'; : '/settings';
const currentPageTitle = (() => { const currentPageTitle = (() => {
if (location === '/security/password-health') return t('txt_password_security');
if (location === '/vault/totp') return t('txt_verification_code'); if (location === '/vault/totp') return t('txt_verification_code');
if (location === '/generator') return t('txt_password_generator');
if (location === '/sends') return t('nav_sends'); if (location === '/sends') return t('nav_sends');
if (location === '/admin') return t('nav_admin_panel'); if (location === '/admin') return t('nav_admin_panel');
if (location === '/logs') return t('nav_log_center'); if (location === '/logs') return t('nav_log_center');
@@ -2121,7 +2174,10 @@ export default function App() {
const hash = await deriveCurrentMasterPasswordHash(masterPassword); const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress); return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
}, },
onInspectRemoteBackup: backupActions.inspectRemoteBackup, onInspectRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.inspectRemoteBackup(hash, destinationId, path);
},
onDeleteRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => { onDeleteRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword); const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.deleteRemoteBackup(hash, destinationId, path); return backupActions.deleteRemoteBackup(hash, destinationId, path);
@@ -2204,6 +2260,7 @@ export default function App() {
unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined} unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined}
unlockReady={!!session?.email} unlockReady={!!session?.email}
unlockPreparing={unlockPreparing} unlockPreparing={unlockPreparing}
sessionRefreshError={lockedSessionRefreshError}
loginValues={loginValues} loginValues={loginValues}
pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null} pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null}
passkeyPassword={passkeyPassword} passkeyPassword={passkeyPassword}
@@ -2244,6 +2301,11 @@ export default function App() {
onLogout={logoutNow} onLogout={logoutNow}
onTogglePasswordHint={() => void handleTogglePasswordHint()} onTogglePasswordHint={() => void handleTogglePasswordHint()}
onShowLockedPasswordHint={handleShowLockedPasswordHint} onShowLockedPasswordHint={handleShowLockedPasswordHint}
onRetrySessionRefresh={() => {
lockedSessionRetryAttemptRef.current = 0;
setLockedSessionRefreshError('');
setLockedSessionRetryKey((value) => value + 1);
}}
/> />
<AppGlobalOverlays <AppGlobalOverlays
toasts={toasts} toasts={toasts}
+61 -153
View File
@@ -1,6 +1,6 @@
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact'; import { ArrowUpDown, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, Sparkles, Users } from 'lucide-preact';
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { Link } from 'wouter'; import { Link } from 'wouter';
import AppMainRoutes from '@/components/AppMainRoutes'; import AppMainRoutes from '@/components/AppMainRoutes';
import NetworkStatusBadge from '@/components/NetworkStatusBadge'; import NetworkStatusBadge from '@/components/NetworkStatusBadge';
@@ -28,19 +28,32 @@ interface AppAuthenticatedShellProps {
mainRoutesProps: AppMainRoutesProps; mainRoutesProps: AppMainRoutesProps;
} }
type NavLayoutMode = 'flat' | 'grouped-expanded' | 'grouped-smart'; const NAV_GROUPS_STORAGE_KEY = 'nodewarden.navGroups';
const NAV_LAYOUT_STORAGE_KEY = 'nodewarden.navLayoutMode'; const DEFAULT_EXPANDED_GROUPS = {
tools: true,
settings: true,
management: true,
};
function readNavLayoutMode(): NavLayoutMode { type NavGroup = keyof typeof DEFAULT_EXPANDED_GROUPS;
if (typeof window === 'undefined') return 'flat'; type ExpandedGroups = Record<NavGroup, boolean>;
function readExpandedGroups(): ExpandedGroups {
if (typeof window === 'undefined') return DEFAULT_EXPANDED_GROUPS;
try { try {
const saved = window.localStorage.getItem(NAV_LAYOUT_STORAGE_KEY); const saved = window.localStorage.getItem(NAV_GROUPS_STORAGE_KEY);
if (saved === 'flat' || saved === 'grouped-expanded' || saved === 'grouped-smart') return saved; if (!saved) return DEFAULT_EXPANDED_GROUPS;
const parsed = JSON.parse(saved) as Partial<ExpandedGroups>;
return {
tools: typeof parsed.tools === 'boolean' ? parsed.tools : DEFAULT_EXPANDED_GROUPS.tools,
settings: typeof parsed.settings === 'boolean' ? parsed.settings : DEFAULT_EXPANDED_GROUPS.settings,
management: typeof parsed.management === 'boolean' ? parsed.management : DEFAULT_EXPANDED_GROUPS.management,
};
} catch { } catch {
// Ignore local preference read failures. // Ignore local preference read failures.
} }
return 'flat'; return DEFAULT_EXPANDED_GROUPS;
} }
function isAdminProfile(profile: Profile | null): boolean { function isAdminProfile(profile: Profile | null): boolean {
@@ -55,58 +68,19 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
const isDomainRulesRoute = props.location === '/settings/domain-rules'; const isDomainRulesRoute = props.location === '/settings/domain-rules';
const isLogRoute = props.location === '/logs'; const isLogRoute = props.location === '/logs';
const isAdmin = isAdminProfile(props.profile); const isAdmin = isAdminProfile(props.profile);
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE; const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive; const [expandedGroups, setExpandedGroups] = useState<ExpandedGroups>(readExpandedGroups);
const flatSettingsActive = settingsActive && !deviceManagementActive;
const dataActive = props.location === '/backup' || props.isImportRoute;
const managementActive = props.location === '/admin' || props.location === '/logs';
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
const [expandedGroups, setExpandedGroups] = useState({
vault: true,
settings: false,
data: false,
management: false,
});
useEffect(() => { function toggleGroup(group: NavGroup): void {
const onPointerDown = (event: Event) => { setExpandedGroups((current) => {
if (!navLayoutPickerOpen) return; const next = { ...current, [group]: !current[group] };
const target = event.target as Node | null;
if (navLayoutPickerRef.current && target && !navLayoutPickerRef.current.contains(target)) {
setNavLayoutPickerOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setNavLayoutPickerOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [navLayoutPickerOpen]);
function setNavMode(mode: NavLayoutMode): void {
setNavLayoutMode(mode);
setNavLayoutPickerOpen(false);
try { try {
window.localStorage.setItem(NAV_LAYOUT_STORAGE_KEY, mode); window.localStorage.setItem(NAV_GROUPS_STORAGE_KEY, JSON.stringify(next));
} catch { } catch {
// Ignore local preference write failures. // Ignore local preference write failures.
} }
} return next;
});
function toggleGroup(group: keyof typeof expandedGroups): void {
setExpandedGroups((current) => ({ ...current, [group]: !current[group] }));
}
function groupOpen(group: keyof typeof expandedGroups, active: boolean): boolean {
if (navLayoutMode === 'grouped-expanded') return true;
return expandedGroups[group] || active;
} }
function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) { function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) {
@@ -127,18 +101,17 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
} }
function renderNavGroup( function renderNavGroup(
group: keyof typeof expandedGroups, group: NavGroup,
title: string, title: string,
icon: ComponentChildren, icon: ComponentChildren,
active: boolean,
children: ComponentChildren children: ComponentChildren
) { ) {
const open = groupOpen(group, active); const open = expandedGroups[group];
return ( return (
<div className={`side-nav-group ${open ? 'open' : ''}`}> <div className={`side-nav-group ${open ? 'open' : ''}`}>
<button <button
type="button" type="button"
className={`side-group-trigger ${active ? 'active' : ''}`} className="side-group-trigger"
aria-expanded={open} aria-expanded={open}
onClick={() => toggleGroup(group)} onClick={() => toggleGroup(group)}
> >
@@ -155,78 +128,40 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
); );
} }
const navLayoutOptions: Array<{ mode: NavLayoutMode; label: string }> = [
{
mode: 'flat',
label: t('txt_nav_layout_flat'),
},
{
mode: 'grouped-expanded',
label: t('txt_nav_layout_grouped_expanded'),
},
{
mode: 'grouped-smart',
label: t('txt_nav_layout_grouped_smart'),
},
];
const navLayoutLabel = navLayoutOptions.find((option) => option.mode === navLayoutMode)?.label || t('txt_nav_layout_flat');
const flatNav = (
<>
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderSideLink('/settings', flatSettingsActive, <SettingsIcon size={16} />, t('txt_settings'))}
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))}
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
</>
);
const groupedNav = ( const groupedNav = (
<> <>
{renderNavGroup( {renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
'vault',
t('nav_my_vault'),
<KeyRound size={16} />,
vaultActive,
<>
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
</>
)}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))} {renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderNavGroup( {renderNavGroup(
'settings', 'tools',
t('txt_settings'), t('nav_group_tools'),
<SettingsIcon size={16} />, <Sparkles size={16} />,
settingsActive,
<> <>
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))} {renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))} {renderSubLink('/generator', props.location === '/generator', t('nav_generator'))}
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))} {renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
</>
)}
{renderNavGroup(
'data',
t('nav_group_data_backup'),
<Cloud size={16} />,
dataActive,
<>
{isAdmin && renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
{renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))} {renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))}
</> </>
)} )}
{renderNavGroup( {renderNavGroup(
'management', 'settings',
t('nav_group_management'), t('txt_settings'),
<ShieldUser size={16} />, <SettingsIcon size={16} />,
managementActive,
<> <>
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))} {renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))} {renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
</>
)}
{isAdmin &&
renderNavGroup(
'management',
t('nav_group_system_management'),
<ShieldUser size={16} />,
<>
{renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
{renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
{renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
</> </>
)} )}
</> </>
@@ -277,38 +212,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<div className="app-main"> <div className="app-main">
<aside className="app-side"> <aside className="app-side">
<div className="side-nav-main"> <div className="side-nav-main">
{navLayoutMode === 'flat' ? flatNav : groupedNav} {groupedNav}
</div>
<div className="nav-layout-control" ref={navLayoutPickerRef}>
{navLayoutPickerOpen && (
<div className="nav-layout-menu" role="menu">
{navLayoutOptions.map((option) => (
<button
key={option.mode}
type="button"
className={`nav-layout-option ${navLayoutMode === option.mode ? 'active' : ''}`}
onClick={() => setNavMode(option.mode)}
role="menuitemradio"
aria-checked={navLayoutMode === option.mode}
>
<span className="nav-layout-option-text">
<strong>{option.label}</strong>
</span>
{navLayoutMode === option.mode && <Check size={15} className="nav-layout-check" />}
</button>
))}
</div>
)}
<button
type="button"
className={`nav-layout-trigger ${navLayoutPickerOpen ? 'active' : ''}`}
aria-haspopup="menu"
aria-expanded={navLayoutPickerOpen}
onClick={() => setNavLayoutPickerOpen((open) => !open)}
title={t('txt_nav_layout')}
>
<SlidersHorizontal size={15} />
</button>
</div> </div>
</aside> </aside>
<main className="content"> <main className="content">
@@ -327,6 +231,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<Clock3 size={18} /> <Clock3 size={18} />
<span>{t('txt_verification_code')}</span> <span>{t('txt_verification_code')}</span>
</Link> </Link>
<Link href="/generator" className={`mobile-tab ${props.mobilePrimaryRoute === '/generator' ? 'active' : ''}`}>
<Sparkles size={18} />
<span>{t('nav_generator')}</span>
</Link>
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}> <Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
<SendIcon size={18} /> <SendIcon size={18} />
<span>{t('nav_sends')}</span> <span>{t('nav_sends')}</span>
+32 -3
View File
@@ -12,7 +12,9 @@ export interface AppConfirmState {
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
hideCancel?: boolean; hideCancel?: boolean;
onConfirm: () => void; /** When true, dialog shows a master-password field and passes it to onConfirm. */
requireMasterPassword?: boolean;
onConfirm: (masterPassword?: string) => void;
onCancel?: () => void; onCancel?: () => void;
} }
@@ -63,6 +65,7 @@ function twoFactorProviderLabel(providerType: number): string {
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const [methodChooserOpen, setMethodChooserOpen] = useState(false); const [methodChooserOpen, setMethodChooserOpen] = useState(false);
const [confirmPassword, setConfirmPassword] = useState('');
const availableProviders = useMemo( const availableProviders = useMemo(
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders), () => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
[props.pendingTotpAvailableProviders] [props.pendingTotpAvailableProviders]
@@ -70,11 +73,16 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType); const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY; const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN; const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
const requireMasterPassword = !!props.confirm?.requireMasterPassword;
useEffect(() => { useEffect(() => {
setMethodChooserOpen(false); setMethodChooserOpen(false);
}, [props.pendingTotpOpen, props.pendingTotpProviderType]); }, [props.pendingTotpOpen, props.pendingTotpProviderType]);
useEffect(() => {
setConfirmPassword('');
}, [props.confirm?.title, props.confirm?.message, requireMasterPassword]);
return ( return (
<> <>
<ConfirmDialog <ConfirmDialog
@@ -86,9 +94,30 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
confirmText={props.confirm?.confirmText} confirmText={props.confirm?.confirmText}
cancelText={props.confirm?.cancelText} cancelText={props.confirm?.cancelText}
hideCancel={props.confirm?.hideCancel} hideCancel={props.confirm?.hideCancel}
onConfirm={() => props.confirm?.onConfirm()} confirmDisabled={requireMasterPassword && !confirmPassword.trim()}
onCancel={props.confirm?.onCancel || props.onCancelConfirm} onConfirm={() => {
if (requireMasterPassword && !confirmPassword.trim()) return;
props.confirm?.onConfirm(requireMasterPassword ? confirmPassword : undefined);
setConfirmPassword('');
}}
onCancel={() => {
setConfirmPassword('');
(props.confirm?.onCancel || props.onCancelConfirm)();
}}
>
{requireMasterPassword && (
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={confirmPassword}
onInput={(e) => setConfirmPassword((e.currentTarget as HTMLInputElement).value)}
/> />
</label>
)}
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={props.pendingTotpOpen} open={props.pendingTotpOpen}
+40 -16
View File
@@ -1,7 +1,7 @@
import { lazy, Suspense } from 'preact/compat'; import { lazy, Suspense } from 'preact/compat';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { Link, Route, Switch } from 'wouter'; import { Link, Route, Switch } from 'wouter';
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldCheck, ShieldUser } from 'lucide-preact';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import LoadingState from '@/components/LoadingState'; import LoadingState from '@/components/LoadingState';
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup'; import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
@@ -13,6 +13,8 @@ import type { ExportRequest } from '@/lib/export-formats';
const VaultPage = lazy(() => import('@/components/VaultPage')); const VaultPage = lazy(() => import('@/components/VaultPage'));
const SendsPage = lazy(() => import('@/components/SendsPage')); const SendsPage = lazy(() => import('@/components/SendsPage'));
const PasswordGeneratorPage = lazy(() => import('@/components/PasswordGeneratorPage'));
const PasswordSecurityPage = lazy(() => import('@/components/PasswordSecurityPage'));
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage')); const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
const SettingsPage = lazy(() => import('@/components/SettingsPage')); const SettingsPage = lazy(() => import('@/components/SettingsPage'));
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage')); const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
@@ -168,7 +170,7 @@ export interface AppMainRoutesProps {
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>; onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>; onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>; onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>; onInspectRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>; onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
@@ -207,6 +209,26 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
return ( return (
<Switch> <Switch>
<Route path="/security/password-health">
<div className="stack">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
<span className="btn-icon" aria-hidden="true">{"<"}</span>
{t('txt_back')}
</button>
</div>
)}
<Suspense fallback={<RouteContentFallback />}>
<PasswordSecurityPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} />
</Suspense>
</div>
</Route>
<Route path="/generator">
<Suspense fallback={<RouteContentFallback />}>
<PasswordGeneratorPage />
</Suspense>
</Route>
<Route path="/sends"> <Route path="/sends">
<Suspense fallback={<RouteContentFallback />}> <Suspense fallback={<RouteContentFallback />}>
<SendsPage <SendsPage
@@ -321,6 +343,19 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<Route path="/settings"> <Route path="/settings">
{props.profile ? ( {props.profile ? (
<section className="card mobile-settings-card settings-home-card"> <section className="card mobile-settings-card settings-home-card">
<div className="settings-home-section">
<h3>{t('nav_group_tools')}</h3>
<div className="mobile-settings-links">
<Link href="/security/password-health" className="mobile-settings-link">
<ShieldCheck size={18} />
<span>{t('nav_password_security')}</span>
</Link>
<Link href={props.importRoute} className="mobile-settings-link">
<ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span>
</Link>
</div>
</div>
<div className="settings-home-section"> <div className="settings-home-section">
<h3>{t('txt_settings')}</h3> <h3>{t('txt_settings')}</h3>
<div className="mobile-settings-links"> <div className="mobile-settings-links">
@@ -338,25 +373,14 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
</Link> </Link>
</div> </div>
</div> </div>
<div className="settings-home-section">
<h3>{t('nav_group_data_backup')}</h3>
<div className="mobile-settings-links">
<Link href={props.importRoute} className="mobile-settings-link">
<ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span>
</Link>
{isAdmin && ( {isAdmin && (
<div className="settings-home-section">
<h3>{t('nav_group_system_management')}</h3>
<div className="mobile-settings-links">
<Link href="/backup" className="mobile-settings-link"> <Link href="/backup" className="mobile-settings-link">
<Cloud size={18} /> <Cloud size={18} />
<span>{t('nav_backup_strategy')}</span> <span>{t('nav_backup_strategy')}</span>
</Link> </Link>
)}
</div>
</div>
{isAdmin && (
<div className="settings-home-section">
<h3>{t('nav_group_management')}</h3>
<div className="mobile-settings-links">
<Link href="/admin" className="mobile-settings-link"> <Link href="/admin" className="mobile-settings-link">
<ShieldUser size={18} /> <ShieldUser size={18} />
<span>{t('nav_admin_panel')}</span> <span>{t('nav_admin_panel')}</span>
+50 -2
View File
@@ -1,8 +1,9 @@
import { useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import { ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact'; import { AlertTriangle, ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import NetworkStatusBadge from '@/components/NetworkStatusBadge'; import NetworkStatusBadge from '@/components/NetworkStatusBadge';
import StandalonePageFrame from '@/components/StandalonePageFrame'; import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { getCurrentNetworkStatus, subscribeNetworkStatus, type NetworkStatus } from '@/lib/network-status';
interface LoginValues { interface LoginValues {
email: string; email: string;
@@ -26,6 +27,7 @@ interface AuthViewsProps {
pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null; pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null;
unlockReady: boolean; unlockReady: boolean;
unlockPreparing: boolean; unlockPreparing: boolean;
sessionRefreshError?: string;
loginValues: LoginValues; loginValues: LoginValues;
pendingPasskeyPasswordEmail?: string | null; pendingPasskeyPasswordEmail?: string | null;
passkeyPassword: string; passkeyPassword: string;
@@ -49,6 +51,7 @@ interface AuthViewsProps {
onLogout: () => void; onLogout: () => void;
onTogglePasswordHint: () => void; onTogglePasswordHint: () => void;
onShowLockedPasswordHint: () => void; onShowLockedPasswordHint: () => void;
onRetrySessionRefresh: () => void;
} }
function PasswordField(props: { function PasswordField(props: {
@@ -81,6 +84,36 @@ function PasswordField(props: {
); );
} }
function OfflineModeNotice() {
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
useEffect(() => subscribeNetworkStatus(setStatus), []);
if (status !== 'offline') return null;
return (
<div className="offline-mode-notice" role="alert" aria-live="assertive">
<div>
<strong>{t('txt_offline_mode_notice_title')}</strong>
<div className="offline-shortcut-list">
<div className="offline-shortcut-row">
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_windows')}</span>
<span className="offline-shortcut-value">
<span className="offline-shortcut-chord"><kbd>Ctrl</kbd><span>+</span><kbd>F5</kbd></span>
</span>
</div>
<div className="offline-shortcut-row">
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_macos')}</span>
<span className="offline-shortcut-value">
<span className="offline-shortcut-chord"><kbd>Command</kbd><span>+</span><kbd>Shift</kbd><span>+</span><kbd>R</kbd></span>
</span>
</div>
</div>
</div>
</div>
);
}
export default function AuthViews(props: AuthViewsProps) { export default function AuthViews(props: AuthViewsProps) {
const loginBusy = props.pendingAction === 'login'; const loginBusy = props.pendingAction === 'login';
const passkeyBusy = props.pendingAction === 'passkey'; const passkeyBusy = props.pendingAction === 'passkey';
@@ -99,6 +132,7 @@ export default function AuthViews(props: AuthViewsProps) {
props.onSubmitUnlock(); props.onSubmitUnlock();
}} }}
> >
<OfflineModeNotice />
<p className="muted standalone-muted">{props.emailForLock}</p> <p className="muted standalone-muted">{props.emailForLock}</p>
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" /> <input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
<PasswordField <PasswordField
@@ -123,6 +157,19 @@ export default function AuthViews(props: AuthViewsProps) {
{props.unlockPreparing ? ( {props.unlockPreparing ? (
<p className="muted standalone-muted">{t('txt_loading')}</p> <p className="muted standalone-muted">{t('txt_loading')}</p>
) : null} ) : null}
{props.sessionRefreshError ? (
<div className="offline-mode-notice" role="alert" aria-live="polite">
<AlertTriangle size={18} />
<div>
<strong>{props.sessionRefreshError}</strong>
<div>
<button type="button" className="auth-link-btn" onClick={props.onRetrySessionRefresh}>
{t('txt_refresh')}
</button>
</div>
</div>
</div>
) : null}
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}> <button type="submit" className="btn btn-primary full" disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}>
<Unlock size={16} className="btn-icon" /> <Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')} {unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
@@ -245,6 +292,7 @@ export default function AuthViews(props: AuthViewsProps) {
props.onSubmitLogin(); props.onSubmitLogin();
}} }}
> >
<OfflineModeNotice />
{passkeyPasswordPending ? ( {passkeyPasswordPending ? (
<> <>
<p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p> <p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p>
+21 -17
View File
@@ -42,7 +42,7 @@ interface BackupCenterPageProps {
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>; onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>; onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>; onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>; onInspectRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>; onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
@@ -492,8 +492,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
return verifyBackupFileIntegrity(bytes, file.name || ''); return verifyBackupFileIntegrity(bytes, file.name || '');
} }
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> { async function inspectRemoteBackupFile(masterPassword: string, destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
const payload = await props.onInspectRemoteBackup(destinationId, path); const payload = await props.onInspectRemoteBackup(masterPassword, destinationId, path);
return { return {
source: 'remote', source: 'remote',
path, path,
@@ -800,19 +800,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
setLocalError(''); setLocalError('');
resetPendingIntegrityWarning(); resetPendingIntegrityWarning();
try { await runRemoteRestore(path, false);
const integrity = await inspectRemoteBackupFile(savedSelectedDestination.id, path);
if (!integrity.result.matches) {
setPendingRestoreIntegrity(integrity);
setConfirmIntegrityWarningOpen(true);
return;
}
await runRemoteRestore(path, false, false, integrity.result);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
setLocalError(message);
props.onNotify('error', message);
}
} }
async function runRemoteRestore( async function runRemoteRestore(
@@ -846,7 +834,23 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setRestoringRemotePath(path); setRestoringRemotePath(path);
setLocalError(''); setLocalError('');
try { try {
const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path); const integrity = knownIntegrity
? { result: knownIntegrity }
: await inspectRemoteBackupFile(masterPassword, savedSelectedDestination.id, path);
if (!allowChecksumMismatch && !integrity.result.matches) {
setPendingRestoreIntegrity(
'source' in integrity
? integrity
: {
source: 'remote',
path,
fileName: path.split('/').pop() || path,
result: integrity.result,
}
);
setConfirmIntegrityWarningOpen(true);
return true;
}
startRestoreProgress('backup-restore', path.split('/').pop() || path, { startRestoreProgress('backup-restore', path.split('/').pop() || path, {
source: 'remote', source: 'remote',
delayMs: replaceExisting ? 480 : 1400, delayMs: replaceExisting ? 480 : 1400,
+3 -1
View File
@@ -87,6 +87,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
const cardRef = useRef<HTMLFormElement | null>(null); const cardRef = useRef<HTMLFormElement | null>(null);
const maskPointerStartedRef = useRef(false); const maskPointerStartedRef = useRef(false);
const restoreFocusRef = useRef<HTMLElement | null>(null); const restoreFocusRef = useRef<HTMLElement | null>(null);
const lastTitleRef = useRef<ComponentChildren>(props.title);
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []); const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
const titleId = `${dialogId}-title`; const titleId = `${dialogId}-title`;
const messageId = `${dialogId}-message`; const messageId = `${dialogId}-message`;
@@ -95,6 +96,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
useEffect(() => { useEffect(() => {
if (props.open) { if (props.open) {
lastTitleRef.current = props.title;
setPresent(true); setPresent(true);
setClosing(false); setClosing(false);
return; return;
@@ -228,7 +230,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
<X size={18} /> <X size={18} />
</button> </button>
)} )}
<h3 id={titleId} className="dialog-title">{props.title}</h3> <h3 id={titleId} className="dialog-title">{props.open ? props.title : lastTitleRef.current}</h3>
{hasMessage && <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>} {hasMessage && <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>}
{props.children} {props.children}
{!props.hideConfirm && ( {!props.hideConfirm && (
@@ -0,0 +1,247 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Check, Copy, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import { EFFLongWordList } from '@/lib/eff-word-list';
import { t } from '@/lib/i18n';
type GeneratorMode = 'password' | 'passphrase';
interface PasswordOptions {
length: number;
uppercase: boolean;
lowercase: boolean;
numbers: boolean;
special: boolean;
minNumbers: number;
minSpecial: number;
avoidAmbiguous: boolean;
}
interface PassphraseOptions {
words: number;
separator: string;
capitalize: boolean;
includeNumber: boolean;
}
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v1';
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
const DIGITS = '0123456789';
const SPECIAL = '!@#$%^&*';
const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1']);
const defaultPasswordOptions: PasswordOptions = {
length: 14,
uppercase: true,
lowercase: true,
numbers: true,
special: false,
minNumbers: 1,
minSpecial: 1,
avoidAmbiguous: false,
};
const defaultPassphraseOptions: PassphraseOptions = {
words: 6,
separator: '-',
capitalize: false,
includeNumber: false,
};
function clamp(value: unknown, minimum: number, maximum: number, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, Math.round(parsed))) : fallback;
}
function readSettings(): { mode: GeneratorMode; password: PasswordOptions; passphrase: PassphraseOptions } {
try {
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') as Partial<{ mode: GeneratorMode; password: Partial<PasswordOptions>; passphrase: Partial<PassphraseOptions> }>;
return {
mode: stored.mode === 'passphrase' ? 'passphrase' : 'password',
password: {
...defaultPasswordOptions,
...stored.password,
length: clamp(stored.password?.length, 5, 128, defaultPasswordOptions.length),
minNumbers: clamp(stored.password?.minNumbers, 0, 9, defaultPasswordOptions.minNumbers),
minSpecial: clamp(stored.password?.minSpecial, 0, 9, defaultPasswordOptions.minSpecial),
},
passphrase: {
...defaultPassphraseOptions,
...stored.passphrase,
words: clamp(stored.passphrase?.words, 3, 20, defaultPassphraseOptions.words),
separator: String(stored.passphrase?.separator ?? defaultPassphraseOptions.separator).slice(0, 1),
},
};
} catch {
return { mode: 'password', password: defaultPasswordOptions, passphrase: defaultPassphraseOptions };
}
}
function randomIndex(length: number): number {
const range = 0x1_0000_0000;
const upperBound = Math.floor(range / length) * length;
const buffer = new Uint32Array(1);
do crypto.getRandomValues(buffer); while (buffer[0] >= upperBound);
return buffer[0] % length;
}
function pick(characters: string): string {
return characters[randomIndex(characters.length)];
}
function shuffle(value: string[]): string[] {
for (let index = value.length - 1; index > 0; index -= 1) {
const next = randomIndex(index + 1);
[value[index], value[next]] = [value[next], value[index]];
}
return value;
}
function filtered(characters: string, avoidAmbiguous: boolean): string {
return avoidAmbiguous ? characters.split('').filter((character) => !AMBIGUOUS.has(character)).join('') : characters;
}
function generatePassword(options: PasswordOptions): string {
const sets: Array<{ chars: string; minimum: number }> = [];
if (options.uppercase) sets.push({ chars: filtered(UPPERCASE, options.avoidAmbiguous), minimum: 1 });
if (options.lowercase) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
if (options.numbers) sets.push({ chars: filtered(DIGITS, options.avoidAmbiguous), minimum: options.minNumbers });
if (options.special) sets.push({ chars: SPECIAL, minimum: options.minSpecial });
if (!sets.length) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
const minimumLength = sets.reduce((total, set) => total + set.minimum, 0);
const length = Math.max(options.length, minimumLength, 5);
const allCharacters = sets.map((set) => set.chars).join('');
const characters = sets.flatMap((set) => Array.from({ length: set.minimum }, () => pick(set.chars)));
while (characters.length < length) characters.push(pick(allCharacters));
return shuffle(characters).join('');
}
function generatePassphrase(options: PassphraseOptions): string {
const words = Array.from({ length: options.words }, () => EFFLongWordList[randomIndex(EFFLongWordList.length)]);
if (options.capitalize) {
for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1);
}
if (options.includeNumber) words[randomIndex(words.length)] += String(randomIndex(10));
return words.join(options.separator);
}
function strengthLabel(mode: GeneratorMode, value: string): { label: string; score: number } {
const score = mode === 'password' ? Math.min(4, Math.max(1, Math.floor(value.length / 5))) : Math.min(4, Math.max(1, Math.floor(value.split(/[-_. ]/).filter(Boolean).length / 2)));
return { score, label: t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][score - 1]) };
}
export default function PasswordGeneratorPage() {
const initial = useMemo(readSettings, []);
const [mode, setMode] = useState<GeneratorMode>(initial.mode);
const [passwordOptions, setPasswordOptions] = useState<PasswordOptions>(initial.password);
const [passphraseOptions, setPassphraseOptions] = useState<PassphraseOptions>(initial.passphrase);
const [seed, setSeed] = useState(0);
const [copied, setCopied] = useState(false);
const generated = useMemo(
() => (mode === 'password' ? generatePassword(passwordOptions) : generatePassphrase(passphraseOptions)),
[mode, passwordOptions, passphraseOptions, seed]
);
const strength = useMemo(() => strengthLabel(mode, generated), [generated, mode]);
useEffect(() => {
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ mode, password: passwordOptions, passphrase: passphraseOptions }));
} catch {
// The generator remains fully usable when browser storage is unavailable.
}
}, [mode, passwordOptions, passphraseOptions]);
const regenerate = () => {
setCopied(false);
setSeed((value) => value + 1);
};
const copy = async () => {
await copyTextToClipboard(generated, { onSuccess: () => setCopied(true), onError: () => setCopied(false) });
window.setTimeout(() => setCopied(false), 1600);
};
const changePasswordOption = <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => {
setPasswordOptions((current) => ({ ...current, [key]: value }));
setCopied(false);
};
const changePassphraseOption = <K extends keyof PassphraseOptions>(key: K, value: PassphraseOptions[K]) => {
setPassphraseOptions((current) => ({ ...current, [key]: value }));
setCopied(false);
};
return (
<section className="generator-page" aria-label={t('txt_password_generator')}>
<div className="generator-layout">
<section className="generator-output-card" aria-live="polite">
<div className="settings-category-tabs" role="tablist" aria-label={t('txt_generator_type')}>
<button type="button" role="tab" aria-selected={mode === 'password'} className={`settings-category-tab ${mode === 'password' ? 'active' : ''}`} onClick={() => setMode('password')}>{t('txt_password')}</button>
<button type="button" role="tab" aria-selected={mode === 'passphrase'} className={`settings-category-tab ${mode === 'passphrase' ? 'active' : ''}`} onClick={() => setMode('passphrase')}>{t('txt_passphrase')}</button>
</div>
<output className="generator-value" aria-label={t('txt_generated_password')}>{generated}</output>
<div className="generator-strength-row">
<div className="generator-strength" aria-label={`${t('txt_password_strength')}: ${strength.label}`}>
{[1, 2, 3, 4].map((level) => <span key={level} className={level <= strength.score ? `active level-${strength.score}` : ''} />)}
</div>
<span><ShieldCheck size={15} /> {strength.label}</span>
</div>
<div className="actions generator-actions">
<button type="button" className="btn btn-primary" onClick={regenerate}><RefreshCw size={16} className="btn-icon" />{t('txt_regenerate')}</button>
<button type="button" className="btn btn-secondary" onClick={() => void copy()}><Copy size={16} className="btn-icon" />{copied ? t('txt_copied') : t('txt_copy')}</button>
</div>
<p className="generator-security-note"><Check size={15} />{t('txt_generator_security_note')}</p>
</section>
<section className="generator-options-card" aria-labelledby="generator-options-title">
<h2 id="generator-options-title">{t('txt_options')}</h2>
{mode === 'password' ? (
<>
<GeneratorNumberStepper id="length" label={t('txt_generator_length')} value={passwordOptions.length} minimum={5} maximum={128} fallback={14} onChange={(value) => changePasswordOption('length', value)} />
<fieldset className="generator-option-group"><legend>{t('txt_generator_character_types')}</legend>
<GeneratorToggle checked={passwordOptions.uppercase} onChange={(checked) => changePasswordOption('uppercase', checked)} label={t('txt_generator_uppercase')} />
<GeneratorToggle checked={passwordOptions.lowercase} onChange={(checked) => changePasswordOption('lowercase', checked)} label={t('txt_generator_lowercase')} />
<GeneratorToggle checked={passwordOptions.numbers} onChange={(checked) => changePasswordOption('numbers', checked)} label={t('txt_generator_numbers')} />
{passwordOptions.numbers && <GeneratorNumberStepper id="min-numbers" compact label={t('txt_generator_minimum')} value={passwordOptions.minNumbers} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minNumbers', value)} />}
<GeneratorToggle checked={passwordOptions.special} onChange={(checked) => changePasswordOption('special', checked)} label={t('txt_generator_special')} />
{passwordOptions.special && <GeneratorNumberStepper id="min-special" compact label={t('txt_generator_minimum')} value={passwordOptions.minSpecial} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minSpecial', value)} />}
</fieldset>
<GeneratorToggle checked={passwordOptions.avoidAmbiguous} onChange={(checked) => changePasswordOption('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} />
</>
) : (
<>
<GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={passphraseOptions.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
<label className="generator-number-field" htmlFor="generator-separator"><span>{t('txt_generator_separator')}</span><input id="generator-separator" className="input" type="text" maxLength={1} value={passphraseOptions.separator} onInput={(event) => changePassphraseOption('separator', event.currentTarget.value.slice(0, 1))} /></label>
<div className="generator-option-group">
<GeneratorToggle checked={passphraseOptions.capitalize} onChange={(checked) => changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} />
<GeneratorToggle checked={passphraseOptions.includeNumber} onChange={(checked) => changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
</div>
</>
)}
</section>
</div>
</section>
);
}
function GeneratorToggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) {
return <label className="generator-toggle"><input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.currentTarget.checked)} /><span aria-hidden="true" /><strong>{props.label}</strong></label>;
}
function GeneratorNumberStepper(props: { id: string; label: string; value: number; minimum: number; maximum: number; fallback: number; compact?: boolean; onChange: (value: number) => void }) {
const id = `generator-stepper-${props.id}`;
const setValue = (value: number) => props.onChange(clamp(value, props.minimum, props.maximum, props.fallback));
return (
<div className={`generator-number-field ${props.compact ? 'compact' : ''}`}>
<label htmlFor={id}>{props.label}</label>
<div className="generator-stepper">
<button type="button" aria-label={`${props.label} -`} disabled={props.value <= props.minimum} onClick={() => setValue(props.value - 1)}><Minus size={15} /></button>
<input id={id} className="input" type="text" inputMode="numeric" pattern="[0-9]*" value={props.value} onInput={(event) => setValue(Number(event.currentTarget.value))} />
<button type="button" aria-label={`${props.label} +`} disabled={props.value >= props.maximum} onClick={() => setValue(props.value + 1)}><Plus size={15} /></button>
</div>
</div>
);
}
@@ -0,0 +1,169 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { AlertTriangle, CheckCircle2, ExternalLink, Eye, EyeOff, RefreshCw, ScanSearch, ShieldAlert, ShieldCheck, Unplug } from 'lucide-preact';
import { Link } from 'wouter';
import { maskSecret } from '@/components/vault/vault-page-helpers';
import { getPasswordSecurityState, readPasswordSecurityState, startPasswordSecurityScan, subscribePasswordSecurityState } from '@/lib/password-security-cache';
import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types';
interface PasswordSecurityPageProps {
ciphers: Cipher[];
loading: boolean;
}
type PasswordSecurityFilter = 'exposed' | 'reused' | 'weak' | 'all';
function vaultFingerprint(ciphers: Cipher[]): string {
return JSON.stringify(ciphers.map((cipher) => ({
id: cipher.id,
type: cipher.type,
revisionDate: cipher.revisionDate || '',
deletedDate: cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt || '',
})));
}
function formatCheckedAt(value: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(value);
}
export default function PasswordSecurityPage(props: PasswordSecurityPageProps) {
const fingerprint = vaultFingerprint(props.ciphers);
const [securityState, setSecurityState] = useState(() => getPasswordSecurityState(fingerprint));
const [filter, setFilter] = useState<PasswordSecurityFilter>('all');
const [revealedPasswordIds, setRevealedPasswordIds] = useState<Set<string>>(() => new Set());
useEffect(() => {
setSecurityState(getPasswordSecurityState(fingerprint));
setFilter('all');
setRevealedPasswordIds(new Set());
return subscribePasswordSecurityState(() => {
const next = readPasswordSecurityState(fingerprint);
if (next) setSecurityState(next);
});
}, [fingerprint]);
const { report, scannedAt, scanning, progress, scanError } = securityState;
const eligibleCount = useMemo(
() => props.ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length,
[props.ciphers],
);
const ciphersById = useMemo(() => new Map(props.ciphers.map((cipher) => [cipher.id, cipher])), [props.ciphers]);
const filteredItems = useMemo(() => {
if (!report || filter === 'all') return report?.items || [];
if (filter === 'exposed') return report.items.filter((item) => (item.exposedCount || 0) > 0);
if (filter === 'reused') return report.items.filter((item) => item.reusedCount > 1);
return report.items.filter((item) => item.weak);
}, [filter, report]);
const allPasswordsVisible = !!report?.items.length && report.items.every((item) => revealedPasswordIds.has(item.cipherId));
const togglePasswordVisibility = (cipherId: string) => {
setRevealedPasswordIds((current) => {
const next = new Set(current);
if (next.has(cipherId)) next.delete(cipherId);
else next.add(cipherId);
return next;
});
};
const toggleAllPasswordVisibility = () => {
if (!report) return;
setRevealedPasswordIds(allPasswordsVisible ? new Set() : new Set(report.items.map((item) => item.cipherId)));
};
const scan = () => {
setRevealedPasswordIds(new Set());
setFilter('all');
startPasswordSecurityScan(fingerprint, props.ciphers);
};
return (
<section className="password-security-page" aria-label={t('txt_password_security')}>
<div className="password-security-intro card">
<div className="password-security-intro-icon"><ShieldCheck size={22} /></div>
<div>
<h2>{t('txt_password_security')}</h2>
<p>{t('txt_password_security_privacy')}</p>
{scannedAt && <p className="password-security-checked-at">{t('txt_password_security_last_checked', { value: formatCheckedAt(scannedAt) })}</p>}
</div>
<div className="password-security-intro-actions">
{report && <button type="button" className="btn btn-secondary password-security-toggle-all" onClick={toggleAllPasswordVisibility}>
{allPasswordsVisible ? <EyeOff size={16} className="btn-icon" /> : <Eye size={16} className="btn-icon" />}
{allPasswordsVisible ? t('txt_password_security_hide_all') : t('txt_password_security_show_all')}
</button>}
<button type="button" className="btn btn-primary password-security-scan" disabled={props.loading || scanning || eligibleCount === 0} onClick={scan}>
{scanning ? <RefreshCw size={16} className="btn-icon spin" /> : <ScanSearch size={16} className="btn-icon" />}
{scanning ? t('txt_checking_password_security') : report ? t('txt_recheck_password_security') : t('txt_check_password_security')}
</button>
</div>
</div>
{!report && !scanning && !props.loading && (
<div className="password-security-empty card">
<ShieldCheck size={26} aria-hidden="true" />
<strong>{eligibleCount ? t('txt_password_security_ready') : t('txt_password_security_no_login')}</strong>
<span>{eligibleCount ? t('txt_password_security_manual') : t('txt_password_security_no_login_help')}</span>
</div>
)}
{(scanning || report) && (
<div className="password-security-summary" aria-live="polite">
<SecurityMetric icon={<ShieldAlert size={18} />} tone="danger" label={t('txt_exposed_passwords')} value={report?.exposedCount ?? 0} active={filter === 'exposed'} disabled={!report} onClick={() => setFilter('exposed')} />
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_reused_passwords')} value={report?.reusedCount ?? 0} active={filter === 'reused'} disabled={!report} onClick={() => setFilter('reused')} />
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_weak_passwords')} value={report?.weakCount ?? 0} active={filter === 'weak'} disabled={!report} onClick={() => setFilter('weak')} />
<SecurityMetric icon={<CheckCircle2 size={18} />} tone="primary" label={t('txt_passwords_checked')} value={`${scanning ? progress.checked : report?.checkedCount || 0} / ${scanning ? progress.total : report?.eligibleCount || 0}`} active={filter === 'all'} disabled={!report} onClick={() => setFilter('all')} />
</div>
)}
{scanError && <div className="password-security-notice warning card" role="alert"><Unplug size={16} />{t('txt_password_security_check_failed')}</div>}
{report && (
<section className="password-security-results card">
{report.unavailableCount > 0 && (
<div className="password-security-notice warning"><Unplug size={16} />{t('txt_password_security_unavailable', { count: report.unavailableCount })}</div>
)}
{!report.items.length ? (
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks')}</strong></div>
) : !filteredItems.length ? (
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks_in_filter')}</strong></div>
) : (
<div className="password-security-list">
{filteredItems.map((item) => {
const cipher = ciphersById.get(item.cipherId);
const name = String(cipher?.decName || cipher?.name || '');
const password = String(cipher?.login?.decPassword || '');
const passwordVisible = revealedPasswordIds.has(item.cipherId);
return <article className="password-security-item" key={item.cipherId}>
<div className="password-security-item-main">
<div className="password-security-item-header">
<strong>{name || t('txt_no_name')}</strong>
<div className="password-security-badges">
{item.exposedCount === null && <span className="risk-badge muted">{t('txt_password_security_not_checked')}</span>}
{(item.exposedCount || 0) > 0 && <span className="risk-badge danger">{t('txt_password_security_exposed_short', { count: item.exposedCount || 0 })}</span>}
{item.weak && <span className="risk-badge weak">{t('txt_password_security_weak_short')}</span>}
{item.reusedCount > 1 && <span className="risk-badge reused">{t('txt_password_security_reused_short')}</span>}
</div>
</div>
<span className="password-security-password">{passwordVisible ? password : maskSecret(password)}</span>
</div>
<div className="password-security-item-actions">
<button type="button" className="btn btn-secondary small" onClick={() => togglePasswordVisibility(item.cipherId)}>
{passwordVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
{passwordVisible ? t('txt_hide') : t('txt_reveal')}
</button>
<Link href={`/vault?cipher=${encodeURIComponent(item.cipherId)}`} className="btn btn-secondary small password-security-open">
<ExternalLink size={14} className="btn-icon" />{t('txt_password_security_jump')}
</Link>
</div>
</article>;
})}
</div>
)}
</section>
)}
</section>
);
}
function SecurityMetric(props: { icon: preact.ComponentChildren; tone: 'danger' | 'warning' | 'primary'; label: string; value: string | number; active: boolean; disabled: boolean; onClick: () => void }) {
return <button type="button" className={`password-security-metric ${props.tone}`} aria-pressed={props.active} disabled={props.disabled} onClick={props.onClick}><span>{props.icon}</span><div><strong>{props.value}</strong><small>{props.label}</small></div></button>;
}
+8 -1
View File
@@ -53,6 +53,13 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? value as Record<string, unknown> : null; return value && typeof value === 'object' ? value as Record<string, unknown> : null;
} }
function formatSendDate(value: string | null | undefined): string {
if (!value) return '';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '';
return parsed.toLocaleString();
}
function optionalString(value: unknown): string | null { function optionalString(value: unknown): string | null {
return typeof value === 'string' ? value : null; return typeof value === 'string' ? value : null;
} }
@@ -283,7 +290,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
</button> </button>
</div> </div>
)} )}
{!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: sendData.expirationDate })}</p>} {!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: formatSendDate(sendData.expirationDate) })}</p>}
</> </>
)} )}
+24 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact'; import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Lock, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import LoadingState from '@/components/LoadingState'; import LoadingState from '@/components/LoadingState';
import type { Send, SendDraft } from '@/lib/types'; import type { Send, SendDraft } from '@/lib/types';
@@ -32,6 +32,13 @@ function daysFromNow(iso: string | null | undefined, fallback: number): string {
return String(Math.max(days, 0)); return String(Math.max(days, 0));
} }
function formatSendDate(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return t('txt_dash');
return parsed.toLocaleString();
}
function buildDefaultDraft(): SendDraft { function buildDefaultDraft(): SendDraft {
return { return {
type: 'text', type: 'text',
@@ -43,6 +50,7 @@ function buildDefaultDraft(): SendDraft {
expirationDays: '0', expirationDays: '0',
maxAccessCount: '', maxAccessCount: '',
password: '', password: '',
hasPassword: false,
disabled: false, disabled: false,
}; };
} }
@@ -59,6 +67,7 @@ function draftFromSend(send: Send): SendDraft {
expirationDays: daysFromNow(send.expirationDate, 0), expirationDays: daysFromNow(send.expirationDate, 0),
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '', maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
password: '', password: '',
hasPassword: !!send.password,
disabled: !!send.disabled, disabled: !!send.disabled,
}; };
} }
@@ -380,6 +389,7 @@ export default function SendsPage(props: SendsPageProps) {
<div className="list-text"> <div className="list-text">
<span className="list-title" title={send.decName || t('txt_no_name')}>{send.decName || t('txt_no_name')}</span> <span className="list-title" title={send.decName || t('txt_no_name')}>{send.decName || t('txt_no_name')}</span>
<span className="list-sub"> <span className="list-sub">
{!!send.password && <><Lock size={12} className="inline-icon" /> </>}
{Number(send.type) === 1 ? t('txt_file') : t('txt_text')} - {t('txt_accessed_count_times', { count: send.accessCount || 0 })} {Number(send.type) === 1 ? t('txt_file') : t('txt_text')} - {t('txt_accessed_count_times', { count: send.accessCount || 0 })}
</span> </span>
</div> </div>
@@ -471,12 +481,23 @@ export default function SendsPage(props: SendsPageProps) {
</label> </label>
<label className="field"> <label className="field">
<span>{t('txt_password')}</span> <span>{t('txt_password')}</span>
{draft.hasPassword ? (
<div className="password-wrap">
<input className="input" type="password" value="••••••••" disabled />
{!isCreating && (
<button type="button" className="password-toggle text-red-600 hover:text-red-700" onClick={() => setDraft({ ...draft, hasPassword: false, password: '' })} title={t('txt_remove')}>
<Trash2 size={16} />
</button>
)}
</div>
) : (
<div className="password-wrap"> <div className="password-wrap">
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} /> <input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}> <button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />} {showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button> </button>
</div> </div>
)}
</label> </label>
<label className="field field-span-2"> <label className="field field-span-2">
<span>{t('txt_notes')}</span> <span>{t('txt_notes')}</span>
@@ -523,8 +544,8 @@ export default function SendsPage(props: SendsPageProps) {
<div className="card stagger-item stagger-delay-2"> <div className="card stagger-item stagger-delay-2">
<h4>{t('txt_send_details')}</h4> <h4>{t('txt_send_details')}</h4>
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div> <div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div> <div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{formatSendDate(selectedSend.deletionDate)}</strong></div>
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{selectedSend.expirationDate || t('txt_dash')}</strong></div> <div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{formatSendDate(selectedSend.expirationDate)}</strong></div>
</div> </div>
<div className="card"> <div className="card">
+2 -2
View File
@@ -130,7 +130,7 @@ export default function SettingsPage(props: SettingsPageProps) {
const [accountPasskeys, setAccountPasskeys] = useState<AccountPasskeyCredential[]>([]); const [accountPasskeys, setAccountPasskeys] = useState<AccountPasskeyCredential[]>([]);
const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false); const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false);
const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey')); const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey'));
const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(false); const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(true);
const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState<string | null>(null); const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState<string | null>(null);
const [createPasskeyDialogOpen, setCreatePasskeyDialogOpen] = useState(false); const [createPasskeyDialogOpen, setCreatePasskeyDialogOpen] = useState(false);
const [createPasskeyMasterPassword, setCreatePasskeyMasterPassword] = useState(''); const [createPasskeyMasterPassword, setCreatePasskeyMasterPassword] = useState('');
@@ -509,7 +509,7 @@ export default function SettingsPage(props: SettingsPageProps) {
setCreatePasskeyDialogOpen(false); setCreatePasskeyDialogOpen(false);
setCreatePasskeyMasterPassword(''); setCreatePasskeyMasterPassword('');
setAccountPasskeyName(t('txt_account_passkey')); setAccountPasskeyName(t('txt_account_passkey'));
setAccountPasskeyDirectUnlock(false); setAccountPasskeyDirectUnlock(true);
} }
async function submitCreatePasskeyDialog(): Promise<void> { async function submitCreatePasskeyDialog(): Promise<void> {
+18 -26
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Clipboard, Globe } from 'lucide-preact'; import { Clipboard, Globe } from 'lucide-preact';
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard'; import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
import { calcTotpNow } from '@/lib/crypto'; import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import LoadingState from '@/components/LoadingState'; import LoadingState from '@/components/LoadingState';
@@ -14,17 +14,9 @@ interface TotpCodesPageProps {
onNotify: (type: 'success' | 'error', text: string) => void; onNotify: (type: 'success' | 'error', text: string) => void;
} }
const TOTP_PERIOD_SECONDS = 30;
const TOTP_RING_RADIUS = 14; const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const TOTP_REFRESH_BATCH_SIZE = 16; const TOTP_REFRESH_BATCH_SIZE = 16;
function getTotpTimeState(): { windowId: number; remain: number } {
const epoch = Math.floor(Date.now() / 1000);
return {
windowId: Math.floor(epoch / TOTP_PERIOD_SECONDS),
remain: TOTP_PERIOD_SECONDS - (epoch % TOTP_PERIOD_SECONDS),
};
}
function TotpListIcon({ cipher }: { cipher: Cipher }) { function TotpListIcon({ cipher }: { cipher: Cipher }) {
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />; return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
@@ -32,13 +24,15 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
interface TotpRowProps { interface TotpRowProps {
cipher: Cipher; cipher: Cipher;
live: { code: string; remain: number } | null; live: TotpCodeResult | null;
onCopy: (value: string) => void; onCopy: (value: string) => void;
} }
function TotpRow(props: TotpRowProps) { function TotpRow(props: TotpRowProps) {
const name = props.cipher.decName || props.cipher.name || t('txt_no_name'); const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
const username = props.cipher.login?.decUsername || ''; const username = props.cipher.login?.decUsername || '';
const period = Math.max(1, props.live?.period || 30);
const progress = props.live ? Math.max(0, Math.min(period, props.live.remain)) / period : 0;
return ( return (
<div className="totp-code-row"> <div className="totp-code-row">
@@ -69,8 +63,7 @@ function TotpRow(props: TotpRowProps) {
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`, strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
strokeDashoffset: String( strokeDashoffset: String(
TOTP_RING_CIRCUMFERENCE - TOTP_RING_CIRCUMFERENCE -
TOTP_RING_CIRCUMFERENCE * TOTP_RING_CIRCUMFERENCE * progress
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
), ),
}} }}
/> />
@@ -86,8 +79,7 @@ function TotpRow(props: TotpRowProps) {
} }
export default function TotpCodesPage(props: TotpCodesPageProps) { export default function TotpCodesPage(props: TotpCodesPageProps) {
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({}); const [totpCodes, setTotpCodes] = useState<Record<string, TotpCodeResult | null>>({});
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
const [columnCount, setColumnCount] = useState(1); const [columnCount, setColumnCount] = useState(1);
const listRef = useRef<HTMLDivElement | null>(null); const listRef = useRef<HTMLDivElement | null>(null);
@@ -120,11 +112,10 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
let stopped = false; let stopped = false;
let activeRun = 0; let activeRun = 0;
let timer = 0; let timer = 0;
let currentWindowId = -1;
const refreshCodes = async () => { const refreshCodes = async () => {
const runId = ++activeRun; const runId = ++activeRun;
const nextCodes: Record<string, string | null> = {}; const nextCodes: Record<string, TotpCodeResult | null> = {};
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) { for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
if (stopped || runId !== activeRun) return; if (stopped || runId !== activeRun) return;
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE); const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
@@ -132,7 +123,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
batch.map(async (cipher) => { batch.map(async (cipher) => {
try { try {
const next = await calcTotpNow(cipher.login?.decTotp || ''); const next = await calcTotpNow(cipher.login?.decTotp || '');
return [cipher.id, next?.code || null] as const; return [cipher.id, next] as const;
} catch { } catch {
return [cipher.id, null] as const; return [cipher.id, null] as const;
} }
@@ -146,15 +137,20 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
if (stopped || runId !== activeRun) return; if (stopped || runId !== activeRun) return;
setTotpCodes((prev) => { setTotpCodes((prev) => {
let changed = false; let changed = false;
const next: Record<string, string | null> = { ...prev }; const next: Record<string, TotpCodeResult | null> = { ...prev };
for (const id of Object.keys(next)) { for (const id of Object.keys(next)) {
if (id in nextCodes) continue; if (id in nextCodes) continue;
delete next[id]; delete next[id];
changed = true; changed = true;
} }
for (const [id, code] of Object.entries(nextCodes)) { for (const [id, live] of Object.entries(nextCodes)) {
if (next[id] === code) continue; const prevLive = next[id];
next[id] = code; if (
prevLive?.code === live?.code &&
prevLive?.remain === live?.remain &&
prevLive?.period === live?.period
) continue;
next[id] = live;
changed = true; changed = true;
} }
return changed ? next : prev; return changed ? next : prev;
@@ -162,10 +158,6 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
}; };
const tick = () => { const tick = () => {
const next = getTotpTimeState();
setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
if (next.windowId === currentWindowId) return;
currentWindowId = next.windowId;
void refreshCodes(); void refreshCodes();
}; };
@@ -215,7 +207,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
<TotpRow <TotpRow
key={cipher.id} key={cipher.id}
cipher={cipher} cipher={cipher}
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null} live={totpCodes[cipher.id] || null}
onCopy={(value) => void copyToClipboard(value)} onCopy={(value) => void copyToClipboard(value)}
/> />
))} ))}
+99 -3
View File
@@ -31,7 +31,7 @@ import {
type SidebarFilter, type SidebarFilter,
type VaultSortMode, type VaultSortMode,
} from '@/components/vault/vault-page-helpers'; } from '@/components/vault/vault-page-helpers';
import { calcTotpNow } from '@/lib/crypto'; import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh'; import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
import { ChevronLeft } from 'lucide-preact'; import { ChevronLeft } from 'lucide-preact';
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
@@ -87,6 +87,7 @@ export default function VaultPage(props: VaultPageProps) {
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' }); const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState(''); const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({}); const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
const pendingFocusCipherIdRef = useRef<string | null>(null);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [createMenuOpen, setCreateMenuOpen] = useState(false); const [createMenuOpen, setCreateMenuOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -109,7 +110,7 @@ export default function VaultPage(props: VaultPageProps) {
const [renameFolderName, setRenameFolderName] = useState(''); const [renameFolderName, setRenameFolderName] = useState('');
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null); const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false); const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null); const [totpLive, setTotpLive] = useState<TotpCodeResult | null>(null);
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({}); const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]); const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({}); const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({});
@@ -419,7 +420,36 @@ export default function VaultPage(props: VaultPageProps) {
return !!meta?.searchText.includes(searchQuery); return !!meta?.searchText.includes(searchQuery);
}); });
// Pre-compute group min name for duplicates group ordering
const groupMinName = new Map<string, string>();
if (sidebarFilter.kind === 'duplicates' && duplicateSignatureInfo) {
for (const cipher of next) {
const gk = (duplicateSignatureInfo.byId.get(cipher.id) || [])
.filter(s => (duplicateSignatureInfo.counts.get(s) || 0) >= 2)
.sort()[0] || '';
if (!gk) continue;
const name = cipherMetaById.get(cipher.id)?.name || '';
const cur = groupMinName.get(gk);
if (!cur || nameCollator.compare(name, cur) < 0) groupMinName.set(gk, name);
}
}
next.sort((a, b) => { next.sort((a, b) => {
// Duplicates view: group by color, sort A-Z within each group
if (sidebarFilter.kind === 'duplicates' && duplicateSignatureInfo) {
const gk = (id: string) => (duplicateSignatureInfo.byId.get(id) || [])
.filter(s => (duplicateSignatureInfo.counts.get(s) || 0) >= 2)
.sort()[0] || '';
const gA = gk(a.id), gB = gk(b.id);
if (gA !== gB) return !gA ? 1 : !gB ? -1 : nameCollator.compare(
groupMinName.get(gA) || '', groupMinName.get(gB) || ''
) || (gA < gB ? -1 : 1);
return nameCollator.compare(
cipherMetaById.get(a.id)?.name || '',
cipherMetaById.get(b.id)?.name || ''
) || String(a.id || '').localeCompare(String(b.id || ''));
}
const metaA = cipherMetaById.get(a.id); const metaA = cipherMetaById.get(a.id);
const metaB = cipherMetaById.get(b.id); const metaB = cipherMetaById.get(b.id);
if (sortMode === 'edited') { if (sortMode === 'edited') {
@@ -468,8 +498,59 @@ export default function VaultPage(props: VaultPageProps) {
if (sidebarFilter.kind === 'duplicates') setSelectedMap({}); if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
}, [sidebarFilter.kind, duplicateMode]); }, [sidebarFilter.kind, duplicateMode]);
useEffect(() => {
if (typeof window === 'undefined') return;
const focusId = String(new URLSearchParams(window.location.search || '').get('cipher') || '').trim();
if (!focusId) return;
pendingFocusCipherIdRef.current = focusId;
}, []);
useEffect(() => {
const focusId = pendingFocusCipherIdRef.current;
if (!focusId) return;
const cipher = cipherById.get(focusId);
if (!cipher) {
if (!props.loading && props.ciphers.length > 0) pendingFocusCipherIdRef.current = null;
return;
}
const nextFilter: SidebarFilter = isCipherVisibleInTrash(cipher)
? { kind: 'trash' }
: isCipherVisibleInArchive(cipher)
? { kind: 'archive' }
: { kind: 'all' };
setSidebarFilter((prev) => (prev.kind === nextFilter.kind ? prev : nextFilter));
setSearchInput('');
setSearchQuery('');
setIsEditing(false);
setIsCreating(false);
setDraft(null);
}, [cipherById, props.ciphers.length, props.loading]);
useEffect(() => { useEffect(() => {
if (isCreating) return; if (isCreating) return;
const focusId = pendingFocusCipherIdRef.current;
if (focusId) {
if (!filteredCipherIds.has(focusId)) return;
setSelectedCipherId(focusId);
setRepromptApprovedCipherId(null);
setShowPassword(false);
setHiddenFieldVisibleMap({});
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
pendingFocusCipherIdRef.current = null;
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
const url = new URL(window.location.href);
if (url.searchParams.has('cipher')) {
url.searchParams.delete('cipher');
const next = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState(null, '', next || '/vault');
}
}
return;
}
if (!filteredCiphers.length) { if (!filteredCiphers.length) {
if (selectedCipherId) setSelectedCipherId(''); if (selectedCipherId) setSelectedCipherId('');
return; return;
@@ -477,7 +558,7 @@ export default function VaultPage(props: VaultPageProps) {
if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) { if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
setSelectedCipherId(filteredCiphers[0].id); setSelectedCipherId(filteredCiphers[0].id);
} }
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]); }, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating, isMobileLayout]);
const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]); const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
const virtualRange = useMemo(() => { const virtualRange = useMemo(() => {
@@ -1049,6 +1130,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
} }
setSelectedMap(map); setSelectedMap(map);
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]); }, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
const handleSelectUniqueFromDuplicates = useCallback(() => {
const map: Record<string, boolean> = {};
const seen = new Set<number>();
for (const cipher of filteredCiphers) {
const groupIndex = duplicateGroupIndexById.get(cipher.id);
if (groupIndex === undefined) continue;
if (seen.has(groupIndex)) {
map[cipher.id] = true;
} else {
seen.add(groupIndex);
}
}
setSelectedMap(map);
}, [filteredCiphers, duplicateGroupIndexById]);
const handleSelectAll = useCallback(() => { const handleSelectAll = useCallback(() => {
const map: Record<string, boolean> = {}; const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true; for (const cipher of filteredCiphers) map[cipher.id] = true;
@@ -1163,6 +1258,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
onSyncVault={handleSyncVault} onSyncVault={handleSyncVault}
onOpenBulkDelete={handleOpenBulkDelete} onOpenBulkDelete={handleOpenBulkDelete}
onSelectDuplicates={handleSelectDuplicates} onSelectDuplicates={handleSelectDuplicates}
onSelectUniqueFromDuplicates={handleSelectUniqueFromDuplicates}
onSelectAll={handleSelectAll} onSelectAll={handleSelectAll}
onToggleCreateMenu={handleToggleCreateMenu} onToggleCreateMenu={handleToggleCreateMenu}
onStartCreate={startCreate} onStartCreate={startCreate}
@@ -1,12 +1,13 @@
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { useEffect, useMemo, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact'; import { AlertTriangle, Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RefreshCw, RotateCcw, ShieldCheck, ShieldAlert, Trash2, X } from 'lucide-preact';
import { useDialogLifecycle } from '@/components/ConfirmDialog'; import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { TotpCodeResult } from '@/lib/crypto';
import { checkPasswordLeaked, type PasswordBreachResult } from '@/lib/password-security';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
CardBrandIcon, CardBrandIcon,
TOTP_PERIOD_SECONDS,
TOTP_RING_CIRCUMFERENCE, TOTP_RING_CIRCUMFERENCE,
VaultListIcon, VaultListIcon,
copyToClipboard, copyToClipboard,
@@ -21,11 +22,15 @@ import {
toBooleanFieldValue, toBooleanFieldValue,
} from '@/components/vault/vault-page-helpers'; } from '@/components/vault/vault-page-helpers';
function isAbortError(error: unknown): boolean {
return !!error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError';
}
interface VaultDetailViewProps { interface VaultDetailViewProps {
selectedCipher: Cipher; selectedCipher: Cipher;
repromptApprovedCipherId: string | null; repromptApprovedCipherId: string | null;
showPassword: boolean; showPassword: boolean;
totpLive: { code: string; remain: number } | null; totpLive: TotpCodeResult | null;
passkeyCreatedAt: string | null; passkeyCreatedAt: string | null;
hiddenFieldVisibleMap: Record<number, boolean>; hiddenFieldVisibleMap: Record<number, boolean>;
folderName: (id: string | null | undefined) => string; folderName: (id: string | null | undefined) => string;
@@ -42,6 +47,11 @@ interface VaultDetailViewProps {
onUnarchive: (cipher: Cipher) => void | Promise<void>; onUnarchive: (cipher: Cipher) => void | Promise<void>;
} }
function totpProgress(live: TotpCodeResult | null): number {
const period = Math.max(1, live?.period || 30);
return live ? Math.max(0, Math.min(period, live.remain)) / period : 0;
}
function PasswordHistoryDialog(props: { function PasswordHistoryDialog(props: {
open: boolean; open: boolean;
entries: Array<{ password: string; lastUsedDate: string | null }>; entries: Array<{ password: string; lastUsedDate: string | null }>;
@@ -85,6 +95,9 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : []; const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false); const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false); const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
const [breachResult, setBreachResult] = useState<PasswordBreachResult | null>(null);
const [checkingBreach, setCheckingBreach] = useState(false);
const breachControllerRef = useRef<AbortController | null>(null);
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt); const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
const isDeleted = isCipherDeleted(props.selectedCipher); const isDeleted = isCipherDeleted(props.selectedCipher);
const passwordHistoryEntries = useMemo( const passwordHistoryEntries = useMemo(
@@ -98,9 +111,39 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
[props.selectedCipher.passwordHistory] [props.selectedCipher.passwordHistory]
); );
useEffect(() => { useEffect(() => {
breachControllerRef.current?.abort();
breachControllerRef.current = null;
setShowSshPrivateKey(false); setShowSshPrivateKey(false);
setPasswordHistoryOpen(false); setPasswordHistoryOpen(false);
}, [props.selectedCipher.id]); setBreachResult(null);
setCheckingBreach(false);
return () => {
breachControllerRef.current?.abort();
breachControllerRef.current = null;
};
}, [props.selectedCipher.id, props.selectedCipher.login?.decPassword]);
const checkBreach = async () => {
const password = String(props.selectedCipher.login?.decPassword || '');
if (!password) return;
breachControllerRef.current?.abort();
const controller = new AbortController();
breachControllerRef.current = controller;
setCheckingBreach(true);
setBreachResult(null);
try {
const result = await checkPasswordLeaked(password, fetch, controller.signal);
if (controller.signal.aborted) return;
setBreachResult(result);
} catch (error) {
if (controller.signal.aborted || isAbortError(error)) return;
setBreachResult({ count: null, available: false });
} finally {
if (breachControllerRef.current === controller) {
breachControllerRef.current = null;
setCheckingBreach(false);
}
}
};
const formatDownloadLabel = (attachmentId: string) => { const formatDownloadLabel = (attachmentId: string) => {
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`; const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download'); if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
@@ -167,8 +210,18 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}> <button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')} <Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button> </button>
<button type="button" className="btn btn-secondary small" disabled={checkingBreach || !props.selectedCipher.login?.decPassword} onClick={() => void checkBreach()}>
{checkingBreach ? <RefreshCw size={14} className="btn-icon spin" /> : <ShieldCheck size={14} className="btn-icon" />}
{checkingBreach ? t('txt_checking_password_security') : t('txt_check_password_breach')}
</button>
</div> </div>
</div> </div>
{breachResult && (
<div className={`password-breach-inline ${breachResult.available ? (breachResult.count ? 'danger' : 'safe') : 'warning'}`} role="status">
{breachResult.available ? (breachResult.count ? <ShieldAlert size={15} /> : <ShieldCheck size={15} />) : <AlertTriangle size={15} />}
<span>{breachResult.available ? (breachResult.count ? t('txt_password_exposed_count', { count: breachResult.count }) : t('txt_password_not_found_in_breaches')) : t('txt_password_security_check_failed')}</span>
</div>
)}
{!!props.selectedCipher.login.decTotp && ( {!!props.selectedCipher.login.decTotp && (
<div className="kv-row"> <div className="kv-row">
<span className="kv-label">{t('txt_totp')}</span> <span className="kv-label">{t('txt_totp')}</span>
@@ -191,8 +244,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`, strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
strokeDashoffset: String( strokeDashoffset: String(
TOTP_RING_CIRCUMFERENCE - TOTP_RING_CIRCUMFERENCE -
TOTP_RING_CIRCUMFERENCE * TOTP_RING_CIRCUMFERENCE * totpProgress(props.totpLive)
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
), ),
}} }}
/> />
+4 -3
View File
@@ -4,6 +4,7 @@ import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, Refr
import jsQR from 'jsqr'; import jsQR from 'jsqr';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useDialogLifecycle } from '@/components/ConfirmDialog'; import { useDialogLifecycle } from '@/components/ConfirmDialog';
import { normalizeTotpInput } from '@/lib/crypto';
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { cardBrand } from '@/lib/import-format-shared'; import { cardBrand } from '@/lib/import-format-shared';
@@ -161,9 +162,9 @@ export default function VaultEditor(props: VaultEditorProps) {
}; };
const applyTotpQrValue = (value: string) => { const applyTotpQrValue = (value: string) => {
const trimmed = value.trim(); const normalized = normalizeTotpInput(value);
if (!trimmed) return false; if (!normalized) return false;
props.onUpdateDraft({ loginTotp: trimmed }); props.onUpdateDraft({ loginTotp: normalized });
setTotpQrStatus(t('txt_totp_qr_scanned')); setTotpQrStatus(t('txt_totp_qr_scanned'));
setTotpQrOpen(false); setTotpQrOpen(false);
return true; return true;
+16 -6
View File
@@ -81,6 +81,7 @@ interface VaultListPanelProps {
onSyncVault: () => void; onSyncVault: () => void;
onOpenBulkDelete: () => void; onOpenBulkDelete: () => void;
onSelectDuplicates: () => void; onSelectDuplicates: () => void;
onSelectUniqueFromDuplicates: () => void;
onSelectAll: () => void; onSelectAll: () => void;
onToggleCreateMenu: () => void; onToggleCreateMenu: () => void;
onStartCreate: (type: number) => void; onStartCreate: (type: number) => void;
@@ -319,13 +320,17 @@ export default function VaultListPanel(props: VaultListPanelProps) {
</> </>
) : ( ) : (
<> <>
<div className="search-input-wrap">
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? ( {props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
<div className="duplicate-mode-head-menu"> <div className="duplicate-mode-head-menu mobile-duplicate-toolbar">
<div className="mobile-duplicate-mode-select-wrap">
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)} {renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
</div> </div>
<button type="button" className="btn btn-secondary small" onClick={props.onSelectUniqueFromDuplicates}>
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
</button>
</div>
) : ( ) : (
<> <div className="search-input-wrap">
<input <input
className="search-input" className="search-input"
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })} placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
@@ -350,9 +355,8 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<X size={14} /> <X size={14} />
</button> </button>
)} )}
</>
)}
</div> </div>
)}
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && ( {props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
<div className="duplicate-mode-head-menu"> <div className="duplicate-mode-head-menu">
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)} {renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
@@ -387,7 +391,13 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}> <button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')} <RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
</button> </button>
{!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu} {props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout ? (
<button type="button" className="btn btn-secondary small" onClick={props.onSelectUniqueFromDuplicates}>
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
</button>
) : (
!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu
)}
</> </>
)} )}
</div> </div>
@@ -17,11 +17,13 @@ import {
LayoutGrid, LayoutGrid,
Pencil, Pencil,
ShieldUser, ShieldUser,
ShieldCheck,
Star, Star,
StickyNote, StickyNote,
Trash2, Trash2,
X, X,
} from 'lucide-preact'; } from 'lucide-preact';
import { Link } from 'wouter';
import type { Folder } from '@/lib/types'; import type { Folder } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers'; import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers';
@@ -95,6 +97,9 @@ export default function VaultSidebar(props: VaultSidebarProps) {
</div> </div>
)} )}
<div className="sidebar-block"> <div className="sidebar-block">
<Link href="/security/password-health" className="tree-btn">
<ShieldCheck size={14} className="tree-icon" /> <span className="tree-label">{t('nav_password_security')}</span>
</Link>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'all' })}> <button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'all' })}>
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span> <LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
</button> </button>
+8 -5
View File
@@ -10,6 +10,7 @@ import {
} from '@/lib/website-icon-cache'; } from '@/lib/website-icon-cache';
import { demoBrandIconUrl } from '@/lib/demo-brand-icons'; import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status'; import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
import { areWebsiteIconsEnabled } from '@/lib/website-icon-settings';
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils'; import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
const ICON_LOAD_ROOT_MARGIN = '180px 0px'; const ICON_LOAD_ROOT_MARGIN = '180px 0px';
@@ -22,7 +23,8 @@ interface WebsiteIconProps {
export default function WebsiteIcon(props: WebsiteIconProps) { export default function WebsiteIcon(props: WebsiteIconProps) {
const host = useMemo(() => hostFromUri(firstCipherUri(props.cipher)), [props.cipher]); const host = useMemo(() => hostFromUri(firstCipherUri(props.cipher)), [props.cipher]);
const src = host ? websiteIconUrl(host) : ''; const iconsEnabled = areWebsiteIconsEnabled();
const src = iconsEnabled && host ? websiteIconUrl(host) : '';
const nodeRef = useRef<HTMLSpanElement | null>(null); const nodeRef = useRef<HTMLSpanElement | null>(null);
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true)); const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle')); const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
@@ -33,7 +35,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []); useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
useEffect(() => { useEffect(() => {
if (!host) { if (!host || !iconsEnabled) {
setShouldLoad(true); setShouldLoad(true);
setStatus('idle'); setStatus('idle');
setImageUrl(''); setImageUrl('');
@@ -47,7 +49,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
setStatus(next); setStatus(next);
setImageUrl(getWebsiteIconImageUrl(host)); setImageUrl(getWebsiteIconImageUrl(host));
}); });
}, [host]); }, [host, iconsEnabled]);
useEffect(() => { useEffect(() => {
if (!host || shouldLoad || status === 'loaded' || status === 'error') return; if (!host || shouldLoad || status === 'loaded' || status === 'error') return;
@@ -81,10 +83,11 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
useEffect(() => { useEffect(() => {
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return; if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
if (demoIconUrl) return; if (demoIconUrl) return;
if (!iconsEnabled) return;
if (networkStatus !== 'online') return; if (networkStatus !== 'online') return;
if (!host || !src || !shouldLoad || status !== 'idle') return; if (!host || !src || !shouldLoad || status !== 'idle') return;
beginWebsiteIconLoad(host, src); beginWebsiteIconLoad(host, src);
}, [demoIconUrl, host, networkStatus, src, shouldLoad, status]); }, [demoIconUrl, host, iconsEnabled, networkStatus, src, shouldLoad, status]);
if (demoIconUrl) { if (demoIconUrl) {
return ( return (
@@ -100,7 +103,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
); );
} }
if (!host || status === 'error') { if (!host || !iconsEnabled || status === 'error') {
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>; return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
} }
@@ -207,8 +207,7 @@ export function getWebsiteMatchOptions(): Array<{ value: number | null; label: s
]; ];
} }
export const TOTP_PERIOD_SECONDS = 30; const TOTP_RING_RADIUS = 14;
export const TOTP_RING_RADIUS = 14;
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
export function CreateTypeIcon({ type }: { type: number }) { export function CreateTypeIcon({ type }: { type: number }) {
@@ -561,13 +561,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
openRemoveAllDevices() { openRemoveAllDevices() {
onSetConfirm({ onSetConfirm({
title: t('txt_remove_all_devices'), title: t('txt_remove_all_devices'),
message: t('txt_remove_all_devices_and_sign_out_all_sessions'), message: `${t('txt_remove_all_devices_and_sign_out_all_sessions')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteAllAuthorizedDevices(authedFetch); if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
await deleteAllAuthorizedDevices(authedFetch, derived.hash);
onNotify('success', t('txt_all_devices_removed')); onNotify('success', t('txt_all_devices_removed'));
onLogoutNow(); onLogoutNow();
} catch (error) { } catch (error) {
+68 -16
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin'; import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin';
import { deriveLoginHash } from '@/lib/api/auth';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AppConfirmState } from '@/components/AppGlobalOverlays'; import type { AppConfirmState } from '@/components/AppGlobalOverlays';
import type { AuthedFetch } from '@/lib/api/shared'; import type { AuthedFetch } from '@/lib/api/shared';
@@ -8,6 +9,8 @@ type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
interface UseAdminActionsOptions { interface UseAdminActionsOptions {
authedFetch: AuthedFetch; authedFetch: AuthedFetch;
email: string;
defaultKdfIterations: number;
onNotify: Notify; onNotify: Notify;
onSetConfirm: (next: AppConfirmState | null) => void; onSetConfirm: (next: AppConfirmState | null) => void;
refetchUsers: () => Promise<unknown>; refetchUsers: () => Promise<unknown>;
@@ -15,7 +18,24 @@ interface UseAdminActionsOptions {
} }
export default function useAdminActions(options: UseAdminActionsOptions) { export default function useAdminActions(options: UseAdminActionsOptions) {
const { authedFetch, onNotify, onSetConfirm, refetchUsers, refetchInvites } = options; const {
authedFetch,
email,
defaultKdfIterations,
onNotify,
onSetConfirm,
refetchUsers,
refetchInvites,
} = options;
async function withMasterPasswordHash(masterPassword: string | undefined): Promise<string> {
const normalizedEmail = String(email || '').trim().toLowerCase();
const normalizedPassword = String(masterPassword || '');
if (!normalizedEmail) throw new Error(t('txt_profile_unavailable'));
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(normalizedEmail, normalizedPassword, defaultKdfIterations);
return derived.hash;
}
return useMemo( return useMemo(
() => ({ () => ({
@@ -26,35 +46,61 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
}, },
async createInvite(hours: number) { async createInvite(hours: number) {
onSetConfirm({
title: t('txt_create_timed_invite'),
message: t('txt_enter_master_password_to_continue'),
requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try { try {
await createInvite(authedFetch, hours); const hash = await withMasterPasswordHash(masterPassword);
await createInvite(authedFetch, hours, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invite_created')); onNotify('success', t('txt_invite_created'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
} }
})();
},
});
}, },
async toggleUserStatus(userId: string, status: 'active' | 'banned') { async toggleUserStatus(userId: string, status: 'active' | 'banned') {
const nextStatus = status === 'active' ? 'banned' : 'active';
onSetConfirm({
title: nextStatus === 'banned' ? t('txt_ban') : t('txt_unban'),
message: t('txt_enter_master_password_to_continue'),
danger: nextStatus === 'banned',
requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try { try {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active'); const hash = await withMasterPasswordHash(masterPassword);
await setUserStatus(authedFetch, userId, nextStatus, hash);
await refetchUsers(); await refetchUsers();
onNotify('success', t('txt_user_status_updated')); onNotify('success', t('txt_user_status_updated'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
} }
})();
},
});
}, },
async deleteInvite(code: string) { async deleteInvite(code: string) {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_invite'), title: t('txt_delete_invite'),
message: t('txt_delete_invite_confirm_message'), message: `${t('txt_delete_invite_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteInvite(authedFetch, code); const hash = await withMasterPasswordHash(masterPassword);
await deleteInvite(authedFetch, code, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invite_deleted')); onNotify('success', t('txt_invite_deleted'));
} catch (error) { } catch (error) {
@@ -68,13 +114,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteInvalidInvites() { async deleteInvalidInvites() {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_invalid_invites'), title: t('txt_delete_invalid_invites'),
message: t('txt_delete_invalid_invites_confirm_message'), message: `${t('txt_delete_invalid_invites_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteInvalidInvites(authedFetch); const hash = await withMasterPasswordHash(masterPassword);
await deleteInvalidInvites(authedFetch, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invalid_invites_deleted')); onNotify('success', t('txt_invalid_invites_deleted'));
} catch (error) { } catch (error) {
@@ -88,13 +136,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteAllInvites() { async deleteAllInvites() {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_all_invites'), title: t('txt_delete_all_invites'),
message: t('txt_delete_all_invite_codes_active_inactive'), message: `${t('txt_delete_all_invite_codes_active_inactive')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteAllInvites(authedFetch); const hash = await withMasterPasswordHash(masterPassword);
await deleteAllInvites(authedFetch, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_all_invites_deleted')); onNotify('success', t('txt_all_invites_deleted'));
} catch (error) { } catch (error) {
@@ -108,13 +158,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteUser(userId: string) { async deleteUser(userId: string) {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_user'), title: t('txt_delete_user'),
message: t('txt_delete_this_user_and_all_user_data'), message: `${t('txt_delete_this_user_and_all_user_data')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteUser(authedFetch, userId); const hash = await withMasterPasswordHash(masterPassword);
await deleteUser(authedFetch, userId, hash);
await refetchUsers(); await refetchUsers();
onNotify('success', t('txt_user_deleted')); onNotify('success', t('txt_user_deleted'));
} catch (error) { } catch (error) {
@@ -125,6 +177,6 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
}); });
}, },
}), }),
[authedFetch, onNotify, onSetConfirm, refetchInvites, refetchUsers] [authedFetch, defaultKdfIterations, email, onNotify, onSetConfirm, refetchInvites, refetchUsers]
); );
} }
+2 -2
View File
@@ -82,8 +82,8 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
}, },
async inspectRemoteBackup(destinationId: string, path: string) { async inspectRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
return inspectRemoteBackupIntegrity(authedFetch, destinationId, path); return inspectRemoteBackupIntegrity(authedFetch, masterPasswordHash, destinationId, path);
}, },
async deleteRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) { async deleteRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
+27 -113
View File
@@ -16,6 +16,7 @@ export interface PendingAccountPasskeyCredential {
deviceResponse: PublicKeyCredential; deviceResponse: PublicKeyCredential;
request: Record<string, unknown>; request: Record<string, unknown>;
supportsPrf: boolean; supportsPrf: boolean;
prfKey?: Uint8Array;
} }
export interface AccountPasskeyPrfKeySet { export interface AccountPasskeyPrfKeySet {
@@ -82,20 +83,9 @@ async function getLoginWithPrfSalt(): Promise<Uint8Array> {
return new Uint8Array(hash); return new Uint8Array(hash);
} }
function credentialIdToBase64Url(id: BufferSource): string | null {
try {
const bytes = id instanceof ArrayBuffer
? new Uint8Array(id)
: new Uint8Array(id.buffer, id.byteOffset, id.byteLength);
return bytesToBase64Url(bytes);
} catch {
return null;
}
}
type PrfEvalInput = { first: Uint8Array }; type PrfEvalInput = { first: Uint8Array };
function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> { function buildPrfExtension(salt: Uint8Array): Record<string, unknown> {
const evalInput: PrfEvalInput = { first: salt }; const evalInput: PrfEvalInput = { first: salt };
return { return {
prf: { prf: {
@@ -104,34 +94,23 @@ function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> {
}; };
} }
function buildCredentialPrfExtension( function withPrfExtension(
salt: Uint8Array, options: PublicKeyCredentialCreationOptions,
credentialIds: Array<string | null | undefined> salt: Uint8Array
): Record<string, unknown> { ): PublicKeyCredentialCreationOptions;
const evalInput = { first: salt };
const evalByCredential = credentialIds
.filter((id): id is string => !!id)
.reduce<Record<string, PrfEvalInput>>((out, id) => {
out[id] = evalInput;
return out;
}, {});
if (!Object.keys(evalByCredential).length) return buildLegacyPrfExtension(salt);
return {
prf: {
evalByCredential,
},
};
}
function withPrfExtension( function withPrfExtension(
options: PublicKeyCredentialRequestOptions, options: PublicKeyCredentialRequestOptions,
extension: Record<string, unknown> salt: Uint8Array
): PublicKeyCredentialRequestOptions { ): PublicKeyCredentialRequestOptions;
function withPrfExtension(
options: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions,
salt: Uint8Array
): PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions {
return { return {
...options, ...options,
extensions: { extensions: {
...((options as any).extensions || {}), ...((options as any).extensions || {}),
...extension, ...buildPrfExtension(salt),
} as any, } as any,
}; };
} }
@@ -154,70 +133,17 @@ function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | unde
return result instanceof ArrayBuffer ? result : undefined; return result instanceof ArrayBuffer ? result : undefined;
} }
function hasPrfExtensionResult(credential: PublicKeyCredential): boolean {
return Object.prototype.hasOwnProperty.call(credential.getClientExtensionResults() as any, 'prf');
}
function shouldRetryWithLegacyPrf(error: unknown): boolean {
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError';
}
function shouldRetryCreateWithoutPrf(error: unknown): boolean {
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
const message = error instanceof DOMException || error instanceof Error ? error.message : '';
return (
name === 'NotSupportedError' ||
name === 'SyntaxError' ||
name === 'TypeError' ||
(name === 'UnknownError' && /transient/i.test(message))
);
}
async function canRequestPrfExtension(): Promise<boolean> {
if (/\bFirefox\//i.test(navigator.userAgent)) return false;
return true;
}
async function getPublicKeyCredentialWithPrf( async function getPublicKeyCredentialWithPrf(
options: PublicKeyCredentialRequestOptions, options: PublicKeyCredentialRequestOptions,
salt: Uint8Array, salt: Uint8Array
credentialIds: string[] = []
): Promise<PublicKeyCredential> { ): Promise<PublicKeyCredential> {
const attempts = credentialIds.length
? [
buildCredentialPrfExtension(salt, credentialIds),
buildLegacyPrfExtension(salt),
]
: [buildLegacyPrfExtension(salt)];
let lastCredential: PublicKeyCredential | null = null;
for (let index = 0; index < attempts.length; index += 1) {
try {
const credential = await navigator.credentials.get({ const credential = await navigator.credentials.get({
publicKey: withPrfExtension(options, attempts[index]), publicKey: withPrfExtension(options, salt),
}); });
if (!(credential instanceof PublicKeyCredential)) { if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_selected')); throw new Error(t('txt_no_passkey_selected'));
} }
lastCredential = credential;
if (readPrfFirstResult(credential) || hasPrfExtensionResult(credential) || index === attempts.length - 1) {
return credential; return credential;
}
} catch (error) {
if (index === attempts.length - 1 || !shouldRetryWithLegacyPrf(error)) {
if (lastCredential) return lastCredential;
throw error;
}
}
}
if (lastCredential) return lastCredential;
throw new Error(t('txt_no_passkey_selected'));
}
function prfCredentialIdsFromAllowCredentials(options: PublicKeyCredentialRequestOptions): string[] {
return (options.allowCredentials || [])
.map((credential) => credentialIdToBase64Url(credential.id))
.filter((id): id is string => !!id);
} }
async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> { async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> {
@@ -282,8 +208,7 @@ export async function assertAccountPasskey(
const nativeOptions = cloneRequestOptions(response.options); const nativeOptions = cloneRequestOptions(response.options);
const credential = await getPublicKeyCredentialWithPrf( const credential = await getPublicKeyCredentialWithPrf(
nativeOptions, nativeOptions,
await getLoginWithPrfSalt(), await getLoginWithPrfSalt()
prfCredentialIdsFromAllowCredentials(nativeOptions)
); );
const prfResult = readPrfFirstResult(credential); const prfResult = readPrfFirstResult(credential);
return { return {
@@ -309,34 +234,22 @@ export async function createAccountPasskeyCredential(
} }
return credential; return credential;
}; };
let credential: PublicKeyCredential; const prfSalt = requestPrf ? await getLoginWithPrfSalt() : null;
if (requestPrf && await canRequestPrfExtension()) { const credential = await createWithOptions(
const prfOptions: PublicKeyCredentialCreationOptions = { prfSalt ? withPrfExtension(noPrfOptions, prfSalt) : noPrfOptions
...noPrfOptions, );
extensions: {
...((noPrfOptions as any).extensions || {}),
prf: {},
} as any,
};
try {
credential = await createWithOptions(prfOptions);
} catch (error) {
if (!shouldRetryCreateWithoutPrf(error)) throw error;
credential = await createWithOptions(noPrfOptions);
}
} else {
credential = await createWithOptions(noPrfOptions);
}
if (!(credential instanceof PublicKeyCredential)) { if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created')); throw new Error(t('txt_no_passkey_created'));
} }
const supportsPrf = !!(credential.getClientExtensionResults() as any).prf?.enabled; const prfResult = readPrfFirstResult(credential);
const supportsPrf = !!prfResult || (credential.getClientExtensionResults() as any).prf?.enabled === true;
return { return {
token: response.token, token: response.token,
createOptions: nativeOptions, createOptions: nativeOptions,
deviceResponse: credential, deviceResponse: credential,
request: attestationRequest(credential), request: attestationRequest(credential),
supportsPrf, supportsPrf,
prfKey: prfResult ? await prfOutputToKey(prfResult) : undefined,
}; };
} }
@@ -373,8 +286,10 @@ export async function buildAccountPasskeyPrfKeySet(
pending: PendingAccountPasskeyCredential, pending: PendingAccountPasskeyCredential,
userKey: { symEncKey: string; symMacKey: string } userKey: { symEncKey: string; symMacKey: string }
): Promise<AccountPasskeyPrfKeySet> { ): Promise<AccountPasskeyPrfKeySet> {
if (pending.prfKey) {
return buildAccountPasskeyPrfKeySetFromPrfKey(pending.prfKey, userKey);
}
const rawId = new Uint8Array(pending.deviceResponse.rawId); const rawId = new Uint8Array(pending.deviceResponse.rawId);
const credentialId = bytesToBase64Url(rawId);
const assertionOptions: PublicKeyCredentialRequestOptions = { const assertionOptions: PublicKeyCredentialRequestOptions = {
challenge: pending.createOptions?.challenge!, challenge: pending.createOptions?.challenge!,
rpId: pending.createOptions?.rp?.id, rpId: pending.createOptions?.rp?.id,
@@ -384,8 +299,7 @@ export async function buildAccountPasskeyPrfKeySet(
}; };
const assertion = await getPublicKeyCredentialWithPrf( const assertion = await getPublicKeyCredentialWithPrf(
assertionOptions, assertionOptions,
await getLoginWithPrfSalt(), await getLoginWithPrfSalt()
[credentialId]
); );
const prfResult = readPrfFirstResult(assertion); const prfResult = readPrfFirstResult(assertion);
if (!prfResult) { if (!prfResult) {
+29 -12
View File
@@ -15,45 +15,62 @@ export async function listAdminInvites(authedFetch: AuthedFetch): Promise<AdminI
return body?.data || []; return body?.data || [];
} }
export async function createInvite(authedFetch: AuthedFetch, hours: number): Promise<void> { export async function createInvite(authedFetch: AuthedFetch, hours: number, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites', { const resp = await authedFetch('/api/admin/invites', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expiresInHours: hours }), body: JSON.stringify({ expiresInHours: hours, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error('Create invite failed'); if (!resp.ok) throw new Error('Create invite failed');
} }
export async function deleteInvite(authedFetch: AuthedFetch, code: string): Promise<void> { export async function deleteInvite(authedFetch: AuthedFetch, code: string, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' }); const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete invite failed'); if (!resp.ok) throw new Error('Delete invite failed');
} }
export async function deleteInvalidInvites(authedFetch: AuthedFetch): Promise<void> { export async function deleteInvalidInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites?scope=invalid', { method: 'DELETE' }); const resp = await authedFetch('/api/admin/invites?scope=invalid', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete invalid invites failed'); if (!resp.ok) throw new Error('Delete invalid invites failed');
} }
export async function deleteAllInvites(authedFetch: AuthedFetch): Promise<void> { export async function deleteAllInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites', { method: 'DELETE' }); const resp = await authedFetch('/api/admin/invites', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete all invites failed'); if (!resp.ok) throw new Error('Delete all invites failed');
} }
export async function setUserStatus( export async function setUserStatus(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
userId: string, userId: string,
status: 'active' | 'banned' status: 'active' | 'banned',
masterPasswordHash: string
): Promise<void> { ): Promise<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, { const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }), body: JSON.stringify({ status, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error('Update user status failed'); if (!resp.ok) throw new Error('Update user status failed');
} }
export async function deleteUser(authedFetch: AuthedFetch, userId: string): Promise<void> { export async function deleteUser(authedFetch: AuthedFetch, userId: string, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }); const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete user failed'); if (!resp.ok) throw new Error('Delete user failed');
} }
-1
View File
@@ -52,7 +52,6 @@ export async function respondToAuthRequest(
requestId: string, requestId: string,
payload: { payload: {
key?: string | null; key?: string | null;
masterPasswordHash?: string | null;
deviceIdentifier: string; deviceIdentifier: string;
requestApproved: boolean; requestApproved: boolean;
} }
+20 -7
View File
@@ -42,6 +42,7 @@ interface RefreshFailure {
ok: false; ok: false;
transient: boolean; transient: boolean;
error: string; error: string;
retryAfterMs?: number;
} }
interface RefreshSuccess { interface RefreshSuccess {
@@ -333,8 +334,8 @@ export async function loginWithAccountPasskeyAssertion(assertion: AccountPasskey
return json; return json;
} }
function isTransientRefreshStatus(status: number): boolean { function isPermanentRefreshFailure(status: number, errorCode: string | undefined): boolean {
return status === 0 || status === 429 || status >= 500; return status === 400 && (errorCode === 'invalid_grant' || errorCode === 'invalid_request');
} }
export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> { export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> {
@@ -346,6 +347,8 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
try { try {
const resp = await fetch('/identity/connect/token', { const resp = await fetch('/identity/connect/token', {
method: 'POST', method: 'POST',
cache: 'no-store',
credentials: 'same-origin',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}), ...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
@@ -354,15 +357,19 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
}); });
if (!resp.ok) { if (!resp.ok) {
const json = await parseJson<TokenError>(resp); const json = await parseJson<TokenError>(resp);
const retryAfterSeconds = Number(resp.headers.get('Retry-After') || 0);
return { return {
ok: false, ok: false,
transient: isTransientRefreshStatus(resp.status), transient: !isPermanentRefreshFailure(resp.status, json?.error),
error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_failed')), error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_temporarily_unavailable')),
...(Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
? { retryAfterMs: retryAfterSeconds * 1000 }
: {}),
}; };
} }
const json = await parseJson<TokenSuccess>(resp); const json = await parseJson<TokenSuccess>(resp);
if (!json?.access_token) { if (!json?.access_token) {
return { ok: false, transient: false, error: t('txt_session_refresh_failed') }; return { ok: false, transient: true, error: t('txt_session_refresh_temporarily_unavailable') };
} }
return { ok: true, token: json }; return { ok: true, token: json };
} catch (error) { } catch (error) {
@@ -400,6 +407,8 @@ export async function revokeCurrentSession(session: SessionState | null): Promis
} }
await fetch('/identity/connect/revocation', { await fetch('/identity/connect/revocation', {
method: 'POST', method: 'POST',
cache: 'no-store',
credentials: 'same-origin',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}), ...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}),
@@ -1140,8 +1149,12 @@ export async function updateAuthorizedDeviceName(
if (!resp.ok) throw new Error(t('txt_update_device_note_failed')); if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
} }
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> { export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/devices', { method: 'DELETE' }); const resp = await authedFetch('/api/devices', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
} }
+14 -9
View File
@@ -196,11 +196,14 @@ export async function exportAdminBackup(
export async function downloadAdminBackupAttachmentBlob( export async function downloadAdminBackupAttachmentBlob(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
blobName: string blobName: string,
masterPasswordHash: string
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const params = new URLSearchParams(); const resp = await authedFetch('/api/admin/backup/blob', {
params.set('blobName', blobName); method: 'POST',
const resp = await authedFetch(`/api/admin/backup/blob?${params.toString()}`, { method: 'GET' }); headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ blobName, masterPasswordHash }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
return new Uint8Array(await resp.arrayBuffer()); return new Uint8Array(await resp.arrayBuffer());
} }
@@ -246,7 +249,7 @@ export async function buildCompleteAdminBackupExport(
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail', stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
}); });
for (const attachment of manifest.attachmentBlobs || []) { for (const attachment of manifest.attachmentBlobs || []) {
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName); const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName, masterPasswordHash);
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes; zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
} }
@@ -417,13 +420,15 @@ export async function deleteRemoteBackup(
export async function inspectRemoteBackupIntegrity( export async function inspectRemoteBackupIntegrity(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string, destinationId: string,
path: string path: string
): Promise<RemoteBackupIntegrityResponse> { ): Promise<RemoteBackupIntegrityResponse> {
const params = new URLSearchParams(); const resp = await authedFetch('/api/admin/backup/remote/integrity', {
params.set('destinationId', destinationId); method: 'POST',
params.set('path', path); headers: { 'Content-Type': 'application/json' },
const resp = await authedFetch(`/api/admin/backup/remote/integrity?${params.toString()}`, { method: 'GET' }); body: JSON.stringify({ destinationId, path, masterPasswordHash }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
const body = await parseJson<RemoteBackupIntegrityResponse>(resp); const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response')); if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
+59 -11
View File
@@ -27,6 +27,7 @@ import {
unlockOfflineVaultWithMasterKey, unlockOfflineVaultWithMasterKey,
} from '@/lib/offline-auth'; } from '@/lib/offline-auth';
import { probeNodeWardenService } from '@/lib/network-status'; import { probeNodeWardenService } from '@/lib/network-status';
import { setWebsiteIconsEnabled } from '@/lib/website-icon-settings';
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types'; import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
export interface PendingTotp { export interface PendingTotp {
@@ -51,6 +52,7 @@ export type JwtUnsafeReason = 'missing' | 'too_short';
export interface BootstrapAppResult { export interface BootstrapAppResult {
defaultKdfIterations: number; defaultKdfIterations: number;
registrationInviteRequired?: boolean; registrationInviteRequired?: boolean;
websiteIconsEnabled: boolean;
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null; jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
session: SessionState | null; session: SessionState | null;
profile: Profile | null; profile: Profile | null;
@@ -61,6 +63,7 @@ export interface BootstrapAppResult {
export interface InitialAppBootstrapState { export interface InitialAppBootstrapState {
defaultKdfIterations: number; defaultKdfIterations: number;
registrationInviteRequired?: boolean; registrationInviteRequired?: boolean;
websiteIconsEnabled: boolean;
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null; jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
session: SessionState | null; session: SessionState | null;
phase: AppPhase; phase: AppPhase;
@@ -196,26 +199,43 @@ function decodeJwtExp(accessToken: string | undefined): number | null {
} }
} }
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> { type SessionRefreshOutcome =
if (!session.refreshToken && session.authMode !== 'web-cookie') return session.accessToken ? session : null; | { kind: 'success'; session: SessionState }
| { kind: 'transient'; session: SessionState; message: string; retryAfterMs?: number }
| { kind: 'expired' };
async function maybeRefreshSession(session: SessionState): Promise<SessionRefreshOutcome> {
if (!session.refreshToken && session.authMode !== 'web-cookie') {
return session.accessToken ? { kind: 'success', session } : { kind: 'expired' };
}
const exp = decodeJwtExp(session.accessToken); const exp = decodeJwtExp(session.accessToken);
const nowSeconds = Math.floor(Date.now() / 1000); const nowSeconds = Math.floor(Date.now() / 1000);
if (session.accessToken && exp !== null && exp - nowSeconds > 60) { if (session.accessToken && exp !== null && exp - nowSeconds > 60) {
return session; return { kind: 'success', session };
} }
const refreshed = await refreshAccessToken(session); const refreshed = await refreshAccessToken(session);
if (!refreshed.ok) { if (!refreshed.ok) {
if (refreshed.transient) return session; if (refreshed.transient) {
return session.accessToken && exp !== null && exp > nowSeconds ? session : null; return {
kind: 'transient',
session,
message: refreshed.error || t('txt_session_refresh_temporarily_unavailable'),
retryAfterMs: refreshed.retryAfterMs,
};
}
return { kind: 'expired' };
} }
return { return {
kind: 'success',
session: {
...session, ...session,
accessToken: refreshed.token.access_token, accessToken: refreshed.token.access_token,
refreshToken: refreshed.token.refresh_token || session.refreshToken, refreshToken: refreshed.token.refresh_token || session.refreshToken,
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'), authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
},
}; };
} }
@@ -229,10 +249,11 @@ function readWindowBootstrap(): WebBootstrapResponse {
return raw && typeof raw === 'object' ? raw : {}; return raw && typeof raw === 'object' ? raw : {};
} }
function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialAppBootstrapState, 'defaultKdfIterations' | 'registrationInviteRequired' | 'jwtWarning'> { function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialAppBootstrapState, 'defaultKdfIterations' | 'registrationInviteRequired' | 'websiteIconsEnabled' | 'jwtWarning'> {
const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000); const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000);
const registrationInviteRequired = const registrationInviteRequired =
typeof boot.registrationInviteRequired === 'boolean' ? boot.registrationInviteRequired : undefined; typeof boot.registrationInviteRequired === 'boolean' ? boot.registrationInviteRequired : undefined;
const websiteIconsEnabled = boot.websiteIconsEnabled !== false;
const jwtUnsafeReason = boot.jwtUnsafeReason || null; const jwtUnsafeReason = boot.jwtUnsafeReason || null;
const jwtWarning = jwtUnsafeReason const jwtWarning = jwtUnsafeReason
? { ? {
@@ -244,6 +265,7 @@ function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialApp
return { return {
defaultKdfIterations, defaultKdfIterations,
registrationInviteRequired, registrationInviteRequired,
websiteIconsEnabled,
jwtWarning, jwtWarning,
}; };
} }
@@ -304,7 +326,8 @@ function resolveUnauthenticatedPhase(registrationInviteRequired: boolean | undef
} }
export function readInitialAppBootstrapState(): InitialAppBootstrapState { export function readInitialAppBootstrapState(): InitialAppBootstrapState {
const { defaultKdfIterations, registrationInviteRequired, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap()); const { defaultKdfIterations, registrationInviteRequired, websiteIconsEnabled, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
setWebsiteIconsEnabled(websiteIconsEnabled);
const session = loadSession(); const session = loadSession();
const hasInviteCode = !!readInviteCodeFromUrl(); const hasInviteCode = !!readInviteCodeFromUrl();
const unauthenticatedPhase = hasInviteCode ? 'register' : 'login'; const unauthenticatedPhase = hasInviteCode ? 'register' : 'login';
@@ -312,6 +335,7 @@ export function readInitialAppBootstrapState(): InitialAppBootstrapState {
return { return {
defaultKdfIterations, defaultKdfIterations,
registrationInviteRequired, registrationInviteRequired,
websiteIconsEnabled,
jwtWarning, jwtWarning,
session, session,
phase: jwtWarning ? 'login' : session ? 'locked' : resolveUnauthenticatedPhase(registrationInviteRequired, unauthenticatedPhase), phase: jwtWarning ? 'login' : session ? 'locked' : resolveUnauthenticatedPhase(registrationInviteRequired, unauthenticatedPhase),
@@ -323,12 +347,15 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
const normalizedBoot = normalizeBootstrapResponse(remoteBoot); const normalizedBoot = normalizeBootstrapResponse(remoteBoot);
const defaultKdfIterations = normalizedBoot.defaultKdfIterations || initial.defaultKdfIterations; const defaultKdfIterations = normalizedBoot.defaultKdfIterations || initial.defaultKdfIterations;
const registrationInviteRequired = normalizedBoot.registrationInviteRequired ?? initial.registrationInviteRequired; const registrationInviteRequired = normalizedBoot.registrationInviteRequired ?? initial.registrationInviteRequired;
const websiteIconsEnabled = normalizedBoot.websiteIconsEnabled !== false;
setWebsiteIconsEnabled(websiteIconsEnabled);
const jwtWarning = normalizedBoot.jwtWarning ?? initial.jwtWarning; const jwtWarning = normalizedBoot.jwtWarning ?? initial.jwtWarning;
if (jwtWarning) { if (jwtWarning) {
return { return {
defaultKdfIterations, defaultKdfIterations,
registrationInviteRequired, registrationInviteRequired,
websiteIconsEnabled,
jwtWarning, jwtWarning,
session: null, session: null,
profile: null, profile: null,
@@ -341,6 +368,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
return { return {
defaultKdfIterations, defaultKdfIterations,
registrationInviteRequired, registrationInviteRequired,
websiteIconsEnabled,
jwtWarning: null, jwtWarning: null,
session: null, session: null,
profile: null, profile: null,
@@ -353,6 +381,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
return { return {
defaultKdfIterations, defaultKdfIterations,
registrationInviteRequired, registrationInviteRequired,
websiteIconsEnabled,
jwtWarning: null, jwtWarning: null,
session: loaded, session: loaded,
profile: cachedProfile, profile: cachedProfile,
@@ -364,6 +393,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
return { return {
defaultKdfIterations, defaultKdfIterations,
registrationInviteRequired, registrationInviteRequired,
websiteIconsEnabled,
jwtWarning: null, jwtWarning: null,
session: loaded, session: loaded,
profile: null, profile: null,
@@ -375,25 +405,41 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
export async function hydrateLockedSession( export async function hydrateLockedSession(
session: SessionState, session: SessionState,
fallbackProfile: Profile | null = null fallbackProfile: Profile | null = null
): Promise<{ session: SessionState | null; profile: Profile | null }> { ): Promise<
| { kind: 'ready'; session: SessionState; profile: Profile | null }
| { kind: 'transient'; session: SessionState; profile: Profile | null; message: string; retryAfterMs?: number }
| { kind: 'expired'; session: null; profile: null }
> {
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email); const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
if (hasOfflineUnlock && browserReportsOffline()) { if (hasOfflineUnlock && browserReportsOffline()) {
return { return {
kind: 'ready',
session, session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email), profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
}; };
} }
const refreshedSession = await maybeRefreshSession(session); const refreshOutcome = await maybeRefreshSession(session);
if (!refreshedSession?.accessToken) { if (refreshOutcome.kind === 'expired') {
return { kind: 'expired', session: null, profile: null };
}
if (refreshOutcome.kind === 'transient') {
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) { if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
return { return {
kind: 'ready',
session, session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email), profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
}; };
} }
return { session: null, profile: null }; return {
kind: 'transient',
session,
profile: fallbackProfile,
message: refreshOutcome.message,
retryAfterMs: refreshOutcome.retryAfterMs,
};
} }
const refreshedSession = refreshOutcome.session;
try { try {
const profile = await getProfile( const profile = await getProfile(
createAuthedFetch( createAuthedFetch(
@@ -402,11 +448,13 @@ export async function hydrateLockedSession(
) )
); );
return { return {
kind: 'ready',
session: refreshedSession, session: refreshedSession,
profile, profile,
}; };
} catch { } catch {
return { return {
kind: 'ready',
session: refreshedSession, session: refreshedSession,
profile: fallbackProfile, profile: fallbackProfile,
}; };
+219 -27
View File
@@ -259,17 +259,33 @@ interface TotpConfig {
period: number; period: number;
} }
interface GoogleAuthenticatorMigrationTotp {
secret: string;
name: string;
issuer: string;
algorithm: TotpHashAlgorithm;
digits: number;
period: number;
}
const DEFAULT_TOTP_CONFIG: Omit<TotpConfig, 'secret' | 'steam'> = { const DEFAULT_TOTP_CONFIG: Omit<TotpConfig, 'secret' | 'steam'> = {
algorithm: 'SHA-1', algorithm: 'SHA-1',
digits: 6, digits: 6,
period: 30, period: 30,
}; };
function parseTotpPositiveInt(value: string | null, fallback: number, min: number, max: number): number { function parseTotpDigits(value: string | null): number {
if (!value) return fallback; if (!value) return DEFAULT_TOTP_CONFIG.digits;
const parsed = Number(value); const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < min || parsed > max) return fallback; if (!Number.isInteger(parsed)) return DEFAULT_TOTP_CONFIG.digits;
return parsed; return Math.max(0, Math.min(10, parsed));
}
function parseTotpPeriod(value: string | null): number {
if (!value) return DEFAULT_TOTP_CONFIG.period;
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) return DEFAULT_TOTP_CONFIG.period;
return Math.max(1, parsed);
} }
function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm { function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
@@ -279,9 +295,190 @@ function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
return 'SHA-1'; return 'SHA-1';
} }
function parseTotpConfig(raw: string): TotpConfig { function base64ToBytesLoose(value: string): Uint8Array {
if (!raw) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG }; const normalized = value.trim().replace(/\s/g, '+').replace(/-/g, '+').replace(/_/g, '/');
if (!normalized) return new Uint8Array();
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
try {
const binary = atob(padded);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
} catch {
return new Uint8Array();
}
}
function bytesToBase32(bytes: Uint8Array): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = 0;
let value = 0;
let out = '';
for (const byte of bytes) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += alphabet[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
out += alphabet[(value << (5 - bits)) & 31];
}
return out;
}
function readProtoVarint(bytes: Uint8Array, state: { offset: number }): number | null {
let result = 0;
let factor = 1;
for (let i = 0; i < 10 && state.offset < bytes.length; i += 1) {
const byte = bytes[state.offset++];
result += (byte & 0x7f) * factor;
if ((byte & 0x80) === 0) return Number.isSafeInteger(result) ? result : null;
factor *= 128;
}
return null;
}
function readProtoBytes(bytes: Uint8Array, state: { offset: number }): Uint8Array | null {
const length = readProtoVarint(bytes, state);
if (length == null || length < 0 || state.offset + length > bytes.length) return null;
const out = bytes.slice(state.offset, state.offset + length);
state.offset += length;
return out;
}
function skipProtoField(bytes: Uint8Array, state: { offset: number }, wireType: number): boolean {
if (wireType === 0) return readProtoVarint(bytes, state) != null;
if (wireType === 1 && state.offset + 8 <= bytes.length) {
state.offset += 8;
return true;
}
if (wireType === 2) return readProtoBytes(bytes, state) != null;
if (wireType === 5 && state.offset + 4 <= bytes.length) {
state.offset += 4;
return true;
}
return false;
}
function googleMigrationAlgorithm(value: number): TotpHashAlgorithm | null {
if (value === 0 || value === 1) return 'SHA-1';
if (value === 2) return 'SHA-256';
if (value === 3) return 'SHA-512';
return null;
}
function googleMigrationDigits(value: number): number {
if (value === 2) return 8;
return 6;
}
function parseGoogleMigrationOtpParameter(bytes: Uint8Array): GoogleAuthenticatorMigrationTotp | null {
const state = { offset: 0 };
let secretBytes: Uint8Array | null = null;
let name = '';
let issuer = '';
let algorithm: TotpHashAlgorithm | null = 'SHA-1';
let digits = 6;
let otpType = 0;
const decoder = new TextDecoder();
while (state.offset < bytes.length) {
const key = readProtoVarint(bytes, state);
if (key == null) return null;
const fieldNumber = Math.floor(key / 8);
const wireType = key % 8;
if (fieldNumber === 1 && wireType === 2) {
secretBytes = readProtoBytes(bytes, state);
} else if (fieldNumber === 2 && wireType === 2) {
const value = readProtoBytes(bytes, state);
name = value ? decoder.decode(value) : '';
} else if (fieldNumber === 3 && wireType === 2) {
const value = readProtoBytes(bytes, state);
issuer = value ? decoder.decode(value) : '';
} else if (fieldNumber === 4 && wireType === 0) {
const value = readProtoVarint(bytes, state);
algorithm = value == null ? null : googleMigrationAlgorithm(value);
} else if (fieldNumber === 5 && wireType === 0) {
const value = readProtoVarint(bytes, state);
digits = googleMigrationDigits(value ?? 0);
} else if (fieldNumber === 6 && wireType === 0) {
otpType = readProtoVarint(bytes, state) ?? 0;
} else if (!skipProtoField(bytes, state, wireType)) {
return null;
}
}
if (!secretBytes?.length || !algorithm || otpType === 1) return null;
return {
secret: bytesToBase32(secretBytes),
name,
issuer,
algorithm,
digits,
period: DEFAULT_TOTP_CONFIG.period,
};
}
function parseGoogleAuthenticatorMigration(raw: string): GoogleAuthenticatorMigrationTotp[] {
let data = '';
try {
data = new URL(raw).searchParams.get('data') || '';
} catch {
data = readOtpAuthParam(raw, 'data');
}
const bytes = base64ToBytesLoose(data);
if (!bytes.length) return [];
const state = { offset: 0 };
const out: GoogleAuthenticatorMigrationTotp[] = [];
while (state.offset < bytes.length) {
const key = readProtoVarint(bytes, state);
if (key == null) return [];
const fieldNumber = Math.floor(key / 8);
const wireType = key % 8;
if (fieldNumber === 1 && wireType === 2) {
const parameterBytes = readProtoBytes(bytes, state);
const parameter = parameterBytes ? parseGoogleMigrationOtpParameter(parameterBytes) : null;
if (parameter) out.push(parameter);
} else if (!skipProtoField(bytes, state, wireType)) {
return [];
}
}
return out;
}
function buildOtpAuthUri(account: GoogleAuthenticatorMigrationTotp): string {
const issuer = account.issuer.trim();
const name = account.name.trim();
const label = issuer && name && !name.toLowerCase().startsWith(`${issuer.toLowerCase()}:`)
? `${issuer}:${name}`
: name || issuer || 'TOTP';
const params = new URLSearchParams({
secret: account.secret,
algorithm: account.algorithm.replace('-', ''),
digits: String(account.digits),
period: String(account.period),
});
if (issuer) params.set('issuer', issuer);
return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
}
export function normalizeTotpInput(raw: string): string {
const s = raw.trim(); const s = raw.trim();
if (!s) return '';
if (/^otpauth-migration:\/\//i.test(s)) {
const accounts = parseGoogleAuthenticatorMigration(s);
return accounts.length === 1 ? buildOtpAuthUri(accounts[0]) : '';
}
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s) && !/^otpauth:\/\//i.test(s) && !/^steam:\/\//i.test(s)) {
return '';
}
return s;
}
function parseTotpConfig(raw: string): TotpConfig {
const s = normalizeTotpInput(raw);
if (!s) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG }; if (!s) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
if (/^steam:\/\//i.test(s)) { if (/^steam:\/\//i.test(s)) {
return { return {
@@ -295,31 +492,20 @@ function parseTotpConfig(raw: string): TotpConfig {
if (/^otpauth:\/\//i.test(s)) { if (/^otpauth:\/\//i.test(s)) {
try { try {
const u = new URL(s); const u = new URL(s);
const otpType = u.hostname.toLowerCase();
if (otpType !== 'totp') {
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
}
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
const issuer = (u.searchParams.get('issuer') || '').trim().toLowerCase();
const algorithm = (u.searchParams.get('algorithm') || '').trim().toLowerCase();
const steam = issuer === 'steam' || label.startsWith('steam:') || algorithm === 'steam';
return { return {
secret: normalizeTotpSecret(u.searchParams.get('secret') || ''), secret: normalizeTotpSecret(u.searchParams.get('secret') || ''),
steam, steam: false,
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(u.searchParams.get('algorithm')), algorithm: parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
digits: steam ? 5 : parseTotpPositiveInt(u.searchParams.get('digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10), digits: parseTotpDigits(u.searchParams.get('digits')),
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600), period: parseTotpPeriod(u.searchParams.get('period')),
}; };
} catch { } catch {
const issuer = readOtpAuthParam(s, 'issuer').trim().toLowerCase();
const algorithm = readOtpAuthParam(s, 'algorithm').trim().toLowerCase();
const steam = issuer === 'steam' || algorithm === 'steam';
return { return {
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')), secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
steam, steam: false,
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(algorithm), algorithm: parseTotpHashAlgorithm(readOtpAuthParam(s, 'algorithm')),
digits: steam ? 5 : parseTotpPositiveInt(readOtpAuthParam(s, 'digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10), digits: parseTotpDigits(readOtpAuthParam(s, 'digits')),
period: parseTotpPositiveInt(readOtpAuthParam(s, 'period'), DEFAULT_TOTP_CONFIG.period, 1, 3600), period: parseTotpPeriod(readOtpAuthParam(s, 'period')),
}; };
} }
} }
@@ -349,7 +535,13 @@ function base32ToBytes(input: string): Uint8Array {
return new Uint8Array(out); return new Uint8Array(out);
} }
export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise<{ code: string; remain: number } | null> { export interface TotpCodeResult {
code: string;
remain: number;
period: number;
}
export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise<TotpCodeResult | null> {
const { secret, steam, algorithm, digits, period } = parseTotpConfig(rawSecret); const { secret, steam, algorithm, digits, period } = parseTotpConfig(rawSecret);
if (!secret) return null; if (!secret) return null;
const keyBytes = base32ToBytes(secret); const keyBytes = base32ToBytes(secret);
@@ -378,5 +570,5 @@ export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now())
value = Math.floor(value / chars.length); value = Math.floor(value / chars.length);
} }
} }
return { code, remain }; return { code, remain, period };
} }
+1
View File
@@ -20,6 +20,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
return { return {
defaultKdfIterations: 600000, defaultKdfIterations: 600000,
registrationInviteRequired: true, registrationInviteRequired: true,
websiteIconsEnabled: true,
jwtWarning: null, jwtWarning: null,
session: null, session: null,
phase: 'login', phase: 'login',
+399 -1
View File
@@ -9,6 +9,7 @@ import type {
import type { import type {
AdminInvite, AdminInvite,
AdminUser, AdminUser,
AuditLogEntry,
AuthorizedDevice, AuthorizedDevice,
Cipher, Cipher,
Folder, Folder,
@@ -383,6 +384,143 @@ export const DEMO_CIPHERS: Cipher[] = [
decFingerprint: 'SHA256:demoNodeWardenFingerprint', decFingerprint: 'SHA256:demoNodeWardenFingerprint',
}, },
}, },
// --- Duplicate detection demo pairs (exact, login-site, login-credentials, password) ---
{
id: 'cipher-dup-exact-a',
type: 1,
folderId: 'folder-work',
favorite: false,
name: 'Internal VPN',
decName: 'Internal VPN',
creationDate: '2026-04-10T08:00:00.000Z',
revisionDate: '2026-04-28T10:00:00.000Z',
login: {
username: 'vpn-user',
password: 'vpn-secret-2026', // gitguardian:ignore
decUsername: 'vpn-user',
decPassword: 'vpn-secret-2026', // gitguardian:ignore
uris: [{ uri: 'https://vpn.internal.example.com', decUri: 'https://vpn.internal.example.com', match: null }],
},
},
{
id: 'cipher-dup-exact-b',
type: 1,
folderId: 'folder-work',
favorite: false,
name: 'Internal VPN',
decName: 'Internal VPN',
creationDate: '2026-03-15T08:00:00.000Z',
revisionDate: '2026-04-30T10:00:00.000Z',
login: {
username: 'vpn-user',
password: 'vpn-secret-2026', // gitguardian:ignore
decUsername: 'vpn-user',
decPassword: 'vpn-secret-2026', // gitguardian:ignore
uris: [{ uri: 'https://vpn.internal.example.com', decUri: 'https://vpn.internal.example.com', match: null }],
},
},
{
id: 'cipher-dup-site-a',
type: 1,
folderId: 'folder-devops',
favorite: false,
name: 'AWS Console',
decName: 'AWS Console',
creationDate: '2026-03-01T08:00:00.000Z',
revisionDate: '2026-04-25T09:00:00.000Z',
login: {
username: 'aws-admin',
password: 'aws-secure-password', // gitguardian:ignore
decUsername: 'aws-admin',
decPassword: 'aws-secure-password', // gitguardian:ignore
uris: [{ uri: 'https://console.aws.amazon.com', decUri: 'https://console.aws.amazon.com', match: null }],
},
},
{
id: 'cipher-dup-site-b',
type: 1,
folderId: 'folder-devops',
favorite: false,
name: 'Amazon Web Services',
decName: 'Amazon Web Services',
creationDate: '2026-02-20T08:00:00.000Z',
revisionDate: '2026-04-20T09:00:00.000Z',
login: {
username: 'aws-admin',
password: 'aws-secure-password', // gitguardian:ignore
decUsername: 'aws-admin',
decPassword: 'aws-secure-password', // gitguardian:ignore
uris: [{ uri: 'https://console.aws.amazon.com', decUri: 'https://console.aws.amazon.com', match: null }],
},
},
{
id: 'cipher-dup-cred-a',
type: 1,
folderId: 'folder-personal',
favorite: false,
name: 'Personal Blog',
decName: 'Personal Blog',
creationDate: '2026-01-10T08:00:00.000Z',
revisionDate: '2026-04-15T10:00:00.000Z',
login: {
username: 'my-account@example.com',
password: 'shared-credential', // gitguardian:ignore
decUsername: 'my-account@example.com',
decPassword: 'shared-credential', // gitguardian:ignore
uris: [{ uri: 'https://blog.example.com', decUri: 'https://blog.example.com', match: null }],
},
},
{
id: 'cipher-dup-cred-b',
type: 1,
folderId: 'folder-personal',
favorite: false,
name: 'Forum Account',
decName: 'Forum Account',
creationDate: '2026-01-15T08:00:00.000Z',
revisionDate: '2026-04-18T10:00:00.000Z',
login: {
username: 'my-account@example.com',
password: 'shared-credential', // gitguardian:ignore
decUsername: 'my-account@example.com',
decPassword: 'shared-credential', // gitguardian:ignore
uris: [{ uri: 'https://forum.example.com', decUri: 'https://forum.example.com', match: null }],
},
},
{
id: 'cipher-dup-pw-a',
type: 1,
folderId: 'folder-personal',
favorite: false,
name: 'Old Forum',
decName: 'Old Forum',
creationDate: '2025-06-01T08:00:00.000Z',
revisionDate: '2026-03-01T10:00:00.000Z',
login: {
username: 'legacy-user',
password: 'reused-password-2020', // gitguardian:ignore
decUsername: 'legacy-user',
decPassword: 'reused-password-2020', // gitguardian:ignore
uris: [{ uri: 'https://old-forum.example.com', decUri: 'https://old-forum.example.com', match: null }],
},
},
{
id: 'cipher-dup-pw-b',
type: 1,
folderId: 'folder-personal',
favorite: false,
name: 'Legacy CMS',
decName: 'Legacy CMS',
creationDate: '2025-05-10T08:00:00.000Z',
revisionDate: '2026-02-15T10:00:00.000Z',
login: {
username: 'cms-admin',
password: 'reused-password-2020', // gitguardian:ignore
decUsername: 'cms-admin',
decPassword: 'reused-password-2020', // gitguardian:ignore
uris: [{ uri: 'https://cms.example.com', decUri: 'https://cms.example.com', match: null }],
},
},
{ {
id: 'cipher-archived', id: 'cipher-archived',
type: 1, type: 1,
@@ -575,6 +713,233 @@ export const DEMO_BACKUP_SETTINGS: AdminBackupSettings = {
], ],
}; };
export const DEMO_AUDIT_LOGS: AuditLogEntry[] = [
{
id: 'demo-log-auth-login',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'auth.login.success',
category: 'auth',
level: 'info',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ ip: '203.0.113.42', device: 'Chrome 125 on Windows', location: 'San Francisco, US' }),
createdAt: '2026-07-08T14:32:10.000Z',
object: 'auditLog',
},
{
id: 'demo-log-auth-failed',
actorUserId: null,
actorEmail: 'unknown@example.com',
action: 'auth.login.failed',
category: 'auth',
level: 'warn',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ ip: '198.51.100.7', reason: 'invalid_password', attemptCount: 3 }),
createdAt: '2026-07-08T13:15:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-auth-2fa',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'auth.totp.enabled',
category: 'auth',
level: 'security',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ ip: '203.0.113.42', trigger: 'user_initiated' }),
createdAt: '2026-07-07T09:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-auth-refresh-failed',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'auth.refresh.failed.token_expired',
category: 'auth',
level: 'error',
targetType: null,
targetId: 'demo-device-browser',
targetUserEmail: null,
metadata: JSON.stringify({ ip: '203.0.113.42', device: 'Chrome 125 on Windows' }),
createdAt: '2026-07-06T18:45:30.000Z',
object: 'auditLog',
},
{
id: 'demo-log-security-password',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'user.password.changed',
category: 'security',
level: 'security',
targetType: 'user',
targetId: DEMO_USER_ID,
targetUserEmail: DEMO_PROFILE.email,
metadata: JSON.stringify({ ip: '203.0.113.42', trigger: 'user_initiated' }),
createdAt: '2026-07-05T10:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-security-user-banned',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.user.banned',
category: 'security',
level: 'security',
targetType: 'user',
targetId: 'demo-user-003',
targetUserEmail: 'suspended@example.com',
metadata: JSON.stringify({ ip: '203.0.113.42', reason: 'violation_of_tos' }),
createdAt: '2026-07-04T16:20:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-security-user-register',
actorUserId: null,
actorEmail: 'newuser@example.com',
action: 'user.register.completed',
category: 'security',
level: 'info',
targetType: 'user',
targetId: 'demo-user-004',
targetUserEmail: 'newuser@example.com',
metadata: JSON.stringify({ ip: '192.0.2.55', invite: 'DEMO-INVITE-2026' }),
createdAt: '2026-07-03T08:30:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-device-trusted',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'device.trusted.added',
category: 'device',
level: 'info',
targetType: 'device',
targetId: 'demo-device-mobile',
targetUserEmail: null,
metadata: JSON.stringify({ deviceName: 'iPhone', os: 'iOS 18', ip: '203.0.113.42' }),
createdAt: '2026-07-02T12:15:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-device-removed',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'device.removed',
category: 'device',
level: 'warn',
targetType: 'device',
targetId: 'demo-device-old',
targetUserEmail: null,
metadata: JSON.stringify({ deviceName: 'Firefox on Linux', ip: '198.51.100.20', trigger: 'user_initiated' }),
createdAt: '2026-07-01T09:45:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-device-all-revoked',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'device.all_trust_revoked',
category: 'device',
level: 'security',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ ip: '203.0.113.42', trigger: 'password_change' }),
createdAt: '2026-07-01T09:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-data-backup',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.backup.run.completed',
category: 'data',
level: 'info',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ fileName: 'nodewarden_backup_20260701_030000.zip', size: '1.2 MB', destination: 'Demo WebDAV' }),
createdAt: '2026-07-01T03:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-data-restore',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.backup.restore.completed',
category: 'data',
level: 'warn',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ fileName: 'nodewarden_backup_20260628_030000.zip', checksum: 'verified' }),
createdAt: '2026-06-30T14:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-data-export',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.export.completed',
category: 'data',
level: 'info',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ format: 'encrypted_json', totalItems: 24 }),
createdAt: '2026-06-28T11:30:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-system-settings',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.settings.updated',
category: 'system',
level: 'info',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ changedKeys: ['signupsAllowed', 'kdfIterations'], ip: '203.0.113.42' }),
createdAt: '2026-06-25T08:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-system-invite',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.invite.created',
category: 'system',
level: 'info',
targetType: 'invite',
targetId: 'DEMO-INVITE-2026',
targetUserEmail: null,
metadata: JSON.stringify({ expiresIn: '168h', ip: '203.0.113.42' }),
createdAt: '2026-06-20T10:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-system-config',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.config.updated',
category: 'system',
level: 'warn',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ changedKeys: ['smtp.host', 'smtp.port'], ip: '203.0.113.42' }),
createdAt: '2026-06-18T15:30:00.000Z',
object: 'auditLog',
},
];
function cloneJson<T>(value: T): T { function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T; return JSON.parse(JSON.stringify(value)) as T;
} }
@@ -790,6 +1155,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
return { return {
defaultKdfIterations: 600000, defaultKdfIterations: 600000,
registrationInviteRequired: true, registrationInviteRequired: true,
websiteIconsEnabled: true,
jwtWarning: null, jwtWarning: null,
session: null, session: null,
phase: 'login', phase: 'login',
@@ -1074,6 +1440,38 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onGetRecoveryCode: readonlyString, onGetRecoveryCode: readonlyString,
onGetApiKey: readonlyString, onGetApiKey: readonlyString,
onRotateApiKey: readonlyString, onRotateApiKey: readonlyString,
onListAccountPasskeys: async () => [],
onCreateAccountPasskey: async () => {
await readonly();
return null;
},
onEnableAccountPasskeyDirectUnlock: readonly,
onDeleteAccountPasskey: readonly,
onLoadAuditLogs: async (filters) => {
const limit = Number(filters.limit || 50) || 50;
const offset = Number(filters.offset || 0) || 0;
let filtered = DEMO_AUDIT_LOGS.filter((log) => {
if (filters.category && filters.category !== 'all' && log.category !== filters.category) return false;
if (filters.level && filters.level !== 'all' && log.level !== filters.level) return false;
if (filters.q) {
const q = filters.q.toLowerCase();
if (!log.action.toLowerCase().includes(q) && !(log.actorEmail || '').toLowerCase().includes(q)) return false;
}
if (filters.from && new Date(log.createdAt).getTime() < new Date(filters.from).getTime()) return false;
if (filters.to && new Date(log.createdAt).getTime() > new Date(filters.to).getTime()) return false;
return true;
});
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const total = filtered.length;
const sliced = filtered.slice(offset, offset + limit);
return {
logs: sliced,
total,
limit,
offset: offset + sliced.length,
hasMore: offset + sliced.length < total,
};
},
onLockTimeoutChange: readonlyVoid, onLockTimeoutChange: readonlyVoid,
onSessionTimeoutActionChange: readonlyVoid, onSessionTimeoutActionChange: readonlyVoid,
onRefreshAuthorizedDevices: async () => { onRefreshAuthorizedDevices: async () => {
@@ -1200,7 +1598,7 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => { onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => {
notify('success', t('txt_demo_download_prepared')); notify('success', t('txt_demo_download_prepared'));
}, },
onInspectRemoteBackup: async (_destinationId: string, path: string) => ({ onInspectRemoteBackup: async (_masterPassword: string, _destinationId: string, path: string) => ({
object: 'backup-remote-integrity', object: 'backup-remote-integrity',
destinationId: _destinationId, destinationId: _destinationId,
path, path,
File diff suppressed because it is too large Load Diff
+21 -4
View File
@@ -10,10 +10,14 @@ export type Locale =
| 'zh-CN' | 'zh-CN'
| 'zh-TW' | 'zh-TW'
| 'ru' | 'ru'
| 'es'; | 'es'
| 'fi'
| 'de'
| 'fr'
| 'it'
| 'sv';
import enMessages from './i18n/locales/en'; import enMessages from './i18n/locales/en';
const LOCALE_STORAGE_KEY = 'nodewarden.locale'; const LOCALE_STORAGE_KEY = 'nodewarden.locale';
type MessageTable = Record<string, string>; type MessageTable = Record<string, string>;
@@ -24,6 +28,11 @@ export const AVAILABLE_LOCALES: readonly { value: Locale; label: string }[] = [
{ value: 'zh-TW', label: '繁體中文' }, { value: 'zh-TW', label: '繁體中文' },
{ value: 'ru', label: 'Русский' }, { value: 'ru', label: 'Русский' },
{ value: 'es', label: 'Español' }, { value: 'es', label: 'Español' },
{ value: 'fi', label: 'Suomi' },
{ value: 'de', label: 'Deutsch' },
{ value: 'fr', label: 'Français' },
{ value: 'it', label: 'Italiano' },
{ value: 'sv', label: 'Svenska' },
]; ];
let locale: Locale = resolveInitialLocale(); let locale: Locale = resolveInitialLocale();
@@ -49,6 +58,11 @@ function resolveInitialLocale(): Locale {
if (normalized.startsWith('zh')) return 'zh-CN'; if (normalized.startsWith('zh')) return 'zh-CN';
if (normalized.startsWith('ru')) return 'ru'; if (normalized.startsWith('ru')) return 'ru';
if (normalized.startsWith('es')) return 'es'; if (normalized.startsWith('es')) return 'es';
if (normalized.startsWith('fi')) return 'fi';
if (normalized.startsWith('de')) return 'de';
if (normalized.startsWith('fr')) return 'fr';
if (normalized.startsWith('it')) return 'it';
if (normalized.startsWith('sv')) return 'sv';
} }
} }
return 'en'; return 'en';
@@ -60,6 +74,11 @@ const localeLoaders: Record<Locale, () => Promise<{ default: MessageTable }>> =
'zh-TW': () => import('./i18n/locales/zh-TW'), 'zh-TW': () => import('./i18n/locales/zh-TW'),
ru: () => import('./i18n/locales/ru'), ru: () => import('./i18n/locales/ru'),
es: () => import('./i18n/locales/es'), es: () => import('./i18n/locales/es'),
fi: () => import('./i18n/locales/fi'),
de: () => import('./i18n/locales/de'),
fr: () => import('./i18n/locales/fr'),
it: () => import('./i18n/locales/it'),
sv: () => import('./i18n/locales/sv'),
}; };
function localeToHtmlLang(value: Locale): string { function localeToHtmlLang(value: Locale): string {
@@ -74,7 +93,6 @@ function syncDocumentLanguage(): void {
async function loadLocaleMessages(next: Locale): Promise<MessageTable> { async function loadLocaleMessages(next: Locale): Promise<MessageTable> {
const cached = loadedMessages.get(next); const cached = loadedMessages.get(next);
if (cached) return cached; if (cached) return cached;
const mod = await localeLoaders[next](); const mod = await localeLoaders[next]();
loadedMessages.set(next, mod.default); loadedMessages.set(next, mod.default);
return mod.default; return mod.default;
@@ -223,7 +241,6 @@ export function translateServerError(message: string | null | undefined, fallbac
'masterPasswordHash is required': 'txt_server_error_master_password_hash_required', 'masterPasswordHash is required': 'txt_server_error_master_password_hash_required',
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required', 'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
}[normalized]; }[normalized];
return key ? t(key) : normalized; return key ? t(key) : normalized;
} }
File diff suppressed because it is too large Load Diff
+65
View File
@@ -7,10 +7,36 @@ const en: Record<string, string> = {
"nav_my_vault": "My Vault", "nav_my_vault": "My Vault",
"nav_vault_items": "Vault", "nav_vault_items": "Vault",
"nav_sends": "Sends", "nav_sends": "Sends",
"nav_generator": "Generator",
"txt_password_generator": "Password Generator",
"txt_password_generator_description": "Create a strong, unique password locally on this device.",
"txt_generator_type": "Generator type",
"txt_passphrase": "Passphrase",
"txt_generated_password": "Generated password",
"txt_password_strength": "Strength",
"txt_password_strength_weak": "Weak",
"txt_password_strength_fair": "Fair",
"txt_password_strength_good": "Good",
"txt_password_strength_strong": "Strong",
"txt_generator_security_note": "Generation happens locally. Your generated password is never sent to the server.",
"txt_generator_length": "Length",
"txt_generator_character_types": "Character types",
"txt_generator_uppercase": "Uppercase (A-Z)",
"txt_generator_lowercase": "Lowercase (a-z)",
"txt_generator_numbers": "Numbers (0-9)",
"txt_generator_special": "Special characters (!@#$%^&*)",
"txt_generator_minimum": "Minimum",
"txt_generator_avoid_ambiguous": "Avoid ambiguous characters",
"txt_generator_words": "Number of words",
"txt_generator_separator": "Word separator",
"txt_generator_capitalize": "Capitalize",
"txt_generator_include_number": "Include a number",
"nav_backup_strategy": "Cloud Backup", "nav_backup_strategy": "Cloud Backup",
"nav_import_export": "Import & Export", "nav_import_export": "Import & Export",
"nav_group_data_backup": "Data & Backup", "nav_group_data_backup": "Data & Backup",
"nav_group_management": "Management", "nav_group_management": "Management",
"nav_group_tools": "Tools",
"nav_group_system_management": "System Management",
"txt_settings_appearance": "Appearance", "txt_settings_appearance": "Appearance",
"txt_theme": "Theme", "txt_theme": "Theme",
"txt_use_system_theme": "Use system theme", "txt_use_system_theme": "Use system theme",
@@ -978,6 +1004,7 @@ const en: Record<string, string> = {
"txt_save_profile_failed": "Save profile failed", "txt_save_profile_failed": "Save profile failed",
"txt_search_sends": "Search sends...", "txt_search_sends": "Search sends...",
"txt_session_refresh_failed": "Session refresh failed. Please sign in again.", "txt_session_refresh_failed": "Session refresh failed. Please sign in again.",
"txt_session_refresh_temporarily_unavailable": "Session verification is temporarily unavailable. Your login is preserved and will retry.",
"txt_search_your_secure_vault": "Search your secure vault...", "txt_search_your_secure_vault": "Search your secure vault...",
"txt_search_items_count": "Search within {count} items...", "txt_search_items_count": "Search within {count} items...",
"txt_clear_search": "Clear search", "txt_clear_search": "Clear search",
@@ -1012,6 +1039,9 @@ const en: Record<string, string> = {
"txt_online": "Online", "txt_online": "Online",
"txt_offline": "Offline", "txt_offline": "Offline",
"txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.", "txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.",
"txt_offline_mode_notice_title": "You are in offline mode. If this looks wrong, force refresh.",
"txt_offline_mode_notice_windows": "Windows:",
"txt_offline_mode_notice_macos": "macOS:",
"txt_submit": "Submit", "txt_submit": "Submit",
"txt_sync": "Sync", "txt_sync": "Sync",
"txt_sync_vault": "Sync Vault", "txt_sync_vault": "Sync Vault",
@@ -1444,4 +1474,39 @@ const en: Record<string, string> = {
"txt_ip_address": "IP address" "txt_ip_address": "IP address"
}; };
Object.assign(en, {
"nav_password_security": "Password Security",
"txt_password_security": "Password Security Check",
"txt_password_security_privacy": "Passwords are checked locally in your browser. Only an anonymous hash prefix is sent to the breach database.",
"txt_check_password_security": "Start check",
"txt_checking_password_security": "Checking",
"txt_recheck_password_security": "Check again",
"txt_password_security_ready": "Your vault is ready for a security check.",
"txt_password_security_no_login": "There are no login passwords to check.",
"txt_password_security_manual": "The check only starts when you choose it. Results are kept until you refresh or your vault changes.",
"txt_password_security_no_login_help": "Add a login item with a password, then return here to check it.",
"txt_exposed_passwords": "Exposed",
"txt_reused_passwords": "Reused",
"txt_weak_passwords": "Weak",
"txt_passwords_checked": "Checked",
"txt_password_security_last_checked": "Last checked: {value}",
"txt_password_security_show_all": "Show all",
"txt_password_security_hide_all": "Hide all",
"txt_password_security_jump": "Go to item",
"txt_password_security_exposed_short": "Exposed {count} times",
"txt_password_security_weak_short": "Weak password",
"txt_password_security_reused_short": "Reused",
"txt_password_security_unavailable": "{count} password checks could not reach the breach database. They are not marked safe.",
"txt_password_security_not_checked": "Not checked",
"txt_password_exposed_count": "Found in {count} breaches",
"txt_password_reused_count": "Used {count} times",
"txt_weak_password": "Weak password",
"txt_no_password_risks": "No password risks found",
"txt_no_password_risks_in_filter": "No password risks in this category",
"txt_open_vault": "Open vault",
"txt_check_password_breach": "Check breach",
"txt_password_not_found_in_breaches": "Not found in the breach database",
"txt_password_security_check_failed": "The breach check could not be completed."
});
export default en; export default en;
+15
View File
@@ -7,10 +7,13 @@ const es: Record<string, string> = {
"nav_my_vault": "Mi bóveda", "nav_my_vault": "Mi bóveda",
"nav_vault_items": "Bóveda", "nav_vault_items": "Bóveda",
"nav_sends": "Envíos", "nav_sends": "Envíos",
"nav_generator": "Generador", "txt_password_generator": "Generador de contraseñas", "txt_password_generator_description": "Crea una contraseña única y segura localmente en este dispositivo.", "txt_generator_type": "Tipo de generador", "txt_passphrase": "Frase de contraseña", "txt_generated_password": "Contraseña generada", "txt_password_strength": "Seguridad", "txt_password_strength_weak": "Débil", "txt_password_strength_fair": "Regular", "txt_password_strength_good": "Buena", "txt_password_strength_strong": "Fuerte", "txt_generator_security_note": "La generación se realiza localmente. Tu contraseña nunca se envía al servidor.", "txt_generator_length": "Longitud", "txt_generator_character_types": "Tipos de caracteres", "txt_generator_uppercase": "Mayúsculas (A-Z)", "txt_generator_lowercase": "Minúsculas (a-z)", "txt_generator_numbers": "Números (0-9)", "txt_generator_special": "Caracteres especiales (!@#$%^&*)", "txt_generator_minimum": "Mínimo", "txt_generator_avoid_ambiguous": "Evitar caracteres ambiguos", "txt_generator_words": "Número de palabras", "txt_generator_separator": "Separador de palabras", "txt_generator_capitalize": "Usar mayúsculas", "txt_generator_include_number": "Incluir un número",
"nav_backup_strategy": "Copia de seguridad en la nube", "nav_backup_strategy": "Copia de seguridad en la nube",
"nav_import_export": "Importar y exportar", "nav_import_export": "Importar y exportar",
"nav_group_data_backup": "Datos y copias", "nav_group_data_backup": "Datos y copias",
"nav_group_management": "Gestión", "nav_group_management": "Gestión",
"nav_group_tools": "Herramientas",
"nav_group_system_management": "Administración del sistema",
"txt_settings_appearance": "Apariencia", "txt_settings_appearance": "Apariencia",
"txt_theme": "Tema", "txt_theme": "Tema",
"txt_use_system_theme": "Usar tema del sistema", "txt_use_system_theme": "Usar tema del sistema",
@@ -978,6 +981,7 @@ const es: Record<string, string> = {
"txt_save_profile_failed": "Error al guardar perfil", "txt_save_profile_failed": "Error al guardar perfil",
"txt_search_sends": "Buscar envíos...", "txt_search_sends": "Buscar envíos...",
"txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.", "txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.",
"txt_session_refresh_temporarily_unavailable": "La sesión no se puede verificar temporalmente. Tu inicio de sesión se conserva y se volverá a intentar.",
"txt_search_your_secure_vault": "Buscar en su bóveda segura...", "txt_search_your_secure_vault": "Buscar en su bóveda segura...",
"txt_search_items_count": "Buscar entre {count} elementos...", "txt_search_items_count": "Buscar entre {count} elementos...",
"txt_clear_search": "Limpiar búsqueda", "txt_clear_search": "Limpiar búsqueda",
@@ -1012,6 +1016,9 @@ const es: Record<string, string> = {
"txt_online": "En línea", "txt_online": "En línea",
"txt_offline": "Sin conexión", "txt_offline": "Sin conexión",
"txt_offline_vault_readonly": "El modo sin conexión es de solo lectura. Conecta con NodeWarden antes de cambiar la bóveda.", "txt_offline_vault_readonly": "El modo sin conexión es de solo lectura. Conecta con NodeWarden antes de cambiar la bóveda.",
"txt_offline_mode_notice_title": "Estás en modo sin conexión. Si parece un error, fuerza la recarga.",
"txt_offline_mode_notice_windows": "Windows:",
"txt_offline_mode_notice_macos": "macOS:",
"txt_submit": "Enviar", "txt_submit": "Enviar",
"txt_sync": "Sincronizar", "txt_sync": "Sincronizar",
"txt_sync_vault": "Sincronizar bóveda", "txt_sync_vault": "Sincronizar bóveda",
@@ -1444,4 +1451,12 @@ const es: Record<string, string> = {
"txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública" "txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública"
}; };
Object.assign(es, {
"nav_password_security": "Seguridad de contraseñas", "txt_password_security": "Comprobación de seguridad", "txt_password_security_privacy": "Las contraseñas se comprueban localmente. Solo se envía un prefijo de hash anónimo a la base de filtraciones al iniciar la comprobación.", "txt_check_password_security": "Iniciar comprobación", "txt_checking_password_security": "Comprobando", "txt_recheck_password_security": "Comprobar de nuevo", "txt_password_security_ready": "Tu bóveda está lista para una comprobación de seguridad.", "txt_password_security_no_login": "No hay contraseñas de inicio de sesión para comprobar.", "txt_password_security_manual": "La comprobación solo empieza cuando la eliges. Los resultados se conservan solo en esta página.", "txt_password_security_no_login_help": "Añade un inicio de sesión con contraseña y vuelve aquí para comprobarlo.", "txt_exposed_passwords": "Filtradas", "txt_reused_passwords": "Reutilizadas", "txt_weak_passwords": "Débiles", "txt_passwords_checked": "Comprobadas", "txt_password_security_unavailable": "{count} comprobaciones no pudieron acceder a la base de filtraciones. No se marcan como seguras.", "txt_password_security_not_checked": "Sin comprobar", "txt_password_exposed_count": "Encontrada en {count} filtraciones", "txt_password_reused_count": "Usada {count} veces", "txt_weak_password": "Contraseña débil", "txt_no_password_risks": "No se encontraron riesgos de contraseña", "txt_open_vault": "Abrir bóveda", "txt_check_password_breach": "Comprobar filtración", "txt_password_not_found_in_breaches": "No encontrada en la base de filtraciones", "txt_password_security_check_failed": "No se pudo completar la comprobación de filtraciones."
});
Object.assign(es, { "txt_password_security_last_checked": "Última comprobación: {value}" });
Object.assign(es, { "txt_no_password_risks_in_filter": "No hay riesgos de contraseña en esta categoría" });
Object.assign(es, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default es; export default es;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15
View File
@@ -8,10 +8,13 @@ const ru: Record<string, string> = {
"nav_my_vault": "Мое хранилище", "nav_my_vault": "Мое хранилище",
"nav_vault_items": "Хранилище", "nav_vault_items": "Хранилище",
"nav_sends": "Отправляет", "nav_sends": "Отправляет",
"nav_generator": "Генератор", "txt_password_generator": "Генератор паролей", "txt_password_generator_description": "Создайте надежный уникальный пароль локально на этом устройстве.", "txt_generator_type": "Тип генератора", "txt_passphrase": "Парольная фраза", "txt_generated_password": "Созданный пароль", "txt_password_strength": "Надежность", "txt_password_strength_weak": "Слабый", "txt_password_strength_fair": "Средний", "txt_password_strength_good": "Хороший", "txt_password_strength_strong": "Надежный", "txt_generator_security_note": "Генерация выполняется локально. Пароль никогда не отправляется на сервер.", "txt_generator_length": "Длина", "txt_generator_character_types": "Типы символов", "txt_generator_uppercase": "Заглавные буквы (A-Z)", "txt_generator_lowercase": "Строчные буквы (a-z)", "txt_generator_numbers": "Цифры (0-9)", "txt_generator_special": "Специальные символы (!@#$%^&*)", "txt_generator_minimum": "Минимум", "txt_generator_avoid_ambiguous": "Исключить похожие символы", "txt_generator_words": "Количество слов", "txt_generator_separator": "Разделитель слов", "txt_generator_capitalize": "С заглавной буквы", "txt_generator_include_number": "Добавить число",
"nav_backup_strategy": "Облачное резервное копирование", "nav_backup_strategy": "Облачное резервное копирование",
"nav_import_export": "Импорт и экспорт", "nav_import_export": "Импорт и экспорт",
"nav_group_data_backup": "Данные и резервные копии", "nav_group_data_backup": "Данные и резервные копии",
"nav_group_management": "Управление", "nav_group_management": "Управление",
"nav_group_tools": "Инструменты",
"nav_group_system_management": "Управление системой",
"txt_settings_appearance": "Внешний вид", "txt_settings_appearance": "Внешний вид",
"txt_theme": "Тема", "txt_theme": "Тема",
"txt_use_system_theme": "Использовать системную тему", "txt_use_system_theme": "Использовать системную тему",
@@ -978,6 +981,7 @@ const ru: Record<string, string> = {
"txt_save_profile_failed": "Сохранить профиль не удалось", "txt_save_profile_failed": "Сохранить профиль не удалось",
"txt_search_sends": "Поиск отправляет...", "txt_search_sends": "Поиск отправляет...",
"txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.", "txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.",
"txt_session_refresh_temporarily_unavailable": "Сеанс временно не удаётся проверить. Вход сохранён, проверка будет повторена.",
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...", "txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
"txt_search_items_count": "Поиск по {count} элементам...", "txt_search_items_count": "Поиск по {count} элементам...",
"txt_clear_search": "Очистить поиск", "txt_clear_search": "Очистить поиск",
@@ -1012,6 +1016,9 @@ const ru: Record<string, string> = {
"txt_online": "Онлайн", "txt_online": "Онлайн",
"txt_offline": "Офлайн", "txt_offline": "Офлайн",
"txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.", "txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.",
"txt_offline_mode_notice_title": "Вы в автономном режиме. Если это ошибка, выполните принудительное обновление.",
"txt_offline_mode_notice_windows": "Windows:",
"txt_offline_mode_notice_macos": "macOS:",
"txt_submit": "Отправить", "txt_submit": "Отправить",
"txt_sync": "Синхронизировать", "txt_sync": "Синхронизировать",
"txt_sync_vault": "Синхронизировать хранилище", "txt_sync_vault": "Синхронизировать хранилище",
@@ -1444,4 +1451,12 @@ const ru: Record<string, string> = {
"txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ" "txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ"
}; };
Object.assign(ru, {
"nav_password_security": "Безопасность паролей", "txt_password_security": "Проверка безопасности паролей", "txt_password_security_privacy": "Пароли проверяются локально. После запуска в базу утечек передаётся только анонимный префикс хеша.", "txt_check_password_security": "Начать проверку", "txt_checking_password_security": "Проверка", "txt_recheck_password_security": "Проверить снова", "txt_password_security_ready": "Ваше хранилище готово к проверке безопасности.", "txt_password_security_no_login": "Нет паролей для входа, доступных для проверки.", "txt_password_security_manual": "Проверка запускается только по вашему выбору. Результаты остаются только на этой странице.", "txt_password_security_no_login_help": "Добавьте запись входа с паролем и вернитесь сюда для проверки.", "txt_exposed_passwords": "Скомпрометированы", "txt_reused_passwords": "Повторно используются", "txt_weak_passwords": "Слабые", "txt_passwords_checked": "Проверено", "txt_password_security_unavailable": "{count} проверок не смогли обратиться к базе утечек. Они не помечены безопасными.", "txt_password_security_not_checked": "Не проверено", "txt_password_exposed_count": "Найдено в {count} утечках", "txt_password_reused_count": "Используется {count} раз", "txt_weak_password": "Слабый пароль", "txt_no_password_risks": "Рисков паролей не найдено", "txt_open_vault": "Открыть хранилище", "txt_check_password_breach": "Проверить утечку", "txt_password_not_found_in_breaches": "Не найден в базе утечек", "txt_password_security_check_failed": "Не удалось завершить проверку утечки."
});
Object.assign(ru, { "txt_password_security_last_checked": "Последняя проверка: {value}" });
Object.assign(ru, { "txt_no_password_risks_in_filter": "В этой категории нет рисков для паролей" });
Object.assign(ru, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default ru; export default ru;
File diff suppressed because it is too large Load Diff
+45
View File
@@ -7,10 +7,16 @@ const zhCN: Record<string, string> = {
"nav_my_vault": "我的密码库", "nav_my_vault": "我的密码库",
"nav_vault_items": "密码库", "nav_vault_items": "密码库",
"nav_sends": "Send", "nav_sends": "Send",
"nav_generator": "密码生成器",
"txt_password_generator": "密码生成器",
"txt_password_generator_description": "在此设备本地生成强且唯一的密码。",
"txt_generator_type": "生成类型", "txt_passphrase": "密码短语", "txt_generated_password": "已生成密码", "txt_password_strength": "强度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "一般", "txt_password_strength_good": "良好", "txt_password_strength_strong": "强", "txt_generator_security_note": "生成过程仅在本地进行,密码不会发送到服务器。", "txt_generator_length": "长度", "txt_generator_character_types": "字符类型", "txt_generator_uppercase": "大写字母 (A-Z)", "txt_generator_lowercase": "小写字母 (a-z)", "txt_generator_numbers": "数字 (0-9)", "txt_generator_special": "特殊字符 (!@#$%^&*)", "txt_generator_minimum": "最少数量", "txt_generator_avoid_ambiguous": "避免易混淆字符", "txt_generator_words": "单词数量", "txt_generator_separator": "单词分隔符", "txt_generator_capitalize": "首字母大写", "txt_generator_include_number": "包含数字",
"nav_backup_strategy": "云端备份", "nav_backup_strategy": "云端备份",
"nav_import_export": "导入导出", "nav_import_export": "导入导出",
"nav_group_data_backup": "数据与备份", "nav_group_data_backup": "数据与备份",
"nav_group_management": "管理", "nav_group_management": "管理",
"nav_group_tools": "工具",
"nav_group_system_management": "系统管理",
"txt_settings_appearance": "外观", "txt_settings_appearance": "外观",
"txt_theme": "主题", "txt_theme": "主题",
"txt_use_system_theme": "使用系统主题", "txt_use_system_theme": "使用系统主题",
@@ -978,6 +984,7 @@ const zhCN: Record<string, string> = {
"txt_save_profile_failed": "保存资料失败", "txt_save_profile_failed": "保存资料失败",
"txt_search_sends": "搜索 Send...", "txt_search_sends": "搜索 Send...",
"txt_session_refresh_failed": "会话刷新失败,请重新登录", "txt_session_refresh_failed": "会话刷新失败,请重新登录",
"txt_session_refresh_temporarily_unavailable": "暂时无法验证会话,登录状态已保留,稍后会自动重试",
"txt_search_your_secure_vault": "搜索你的密码库...", "txt_search_your_secure_vault": "搜索你的密码库...",
"txt_search_items_count": "共 {count} 项中搜索...", "txt_search_items_count": "共 {count} 项中搜索...",
"txt_clear_search": "清空搜索", "txt_clear_search": "清空搜索",
@@ -1012,6 +1019,9 @@ const zhCN: Record<string, string> = {
"txt_online": "在线", "txt_online": "在线",
"txt_offline": "离线", "txt_offline": "离线",
"txt_offline_vault_readonly": "当前为离线模式,只能查看密码库。连接到 NodeWarden 后才能修改。", "txt_offline_vault_readonly": "当前为离线模式,只能查看密码库。连接到 NodeWarden 后才能修改。",
"txt_offline_mode_notice_title": "你正处于离线模式,若误判请强制刷新",
"txt_offline_mode_notice_windows": "Windows",
"txt_offline_mode_notice_macos": "macOS",
"txt_submit": "提交", "txt_submit": "提交",
"txt_sync": "同步", "txt_sync": "同步",
"txt_sync_vault": "同步", "txt_sync_vault": "同步",
@@ -1444,4 +1454,39 @@ const zhCN: Record<string, string> = {
"txt_ip_address": "IP 地址" "txt_ip_address": "IP 地址"
}; };
Object.assign(zhCN, {
"nav_password_security": "安全检测",
"txt_password_security": "安全检测",
"txt_password_security_privacy": "密码仅在本地前端检查;只有匿名哈希前缀会发送到泄露密码库。",
"txt_check_password_security": "开始检查",
"txt_checking_password_security": "检查中",
"txt_recheck_password_security": "重新检查",
"txt_password_security_ready": "密码库已准备好进行安全检查。",
"txt_password_security_no_login": "没有可检查的登录密码。",
"txt_password_security_manual": "仅在您主动开始后才会联网检查;结果会保留到刷新页面或密码库内容变更前。",
"txt_password_security_no_login_help": "添加一个包含密码的登录项目后,再回到此处检查。",
"txt_exposed_passwords": "已泄露",
"txt_reused_passwords": "重复使用",
"txt_weak_passwords": "较弱",
"txt_passwords_checked": "已检查",
"txt_password_security_last_checked": "上次检测:{value}",
"txt_password_security_show_all": "显示全部",
"txt_password_security_hide_all": "隐藏全部",
"txt_password_security_jump": "跳转",
"txt_password_security_exposed_short": "泄露 {count} 次",
"txt_password_security_weak_short": "弱密码",
"txt_password_security_reused_short": "重复",
"txt_password_security_unavailable": "有 {count} 个密码无法连接泄露库,未被标记为安全。",
"txt_password_security_not_checked": "未检查",
"txt_password_exposed_count": "已在 {count} 次泄露中出现",
"txt_password_reused_count": "使用了 {count} 次",
"txt_weak_password": "较弱密码",
"txt_no_password_risks": "未发现密码风险",
"txt_no_password_risks_in_filter": "此类别中没有密码风险",
"txt_open_vault": "打开密码库",
"txt_check_password_breach": "检查泄露",
"txt_password_not_found_in_breaches": "未在泄露密码库中发现",
"txt_password_security_check_failed": "无法完成泄露检查。"
});
export default zhCN; export default zhCN;
+45
View File
@@ -7,10 +7,16 @@ const zhTW: Record<string, string> = {
"nav_my_vault": "我的密碼庫", "nav_my_vault": "我的密碼庫",
"nav_vault_items": "密碼庫", "nav_vault_items": "密碼庫",
"nav_sends": "Send", "nav_sends": "Send",
"nav_generator": "密碼產生器",
"txt_password_generator": "密碼產生器",
"txt_password_generator_description": "在此裝置本機建立強而唯一的密碼。",
"txt_generator_type": "產生類型", "txt_passphrase": "密碼片語", "txt_generated_password": "已產生密碼", "txt_password_strength": "強度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "普通", "txt_password_strength_good": "良好", "txt_password_strength_strong": "強", "txt_generator_security_note": "產生程序僅在本機進行,密碼不會傳送到伺服器。", "txt_generator_length": "長度", "txt_generator_character_types": "字元類型", "txt_generator_uppercase": "大寫字母 (A-Z)", "txt_generator_lowercase": "小寫字母 (a-z)", "txt_generator_numbers": "數字 (0-9)", "txt_generator_special": "特殊字元 (!@#$%^&*)", "txt_generator_minimum": "最少數量", "txt_generator_avoid_ambiguous": "避免易混淆字元", "txt_generator_words": "單字數量", "txt_generator_separator": "單字分隔符號", "txt_generator_capitalize": "首字母大寫", "txt_generator_include_number": "包含數字",
"nav_backup_strategy": "雲端備份", "nav_backup_strategy": "雲端備份",
"nav_import_export": "導入導出", "nav_import_export": "導入導出",
"nav_group_data_backup": "資料與備份", "nav_group_data_backup": "資料與備份",
"nav_group_management": "管理", "nav_group_management": "管理",
"nav_group_tools": "工具",
"nav_group_system_management": "系統管理",
"txt_settings_appearance": "外觀", "txt_settings_appearance": "外觀",
"txt_theme": "主題", "txt_theme": "主題",
"txt_use_system_theme": "使用系統主題", "txt_use_system_theme": "使用系統主題",
@@ -978,6 +984,7 @@ const zhTW: Record<string, string> = {
"txt_save_profile_failed": "保存資料失敗", "txt_save_profile_failed": "保存資料失敗",
"txt_search_sends": "搜索 Send...", "txt_search_sends": "搜索 Send...",
"txt_session_refresh_failed": "會話刷新失敗,請重新登入", "txt_session_refresh_failed": "會話刷新失敗,請重新登入",
"txt_session_refresh_temporarily_unavailable": "暫時無法驗證會話,登入狀態已保留,稍後會自動重試",
"txt_search_your_secure_vault": "搜索你的密碼庫...", "txt_search_your_secure_vault": "搜索你的密碼庫...",
"txt_search_items_count": "在共 {count} 項中搜索...", "txt_search_items_count": "在共 {count} 項中搜索...",
"txt_clear_search": "清空搜索", "txt_clear_search": "清空搜索",
@@ -1012,6 +1019,9 @@ const zhTW: Record<string, string> = {
"txt_online": "在線", "txt_online": "在線",
"txt_offline": "離線", "txt_offline": "離線",
"txt_offline_vault_readonly": "目前為離線模式,只能查看密碼庫。連線到 NodeWarden 後才能修改。", "txt_offline_vault_readonly": "目前為離線模式,只能查看密碼庫。連線到 NodeWarden 後才能修改。",
"txt_offline_mode_notice_title": "你正處於離線模式,若誤判請強制重新整理",
"txt_offline_mode_notice_windows": "Windows",
"txt_offline_mode_notice_macos": "macOS",
"txt_submit": "提交", "txt_submit": "提交",
"txt_sync": "同步", "txt_sync": "同步",
"txt_sync_vault": "同步", "txt_sync_vault": "同步",
@@ -1444,4 +1454,39 @@ const zhTW: Record<string, string> = {
"txt_auth_request_missing_public_key": "裝置登入請求缺少公鑰" "txt_auth_request_missing_public_key": "裝置登入請求缺少公鑰"
}; };
Object.assign(zhTW, {
"nav_password_security": "密碼安全",
"txt_password_security": "密碼安全檢查",
"txt_password_security_privacy": "密碼僅在本機前端檢查;只有匿名雜湊前綴會傳送到外洩密碼庫。",
"txt_check_password_security": "開始檢查",
"txt_checking_password_security": "檢查中",
"txt_recheck_password_security": "重新檢查",
"txt_password_security_ready": "密碼庫已準備好進行安全檢查。",
"txt_password_security_no_login": "沒有可檢查的登入密碼。",
"txt_password_security_manual": "僅在您主動開始後才會連線檢查;結果會保留到重新整理頁面或密碼庫內容變更前。",
"txt_password_security_no_login_help": "新增一個含有密碼的登入項目後,再回到此處檢查。",
"txt_exposed_passwords": "已外洩",
"txt_reused_passwords": "重複使用",
"txt_weak_passwords": "較弱",
"txt_passwords_checked": "已檢查",
"txt_password_security_last_checked": "上次檢查:{value}",
"txt_password_security_show_all": "顯示全部",
"txt_password_security_hide_all": "隱藏全部",
"txt_password_security_jump": "跳轉",
"txt_password_security_exposed_short": "外洩 {count} 次",
"txt_password_security_weak_short": "弱密碼",
"txt_password_security_reused_short": "重複",
"txt_password_security_unavailable": "有 {count} 個密碼無法連線至外洩資料庫,未被標記為安全。",
"txt_password_security_not_checked": "未檢查",
"txt_password_exposed_count": "已在 {count} 次外洩中出現",
"txt_password_reused_count": "使用了 {count} 次",
"txt_weak_password": "較弱密碼",
"txt_no_password_risks": "未發現密碼風險",
"txt_no_password_risks_in_filter": "此類別中沒有密碼風險",
"txt_open_vault": "開啟密碼庫",
"txt_check_password_breach": "檢查外洩",
"txt_password_not_found_in_breaches": "未在外洩密碼庫中發現",
"txt_password_security_check_failed": "無法完成外洩檢查。"
});
export default zhTW; export default zhTW;
+74
View File
@@ -0,0 +1,74 @@
import { inspectVaultPasswordSecurity, type PasswordSecurityReport } from '@/lib/password-security';
import type { Cipher } from '@/lib/types';
export interface PasswordSecurityState {
fingerprint: string;
report: PasswordSecurityReport | null;
scannedAt: number | null;
scanning: boolean;
progress: { checked: number; total: number };
scanError: boolean;
}
type InternalPasswordSecurityState = PasswordSecurityState & { controller: AbortController | null };
let state: InternalPasswordSecurityState | null = null;
const listeners = new Set<() => void>();
function notify(): void {
listeners.forEach((listener) => listener());
}
function createState(fingerprint: string): InternalPasswordSecurityState {
return { fingerprint, report: null, scannedAt: null, scanning: false, progress: { checked: 0, total: 0 }, scanError: false, controller: null };
}
export function getPasswordSecurityState(fingerprint: string): PasswordSecurityState {
if (state?.fingerprint !== fingerprint) {
state?.controller?.abort();
state = createState(fingerprint);
}
return state;
}
export function readPasswordSecurityState(fingerprint: string): PasswordSecurityState | null {
return state?.fingerprint === fingerprint ? state : null;
}
export function subscribePasswordSecurityState(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function startPasswordSecurityScan(fingerprint: string, ciphers: Cipher[]): void {
const current = getPasswordSecurityState(fingerprint);
current.controller?.abort();
const controller = new AbortController();
const total = ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length;
state = { ...current, report: null, scannedAt: null, scanning: true, progress: { checked: 0, total }, scanError: false, controller };
notify();
void (async () => {
try {
const report = await inspectVaultPasswordSecurity(ciphers, (checked, total) => {
if (controller.signal.aborted || state?.controller !== controller) return;
state = { ...state, progress: { checked, total } };
notify();
}, fetch, controller.signal);
if (controller.signal.aborted || state?.controller !== controller) return;
state = { ...state, report, scannedAt: Date.now() };
} catch (error) {
if (controller.signal.aborted || (error as { name?: string } | null)?.name === 'AbortError') return;
if (state?.controller === controller) state = { ...state, scanError: true };
} finally {
if (state?.controller === controller) state = { ...state, controller: null, scanning: false };
notify();
}
})();
}
export function clearPasswordSecurityCache(): void {
state?.controller?.abort();
state = null;
notify();
}
+229
View File
@@ -0,0 +1,229 @@
import type { Cipher } from '@/lib/types';
const PWNED_PASSWORDS_RANGE_URL = 'https://api.pwnedpasswords.com/range/';
const MAX_CONCURRENT_BREACH_CHECKS = 5;
const COMMON_PASSWORDS = new Set([
'password', 'password1', '123456', '12345678', '123456789', 'qwerty', 'abc123', 'letmein', 'welcome', 'iloveyou', 'admin', 'changeme',
]);
export interface PasswordBreachResult {
count: number | null;
available: boolean;
}
export interface PasswordSecurityItem {
cipherId: string;
exposedCount: number | null;
reusedCount: number;
weak: boolean;
}
export interface PasswordSecurityReport {
eligibleCount: number;
checkedCount: number;
exposedCount: number;
reusedCount: number;
weakCount: number;
unavailableCount: number;
items: PasswordSecurityItem[];
}
type Candidate = {
cipherId: string;
name: string;
hash: string;
weak: boolean;
};
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('').toUpperCase();
}
function isAbortError(error: unknown): boolean {
return !!error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError';
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
const error = new Error('The operation was aborted.');
error.name = 'AbortError';
throw error;
}
}
export async function sha1Password(password: string): Promise<string> {
const input = new TextEncoder().encode(password);
return bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-1', input)));
}
function parseRangeResponse(text: string, suffix: string): number {
for (const line of text.split(/\r?\n/)) {
const separator = line.indexOf(':');
if (separator !== 35) continue;
if (line.slice(0, separator).toUpperCase() !== suffix) continue;
const count = Number.parseInt(line.slice(separator + 1), 10);
return Number.isSafeInteger(count) && count > 0 ? count : 0;
}
return 0;
}
export async function checkPasswordHashLeaked(
hash: string,
fetchImpl: typeof fetch = fetch,
signal?: AbortSignal,
): Promise<number> {
if (!/^[A-F0-9]{40}$/.test(hash)) throw new Error('Password hash is invalid.');
throwIfAborted(signal);
const controller = new AbortController();
const timeout = globalThis.setTimeout(() => controller.abort(), 12_000);
const onExternalAbort = () => controller.abort();
signal?.addEventListener('abort', onExternalAbort, { once: true });
if (signal?.aborted) controller.abort();
try {
const response = await fetchImpl(`${PWNED_PASSWORDS_RANGE_URL}${hash.slice(0, 5)}`, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
cache: 'no-store',
referrerPolicy: 'no-referrer',
headers: { 'Add-Padding': 'true' },
signal: controller.signal,
});
if (!response.ok) throw new Error(`Pwned Passwords returned ${response.status}.`);
return parseRangeResponse(await response.text(), hash.slice(5));
} catch (error) {
// External cancel (leave page / re-scan) must stay distinguishable from timeout/network failures.
if (signal?.aborted) {
const abortError = new Error('The operation was aborted.');
abortError.name = 'AbortError';
throw abortError;
}
if (isAbortError(error)) throw new Error('Pwned Passwords request timed out.');
throw error;
} finally {
globalThis.clearTimeout(timeout);
signal?.removeEventListener('abort', onExternalAbort);
}
}
export async function checkPasswordLeaked(
password: string,
fetchImpl: typeof fetch = fetch,
signal?: AbortSignal,
): Promise<PasswordBreachResult> {
if (!password) return { count: 0, available: true };
try {
return { count: await checkPasswordHashLeaked(await sha1Password(password), fetchImpl, signal), available: true };
} catch (error) {
if (isAbortError(error) || signal?.aborted) throw error;
return { count: null, available: false };
}
}
function hasSimpleSequence(value: string): boolean {
const normalized = value.toLowerCase();
return ['0123456789', '9876543210', 'abcdefghijklmnopqrstuvwxyz', 'zyxwvutsrqponmlkjihgfedcba', 'qwertyuiop', 'poiuytrewq']
.some((sequence) => sequence.includes(normalized) || normalized.includes(sequence.slice(0, 5)));
}
export function isWeakPassword(password: string, username: string = ''): boolean {
const normalized = password.toLowerCase();
const compactUsername = username.split('@')[0]?.trim().toLowerCase() || '';
if (COMMON_PASSWORDS.has(normalized) || password.length < 10) return true;
if (/^(.)\1+$/.test(password) || hasSimpleSequence(password)) return true;
if (compactUsername.length >= 3 && normalized.includes(compactUsername)) return true;
const classes = [/[a-z]/.test(password), /[A-Z]/.test(password), /\d/.test(password), /[^A-Za-z0-9]/.test(password)].filter(Boolean).length;
return password.length < 14 && classes < 3;
}
function isEligibleCipher(cipher: Cipher): boolean {
return Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword;
}
async function mapWithConcurrency<T, R>(
values: T[],
limit: number,
worker: (value: T) => Promise<R>,
signal?: AbortSignal,
): Promise<R[]> {
const results = new Array<R>(values.length);
let nextIndex = 0;
const run = async () => {
while (true) {
throwIfAborted(signal);
const index = nextIndex;
nextIndex += 1;
if (index >= values.length) return;
results[index] = await worker(values[index]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, run));
return results;
}
export async function inspectVaultPasswordSecurity(
ciphers: Cipher[],
onProgress?: (checked: number, total: number) => void,
fetchImpl: typeof fetch = fetch,
signal?: AbortSignal,
): Promise<PasswordSecurityReport> {
throwIfAborted(signal);
const eligible = ciphers.filter(isEligibleCipher);
const candidates: Candidate[] = await Promise.all(eligible.map(async (cipher) => {
throwIfAborted(signal);
const password = String(cipher.login?.decPassword || '');
const username = String(cipher.login?.decUsername || '');
return {
cipherId: cipher.id,
name: String(cipher.decName || cipher.name || ''),
hash: await sha1Password(password),
weak: isWeakPassword(password, username),
};
}));
const candidatesByHash = new Map<string, Candidate[]>();
for (const candidate of candidates) {
const group = candidatesByHash.get(candidate.hash) || [];
group.push(candidate);
candidatesByHash.set(candidate.hash, group);
}
const exposureByHash = new Map<string, PasswordBreachResult>();
let checked = 0;
await mapWithConcurrency([...candidatesByHash.keys()], MAX_CONCURRENT_BREACH_CHECKS, async (hash) => {
throwIfAborted(signal);
let result: PasswordBreachResult;
try {
result = { count: await checkPasswordHashLeaked(hash, fetchImpl, signal), available: true };
} catch (error) {
if (isAbortError(error) || signal?.aborted) throw error;
result = { count: null, available: false };
}
exposureByHash.set(hash, result);
checked += candidatesByHash.get(hash)?.length || 0;
onProgress?.(Math.min(checked, candidates.length), candidates.length);
return result;
}, signal);
throwIfAborted(signal);
const items = candidates.map((candidate) => {
const exposure = exposureByHash.get(candidate.hash) || { count: null, available: false };
return {
cipherId: candidate.cipherId,
exposedCount: exposure.count,
reusedCount: candidatesByHash.get(candidate.hash)?.length || 1,
weak: candidate.weak,
};
}).filter((item) => item.exposedCount === null || (item.exposedCount || 0) > 0 || item.reusedCount > 1 || item.weak)
.sort((a, b) => (Number(b.exposedCount || 0) - Number(a.exposedCount || 0)) || (b.reusedCount - a.reusedCount) || Number(b.weak) - Number(a.weak) || a.cipherId.localeCompare(b.cipherId));
return {
eligibleCount: candidates.length,
checkedCount: checked,
exposedCount: candidates.filter((candidate) => (exposureByHash.get(candidate.hash)?.count || 0) > 0).length,
reusedCount: candidates.filter((candidate) => (candidatesByHash.get(candidate.hash)?.length || 0) > 1).length,
weakCount: candidates.filter((candidate) => candidate.weak).length,
unavailableCount: candidates.filter((candidate) => exposureByHash.get(candidate.hash)?.count === null).length,
items,
};
}
+5
View File
@@ -280,6 +280,8 @@ export interface Send {
key?: string | null; key?: string | null;
maxAccessCount?: number | null; maxAccessCount?: number | null;
accessCount?: number; accessCount?: number;
password?: string | null;
authType?: number | null;
disabled?: boolean; disabled?: boolean;
revisionDate?: string; revisionDate?: string;
expirationDate?: string | null; expirationDate?: string | null;
@@ -308,6 +310,7 @@ export interface SendDraft {
expirationDays: string; expirationDays: string;
maxAccessCount: string; maxAccessCount: string;
password: string; password: string;
hasPassword?: boolean;
disabled: boolean; disabled: boolean;
} }
@@ -411,6 +414,8 @@ export interface WebBootstrapResponse {
jwtUnsafeReason?: 'missing' | 'too_short' | null; jwtUnsafeReason?: 'missing' | 'too_short' | null;
jwtSecretMinLength?: number; jwtSecretMinLength?: number;
registrationInviteRequired?: boolean; registrationInviteRequired?: boolean;
webAuthnAllowedOrigins?: string[];
websiteIconsEnabled?: boolean;
} }
export interface YubiKeyOtpSettings { export interface YubiKeyOtpSettings {
+9
View File
@@ -0,0 +1,9 @@
let websiteIconsEnabled = true;
export function setWebsiteIconsEnabled(enabled: boolean): void {
websiteIconsEnabled = enabled;
}
export function areWebsiteIconsEnabled(): boolean {
return websiteIconsEnabled;
}
+4 -2
View File
@@ -2,6 +2,8 @@
@import './styles/base.css'; @import './styles/base.css';
@import './styles/auth.css'; @import './styles/auth.css';
@import './styles/forms.css'; @import './styles/forms.css';
@import './styles/generator.css';
@import './styles/password-security.css';
@import './styles/shell.css'; @import './styles/shell.css';
@import './styles/vault.css'; @import './styles/vault.css';
@import './styles/management.css'; @import './styles/management.css';
@@ -428,7 +430,7 @@ h4 {
min-height: min(640px, calc(100dvh - 180px)); min-height: min(640px, calc(100dvh - 180px));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 10px;
} }
.settings-home-section { .settings-home-section {
@@ -675,7 +677,7 @@ h4 {
} }
.card { .card {
margin-bottom: 8px; margin-bottom: 10;
padding: 14px; padding: 14px;
} }
+52
View File
@@ -492,6 +492,58 @@
transform: translateY(-50%); transform: translateY(-50%);
} }
.offline-mode-notice {
@apply mb-4 flex items-start gap-3 rounded-xl border px-3.5 py-3 text-left text-sm leading-relaxed;
background: color-mix(in srgb, var(--warning) 10%, var(--panel));
border-color: color-mix(in srgb, var(--warning) 42%, var(--line));
color: var(--text);
}
.offline-mode-notice svg {
@apply mt-0.5 shrink-0;
color: var(--warning);
}
.offline-mode-notice strong {
@apply block text-center text-[13px] font-extrabold;
color: color-mix(in srgb, var(--warning) 82%, var(--text));
}
.offline-mode-notice > div {
@apply min-w-0 flex-1;
}
.offline-shortcut-list {
@apply mt-2 grid gap-1.5;
grid-template-columns: max-content minmax(0, 1fr);
}
.offline-shortcut-row {
display: contents;
}
.offline-shortcut-label {
@apply text-xs font-extrabold;
color: color-mix(in srgb, var(--warning) 76%, var(--text));
}
.offline-shortcut-value {
@apply flex min-w-0 flex-wrap items-center gap-1.5;
color: var(--muted-strong);
}
.offline-shortcut-chord {
@apply inline-flex items-center gap-1 whitespace-nowrap;
}
.offline-shortcut-chord kbd {
@apply inline-flex min-h-6 items-center rounded-md border px-1.5 font-mono text-[12px] font-bold leading-none;
background: color-mix(in srgb, var(--panel) 88%, var(--warning));
border-color: color-mix(in srgb, var(--warning) 35%, var(--line));
box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--warning) 26%, transparent);
color: var(--text);
}
.standalone-muted { .standalone-muted {
@apply text-left; @apply text-left;
} }
+25
View File
@@ -0,0 +1,25 @@
.generator-page {
width: min(100%, 1180px);
margin: 0;
padding: 4px 0 28px;
}
.generator-layout { display: grid; grid-template-columns: minmax(300px, .82fr) minmax(0, 1.18fr); grid-template-areas: 'options output'; gap: 16px; align-items: start; }
.generator-output-card, .generator-options-card { border: 1px solid var(--line); border-radius: 20px; background: var(--panel); box-shadow: var(--shadow-sm); }
.generator-output-card { grid-area: output; padding: 20px; }
.generator-options-card { grid-area: options; padding: 19px; }
.generator-options-card h2 { margin: 0 0 18px; font-size: 17px; }
.generator-value { display: block; min-height: 110px; margin: 18px 0 10px; padding: 18px; border: 1px solid color-mix(in srgb, var(--primary) 22%, var(--line)); border-radius: 16px; background: color-mix(in srgb, var(--primary) 5%, var(--panel)); color: var(--text); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: clamp(19px, 2.2vw, 27px); font-weight: 700; line-height: 1.45; overflow-wrap: anywhere; user-select: all; }
.generator-strength-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted-strong); font-size: 13px; font-weight: 700; }
.generator-strength-row > span, .generator-security-note { display: inline-flex; align-items: center; gap: 6px; }
.generator-strength { display: flex; flex: 1; gap: 4px; }
.generator-strength span { height: 5px; flex: 1; border-radius: 999px; background: var(--line); }
.generator-strength span.active.level-1 { background: #e87171; }.generator-strength span.active.level-2 { background: #db9b38; }.generator-strength span.active.level-3 { background: #46936c; }.generator-strength span.active.level-4 { background: var(--primary); }
.generator-actions { margin-top: 22px; }.generator-actions .btn { flex: 1; }
.generator-security-note { margin: 18px 0 0; color: var(--muted); font-size: 12px; line-height: 1.45; }.generator-security-note svg { color: var(--success); flex: 0 0 auto; }
.generator-number-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 15px; color: var(--text); font-size: 14px; font-weight: 700; }.generator-number-field > label { min-width: 0; }.generator-stepper { display: grid; grid-template-columns: 38px 64px 38px; align-items: center; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }.generator-stepper button { display: grid; width: 38px; height: 40px; place-items: center; border: 0; background: transparent; color: var(--primary-strong); cursor: pointer; transition: background-color 160ms ease, color 160ms ease; }.generator-stepper button:hover:not(:disabled) { background: color-mix(in srgb, var(--primary) 10%, var(--panel)); }.generator-stepper button:active:not(:disabled) { background: color-mix(in srgb, var(--primary) 17%, var(--panel)); }.generator-stepper button:focus-visible { position: relative; z-index: 1; outline: 3px solid color-mix(in srgb, var(--primary) 35%, transparent); outline-offset: -3px; }.generator-stepper button:disabled { color: var(--muted); cursor: not-allowed; }.generator-stepper .input { width: 64px; height: 40px; min-width: 0; border: 0; border-radius: 0; padding: 0; background: transparent; text-align: center; font-variant-numeric: tabular-nums; }.generator-stepper .input:focus { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--primary) 36%, transparent); }.generator-number-field.compact { grid-template-columns: minmax(0, 1fr) auto; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 32px 46px 32px; border-radius: 9px; }.generator-number-field.compact .generator-stepper button { width: 32px; height: 34px; }.generator-number-field.compact .generator-stepper .input { width: 46px; height: 34px; font-size: 13px; }
.generator-option-group { display: grid; gap: 9px; margin: 18px 0; padding: 0; border: 0; }.generator-option-group legend { margin-bottom: 10px; padding: 0; font-size: 14px; font-weight: 700; }
.generator-toggle { display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: 10px; min-height: 32px; cursor: pointer; }.generator-toggle input { position: absolute; opacity: 0; }.generator-toggle > span { position: relative; width: 38px; height: 22px; border-radius: 999px; background: #cbd5e1; transition: background 180ms ease; }.generator-toggle > span::after { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.25); content: ''; transition: transform 180ms ease; }.generator-toggle input:checked + span { background: var(--primary); }.generator-toggle input:checked + span::after { transform: translateX(16px); }.generator-toggle input:focus-visible + span { outline: 3px solid color-mix(in srgb, var(--primary) 30%, transparent); outline-offset: 2px; }.generator-toggle strong { font-size: 14px; font-weight: 600; }
.generator-inline-number { display: grid; grid-template-columns: minmax(0, 1fr) 72px; align-items: center; gap: 14px; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-inline-number .input { height: 34px; text-align: center; }
@media (max-width: 760px) { .generator-page { width: 100%; padding: 0 0 18px; }.generator-layout { grid-template-columns: 1fr; grid-template-areas: 'output' 'options'; gap: 10px; }.generator-output-card, .generator-options-card { padding: 15px; border-radius: 16px; }.generator-value { min-height: 94px; margin: 14px 0 10px; padding: 14px; font-size: 19px; }.generator-actions .btn { justify-content: center; padding-inline: 10px; }.generator-option-group { margin: 15px 0; }.generator-toggle { min-height: 44px; }.generator-number-field.compact { margin-left: 50px; }.generator-stepper { grid-template-columns: 40px 64px 40px; }.generator-stepper button { width: 40px; min-height: 44px; }.generator-stepper .input { height: 44px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 36px 46px 36px; }.generator-number-field.compact .generator-stepper button { width: 36px; height: 40px; min-height: 40px; } }
@media (prefers-reduced-motion: reduce) { .generator-toggle > span, .generator-toggle > span::after { transition: none; } }
+77
View File
@@ -0,0 +1,77 @@
.password-security-page { width: min(100%, 1180px); margin: 0; display: grid; gap: 10px; padding: 4px 0 24px; }
.password-security-intro { display: flex; align-items: center; gap: 12px; padding: 14px 16px; }
.password-security-intro-icon { width: 42px; height: 42px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 14px; color: var(--primary-strong); background: color-mix(in srgb, var(--primary) 12%, var(--panel)); }
.password-security-intro h2 { margin: 0 0 3px; font-size: 18px; }
.password-security-intro p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
.password-security-intro .password-security-checked-at { margin-top: 4px; font-size: 12px; font-variant-numeric: tabular-nums; }
.password-security-intro-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.password-security-scan, .password-security-toggle-all { min-height: 40px; }
.password-security-empty { min-height: 190px; display: grid; place-items: center; align-content: center; gap: 9px; text-align: center; color: var(--muted); padding: 28px; }
.password-security-empty > svg { color: var(--primary); }
.password-security-empty strong { color: var(--ink); }
.password-security-empty span { font-size: 14px; max-width: 520px; line-height: 1.5; }
.password-security-empty.compact { min-height: 150px; }
.password-security-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.password-security-metric { display: flex; align-items: center; gap: 10px; min-height: 72px; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--panel); box-shadow: var(--shadow-sm); color: inherit; font: inherit; text-align: left; cursor: pointer; transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; }
.password-security-metric:hover:not(:disabled), .password-security-metric[aria-pressed='true'] { border-color: var(--primary); box-shadow: var(--shadow-md); }
.password-security-metric:active:not(:disabled) { transform: scale(.99); }
.password-security-metric:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 45%, transparent); outline-offset: 2px; }
.password-security-metric:disabled { cursor: default; }
.password-security-metric > span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 12px; }
.password-security-metric.danger > span { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, var(--panel)); }
.password-security-metric.warning > span { color: #b45309; background: #fff7e6; }
.password-security-metric.primary > span { color: var(--primary-strong); background: color-mix(in srgb, var(--primary) 12%, var(--panel)); }
.password-security-metric div { display: grid; gap: 1px; min-width: 0; }
.password-security-metric strong { font-size: 20px; line-height: 1.15; font-variant-numeric: tabular-nums; }
.password-security-metric small { color: var(--muted); font-size: 12px; }
.password-security-results { padding: 8px; }
.password-security-notice { display: flex; align-items: center; gap: 8px; padding: 9px 10px; margin-bottom: 8px; border-radius: var(--radius-md); font-size: 13px; }
.password-security-notice.warning { color: #92400e; background: #fff7e6; border: 1px solid #fcd8a3; }
.password-security-list { display: grid; }
.password-security-item { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 64px; padding: 10px; border-bottom: 1px solid var(--line-soft); }
.password-security-item:last-child { border-bottom: 0; }
.password-security-item-main { min-width: 0; display: grid; gap: 5px; }
.password-security-item-header { display: flex; align-items: center; gap: 8px; min-width: 0; }
.password-security-item-header > strong { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.password-security-password { min-width: 0; color: var(--muted); font-size: 13px; font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.password-security-item-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 6px; }
.password-security-badges { display: flex; flex-wrap: wrap; gap: 6px; }
.risk-badge { display: inline-flex; align-items: center; min-height: 22px; padding: 2px 7px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.risk-badge.danger { color: #b42318; background: #fef0ef; }
.risk-badge.reused { color: #92400e; background: #fff7e6; }
.risk-badge.weak { color: #5b21b6; background: #f3e8ff; }
.risk-badge.muted { color: var(--muted); background: var(--panel-soft); }
.password-security-open { flex: 0 0 auto; }
.password-breach-inline { display: flex; align-items: center; gap: 7px; margin-top: 9px; padding: 9px 10px; border-radius: var(--radius-md); font-size: 13px; line-height: 1.35; }
.password-breach-inline.safe { color: #16704d; background: #ecfdf3; border: 1px solid #b7ebcd; }
.password-breach-inline.danger { color: #b42318; background: #fef0ef; border: 1px solid #fecdc9; }
.password-breach-inline.warning { color: #92400e; background: #fff7e6; border: 1px solid #fcd8a3; }
.spin { animation: password-security-spin 900ms linear infinite; }
@keyframes password-security-spin { to { transform: rotate(360deg); } }
@media (max-width: 760px) {
.password-security-page { width: 100%; padding: 0 0 18px; gap: 10px; }
.password-security-intro { align-items: flex-start; padding: 14px; }
.password-security-intro-icon { width: 38px; height: 38px; border-radius: 12px; }
.password-security-intro h2 { font-size: 16px; }
.password-security-intro p { font-size: 13px; }
.password-security-intro-actions { width: 100%; margin: 8px 0 0; grid-column: 1 / -1; }
.password-security-scan, .password-security-toggle-all { flex: 1 1 0; }
.password-security-intro { display: grid; grid-template-columns: auto minmax(0, 1fr); }
.password-security-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.password-security-metric { min-height: 74px; padding: 12px; gap: 8px; }
.password-security-metric > span { width: 32px; height: 32px; border-radius: 10px; }
.password-security-metric strong { font-size: 18px; }
.password-security-item { align-items: stretch; flex-direction: column; gap: 8px; }
.password-security-item-actions { width: 100%; }
.password-security-item-actions > * { flex: 1 1 0; min-height: 40px; }
}
:root[data-theme='dark'] .password-security-metric.warning > span,
:root[data-theme='dark'] .password-security-notice.warning,
:root[data-theme='dark'] .risk-badge.reused,
:root[data-theme='dark'] .password-breach-inline.warning { color: #fbbf24; background: rgba(180, 83, 9, .18); border-color: rgba(251, 191, 36, .25); }
:root[data-theme='dark'] .risk-badge.danger { color: #fca5a5; background: rgba(180, 35, 24, .2); }
:root[data-theme='dark'] .risk-badge.weak { color: #d8b4fe; background: rgba(91, 33, 182, .22); }
:root[data-theme='dark'] .password-breach-inline.safe { color: #6ee7b7; background: rgba(22, 112, 77, .2); border-color: rgba(110, 231, 183, .25); }
:root[data-theme='dark'] .password-breach-inline.danger { color: #fca5a5; background: rgba(180, 35, 24, .2); border-color: rgba(252, 165, 165, .25); }
+15 -1
View File
@@ -234,7 +234,7 @@
.mobile-tabbar { .mobile-tabbar {
@apply grid items-center gap-1.5; @apply grid items-center gap-1.5;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
min-height: var(--mobile-tabbar-height); min-height: var(--mobile-tabbar-height);
padding: 8px 10px calc(8px + env(safe-area-inset-bottom)); padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
@@ -427,6 +427,20 @@
min-width: max(100%, 190px); min-width: max(100%, 190px);
} }
.list-head .mobile-duplicate-toolbar {
@apply flex min-w-0 items-center gap-1.5;
flex: none;
}
.list-head .mobile-duplicate-mode-select-wrap {
max-width: 130px;
flex-shrink: 0;
}
.list-head .mobile-duplicate-mode-select-wrap .mobile-vault-filter-trigger {
@apply w-full;
}
.toolbar.actions { .toolbar.actions {
@apply justify-end overflow-visible pb-0.5; @apply justify-end overflow-visible pb-0.5;
flex-wrap: unset; flex-wrap: unset;
+5
View File
@@ -1269,3 +1269,8 @@ select.input.duplicate-mode-toolbar-select {
@apply text-sm; @apply text-sm;
color: var(--danger); color: var(--danger);
} }
.inline-icon {
display: inline;
vertical-align: middle;
}