mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 14:50:11 +00:00
Compare commits
55
Commits
v1.7.2
...
58a86ae8fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a86ae8fd | ||
|
|
b986af86dc | ||
|
|
8e33f92b33 | ||
|
|
a0f832e8a5 | ||
|
|
8a5b210a1d | ||
|
|
ebc8e8e340 | ||
|
|
a870142b7b | ||
|
|
a366acbac0 | ||
|
|
57c5ef9da6 | ||
|
|
f532d3ace3 | ||
|
|
cc4a830be8 | ||
|
|
c6438747e3 | ||
|
|
5c8f01be59 | ||
|
|
7ac6ae50bb | ||
|
|
ace00e8e74 | ||
|
|
51428461a8 | ||
|
|
23c53bd1af | ||
|
|
ae168bea31 | ||
|
|
00e0ec0892 | ||
|
|
2df43ccdb0 | ||
|
|
fd46dffc34 | ||
|
|
56b301f2d1 | ||
|
|
f0e523376c | ||
|
|
8b2f98b847 | ||
|
|
cde4555add | ||
|
|
1bad32fd90 | ||
|
|
e376a840c2 | ||
|
|
9de0d3bd87 | ||
|
|
109593da90 | ||
|
|
01ff627ac6 | ||
|
|
d028b194e7 | ||
|
|
c53d71fc28 | ||
|
|
6e722205b1 | ||
|
|
cf14704d99 | ||
|
|
0cef6a04e9 | ||
|
|
8c481a1564 | ||
|
|
d9a36fefe6 | ||
|
|
12af18e3a3 | ||
|
|
d8cc88d9c0 | ||
|
|
94b5f3e975 | ||
|
|
062c966e14 | ||
|
|
e73ae3d5ea | ||
|
|
c019c93726 | ||
|
|
f63b745d05 | ||
|
|
c7eb6c663d | ||
|
|
1ec6ed44a1 | ||
|
|
6284c632de | ||
|
|
60dd298dee | ||
|
|
439683d350 | ||
|
|
1545881eae | ||
|
|
680e287c8d | ||
|
|
baf569983d | ||
|
|
73bbe8b268 | ||
|
|
d024798548 | ||
|
|
b0a679b1c2 |
@@ -1,5 +0,0 @@
|
||||
# JWT Secret for signing tokens (required)
|
||||
# IMPORTANT: change this value before any real deployment.
|
||||
# Generate one with: openssl rand -hex 32
|
||||
# (Example only, 64 hex chars = 32 bytes)
|
||||
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
|
||||
@@ -26,7 +26,16 @@ jobs:
|
||||
node-version: 22
|
||||
|
||||
- name: Sync generated Bitwarden domains
|
||||
run: npm run domains:sync -- --ref "${{ inputs.bitwarden_ref || 'main' }}"
|
||||
env:
|
||||
BITWARDEN_REF: ${{ inputs.bitwarden_ref || 'main' }}
|
||||
run: |
|
||||
case "$BITWARDEN_REF" in
|
||||
"" | *[!A-Za-z0-9._/-]* )
|
||||
echo "Invalid bitwarden_ref"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
npm run domains:sync -- --ref "$BITWARDEN_REF"
|
||||
|
||||
- name: Verify custom domains were not touched
|
||||
run: git diff --exit-code -- src/static/global_domains.custom.json
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
name: Sync upstream
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_commit:
|
||||
description: 'Commit hash (leave blank to use latest commit)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Add upstream
|
||||
run: |
|
||||
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
|
||||
git fetch upstream --tags
|
||||
|
||||
- name: Resolve target commit
|
||||
id: resolve
|
||||
run: |
|
||||
TRIGGER="${{ github.event_name }}"
|
||||
MANUAL_INPUT="${{ github.event.inputs.target_commit }}"
|
||||
|
||||
if [ "$TRIGGER" = "schedule" ]; then
|
||||
# Auto mode: resolve latest upstream release tag
|
||||
LATEST_TAG=$(curl -s https://api.github.com/repos/shuaiplus/NodeWarden/releases/latest | jq -r .tag_name)
|
||||
if [ "$LATEST_TAG" = "null" ] || [ -z "$LATEST_TAG" ]; then
|
||||
echo "No release found in upstream."
|
||||
exit 1
|
||||
fi
|
||||
TARGET_SHA=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null)
|
||||
if [ -z "$TARGET_SHA" ]; then
|
||||
echo "Tag '$LATEST_TAG' not found after fetch."
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "mode=auto"
|
||||
echo "latest_tag=$LATEST_TAG"
|
||||
echo "target_sha=$TARGET_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)"
|
||||
|
||||
elif [ -n "$MANUAL_INPUT" ]; then
|
||||
# Manual mode: use provided commit hash or tag
|
||||
TARGET_SHA=$(git rev-parse "$MANUAL_INPUT" 2>/dev/null)
|
||||
if [ -z "$TARGET_SHA" ]; then
|
||||
echo "Cannot resolve '$MANUAL_INPUT' to a commit."
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "mode=manual"
|
||||
echo "target_sha=$TARGET_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Manual mode — target: $MANUAL_INPUT ($TARGET_SHA)"
|
||||
|
||||
else
|
||||
# Manual mode, blank input: use latest commit on upstream/main
|
||||
TARGET_SHA=$(git rev-parse upstream/main)
|
||||
{
|
||||
echo "mode=manual"
|
||||
echo "target_sha=$TARGET_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Manual mode — latest commit: $TARGET_SHA"
|
||||
fi
|
||||
|
||||
- name: Check if update is needed
|
||||
id: check
|
||||
run: |
|
||||
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
|
||||
MODE="${{ steps.resolve.outputs.mode }}"
|
||||
|
||||
if [ "$MODE" = "manual" ]; then
|
||||
# Manual: skip only if HEAD is exactly this commit
|
||||
CURRENT_SHA=$(git rev-parse HEAD)
|
||||
if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then
|
||||
echo "Already at $TARGET_SHA — skipping."
|
||||
echo "needs_update=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Switching to $TARGET_SHA"
|
||||
echo "needs_update=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
# Auto: skip if target is already in ancestry
|
||||
if git merge-base --is-ancestor "$TARGET_SHA" HEAD 2>/dev/null; then
|
||||
echo "Already up to date with $TARGET_SHA — skipping."
|
||||
echo "needs_update=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Update needed — target: $TARGET_SHA"
|
||||
echo "needs_update=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Apply update
|
||||
if: steps.check.outputs.needs_update == 'true'
|
||||
run: |
|
||||
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
|
||||
MODE="${{ steps.resolve.outputs.mode }}"
|
||||
git checkout main
|
||||
if [ "$MODE" = "manual" ]; then
|
||||
# Hard reset allows both upgrade and rollback
|
||||
git reset --hard "$TARGET_SHA"
|
||||
else
|
||||
git merge "$TARGET_SHA" --no-edit
|
||||
fi
|
||||
|
||||
- name: Restore workflow file
|
||||
if: steps.check.outputs.needs_update == 'true'
|
||||
run: |
|
||||
# Always keep our own workflow file, never let upstream overwrite it
|
||||
git checkout 'HEAD@{1}' -- .github/workflows/sync-upstream.yml 2>/dev/null || true
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: restore sync-upstream workflow after sync"
|
||||
fi
|
||||
|
||||
- name: Push
|
||||
if: steps.check.outputs.needs_update == 'true'
|
||||
run: |
|
||||
if [ "${{ steps.resolve.outputs.mode }}" = "manual" ]; then
|
||||
git push origin main --force
|
||||
else
|
||||
git push origin main
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.needs_update }}" = "true" ]; then
|
||||
{
|
||||
echo "### Synced successfully"
|
||||
echo "- **Mode:** ${{ steps.resolve.outputs.mode }}"
|
||||
echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}"
|
||||
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "### Nothing to update" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
@@ -60,6 +60,7 @@ NodeWarden-compat/
|
||||
|
||||
# Compatibility analysis documents
|
||||
BITWARDEN_COMPATIBILITY_ANALYSIS.md
|
||||
security-audits/
|
||||
.mcp.json
|
||||
opencode.jsonc
|
||||
.cursor/
|
||||
|
||||
@@ -3,95 +3,100 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
||||
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 频道</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
||||
<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_EN.md">English</a> |
|
||||
<a href="./CONTRIBUTING.md">贡献指南</a>
|
||||
<a href="./README_ZH.md">中文</a> |
|
||||
<a href="./CONTRIBUTING.md">Contributing</a> |
|
||||
<a href="https://nodewarden.app">Official wiki</a>
|
||||
</p>
|
||||
|
||||
> **免责声明**
|
||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
## 与 Bitwarden 官方服务端能力对比
|
||||
## Feature comparison with the official Bitwarden server
|
||||
|
||||
| 能力 | Bitwarden | NodeWarden | 说明 |
|
||||
| Feature | Bitwarden Free | NodeWarden | Notes |
|
||||
|---|---|---|---|
|
||||
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
|
||||
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
|
||||
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
||||
| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
|
||||
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
||||
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份(OneDrive/Google Drive等)** |
|
||||
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
||||
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
||||
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
||||
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||
| 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) |
|
||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
||||
| Web vault | ✅ | ✅ | **Original Web Vault UI** |
|
||||
| TOTP | ❌ | ✅ | Includes `steam://` support |
|
||||
| **PWA / offline** | ❌ | ✅ | **Installable, offline** |
|
||||
| **Passkey login** | ✅ | ✅ | **passwordless auth** |
|
||||
| API keys | ✅ | ✅ | CLI keys; create and rotate |
|
||||
| Login 2FA | ✅ | ✅ | TOTP, YubiKey, Passkey |
|
||||
| 2FA recovery codes | ✅ | ✅ | One-time 2FA disable codes |
|
||||
| Real-time push sync | ✅ | ✅ | All device sync |
|
||||
| Attachments / Send | ✅ | ✅ | Cloudflare R2 or KV |
|
||||
| Import / export | ✅ | ✅ | Bitwarden JSON / CSV / **ZIP** |
|
||||
| **Cloud backup center** | ❌ | ✅ | **Scheduled WebDAV / S3 incrementals** |
|
||||
| Device management | ✅ | ✅ | **Remove devices; trust controls** |
|
||||
| Login requests | ✅ | ✅ | **Cross-device login approval/unlock** |
|
||||
| **Multi-user** | ✅ | ✅ | Invite-code registration |
|
||||
| Domain rules | ✅ | ✅ | Equivalent domains, global exclusions |
|
||||
| Fill-assist | ✅ | ✅ | `POST /fill-assist`|
|
||||
| Organizations / collections / roles | ✅ | ❌ | Not implemented |
|
||||
| SSO / SCIM / directory | ✅ | ❌ | Not implemented |
|
||||
|
||||
---
|
||||
|
||||
## 已测试客户端
|
||||
## Tested clients
|
||||
|
||||
- ✅ Windows 桌面端
|
||||
- ✅ 手机 App
|
||||
- ✅ 浏览器扩展
|
||||
- ✅ Linux 桌面端
|
||||
- ⚠️ macOS 桌面端尚未完整验证
|
||||
- ✅ Windows desktop
|
||||
- ✅ Mobile app
|
||||
- ✅ Browser extension
|
||||
- ✅ Linux desktop
|
||||
- ⚠️ macOS desktop not fully verified yet
|
||||
|
||||
---
|
||||
|
||||
## 可视化快速部署
|
||||
## Visual quick deploy
|
||||
|
||||
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 域名
|
||||
1. Fork the NodeWarden repository to your GitHub account
|
||||
2. Open [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||
3. Choose **Continue with GitHub** and select your fork
|
||||
4. Set **build command** to `npm run build` and **deploy command** to `npm run deploy`
|
||||
- For KV mode, change the deploy command to `npm run deploy:kv`
|
||||
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]
|
||||
> 默认R2与可选KV的区别:
|
||||
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||
> Default R2 vs optional KV:
|
||||
> | Storage | Card required | Max single attachment / Send file | Free tier |
|
||||
> |---|---|---|---|
|
||||
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||
> | R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
||||
> | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||
|
||||
|
||||
## 更新方法:
|
||||
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||
- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
||||
## How to update
|
||||
|
||||
- Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork** → **Update branch**
|
||||
|
||||
|
||||
|
||||
## CLI 部署
|
||||
|
||||
## CLI deploy
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||
@@ -100,82 +105,31 @@ cd NodeWarden
|
||||
npm install
|
||||
npx wrangler login
|
||||
|
||||
# 默认:R2 模式
|
||||
# Default: R2 mode
|
||||
npm run deploy
|
||||
|
||||
# 可选:KV 模式
|
||||
# Optional: KV mode
|
||||
npm run deploy:kv
|
||||
|
||||
# 本地开发
|
||||
# Local development
|
||||
npm run dev
|
||||
npm run dev:kv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 主要特性
|
||||
|
||||
### PWA 渐进式 Web 应用
|
||||
|
||||
- ✅ **可安装到桌面** - 像原生应用一样运行
|
||||
- ✅ **离线使用** - 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 系列
|
||||
- 备份中心中的实例级完整手动导出
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 开源协议
|
||||
## License
|
||||
|
||||
LGPL-3.0 License
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
## Credits
|
||||
|
||||
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||
- [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
|
||||
|
||||
---
|
||||
|
||||
|
||||
-172
@@ -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
|
||||
|
||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
<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 MiB(Cloudflare限制) | 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/) - 无服务器平台
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||
@@ -241,6 +241,7 @@ CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL DEFAULT 'login',
|
||||
name TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL,
|
||||
|
||||
Submodule
+1
Submodule nodewarden-wiki added at 70f838b044
Generated
+9
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.7.2",
|
||||
"version": "1.7.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nodewarden",
|
||||
"version": "1.7.2",
|
||||
"version": "1.7.3",
|
||||
"license": "LGPL-3.0",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
@@ -14,6 +14,7 @@
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@zip.js/zip.js": "^2.8.26",
|
||||
"fflate": "^0.8.3",
|
||||
"jsqr": "1.4.0",
|
||||
"lucide-preact": "^1.22.0",
|
||||
"preact": "^10.29.3",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
@@ -3442,6 +3443,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsqr": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
|
||||
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.7.2",
|
||||
"version": "1.7.3",
|
||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||
"author": "shuaiplus",
|
||||
"license": "LGPL-3.0",
|
||||
@@ -67,6 +67,7 @@
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@zip.js/zip.js": "^2.8.26",
|
||||
"fflate": "^0.8.3",
|
||||
"jsqr": "1.4.0",
|
||||
"lucide-preact": "^1.22.0",
|
||||
"preact": "^10.29.3",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '1.7.2';
|
||||
export const APP_VERSION = '1.7.3';
|
||||
|
||||
@@ -62,6 +62,9 @@
|
||||
// Refresh-token grant budget per IP per minute.
|
||||
// refresh_token 授权每 IP 每分钟请求配额。
|
||||
refreshTokenRequestsPerMinute: 30,
|
||||
// Passwordless/auth-request creation budget per IP/email/device per minute.
|
||||
// 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。
|
||||
authRequestRequestsPerMinute: 5,
|
||||
// Fixed window size for API rate limiting in seconds.
|
||||
// API 限流固定窗口大小(秒)。
|
||||
apiWindowSeconds: 60,
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
executeConfiguredBackup,
|
||||
importAndAuditRemoteBackupFile,
|
||||
} from '../handlers/backup';
|
||||
import { verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
|
||||
import { isSafeBackupAttachmentBlobName, verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
|
||||
import { zipSync } from 'fflate';
|
||||
|
||||
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
|
||||
@@ -372,7 +372,7 @@ export class BackupTransferRunner {
|
||||
return badRequest('Remote attachment download payload is invalid');
|
||||
}
|
||||
const blobName = String(body?.blobName || '').trim();
|
||||
if (!body?.destination || !blobName) {
|
||||
if (!body?.destination || !isSafeBackupAttachmentBlobName(blobName)) {
|
||||
return badRequest('Remote attachment download payload is invalid');
|
||||
}
|
||||
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
|
||||
@@ -398,7 +398,7 @@ export class BackupTransferRunner {
|
||||
const blobNames = Array.from(new Set(
|
||||
(Array.isArray(body?.blobNames) ? body.blobNames : [])
|
||||
.map((blobName) => String(blobName || '').trim())
|
||||
.filter(Boolean)
|
||||
.filter(isSafeBackupAttachmentBlobName)
|
||||
));
|
||||
if (!body?.destination || !blobNames.length || blobNames.length > 40) {
|
||||
return badRequest('Remote attachment batch download payload is invalid');
|
||||
@@ -446,7 +446,7 @@ export class BackupTransferRunner {
|
||||
|
||||
for (const attachment of body.attachments) {
|
||||
const blobName = String(attachment?.blobName || '').trim();
|
||||
if (!blobName) {
|
||||
if (!isSafeBackupAttachmentBlobName(blobName)) {
|
||||
return badRequest('Attachment chunk payload is invalid');
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { bytesToBase64Url } from '../utils/passkey';
|
||||
import { bytesToBase64Url, parseClientDataJSON } from '../utils/passkey';
|
||||
import {
|
||||
accountPasskeyCredentialToResponse,
|
||||
accountPasskeyPrfStatus,
|
||||
@@ -29,8 +29,10 @@ import {
|
||||
verifyAccountPasskeyToken,
|
||||
} from '../utils/account-passkeys';
|
||||
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||
import { createRecoveryCode } from '../utils/recovery-code';
|
||||
|
||||
const MAX_ACCOUNT_PASSKEYS = 5;
|
||||
const MAX_TWO_FACTOR_PASSKEYS = 5;
|
||||
|
||||
function parseBodyObject(body: unknown): Record<string, any> {
|
||||
return body && typeof body === 'object' ? body as Record<string, any> : {};
|
||||
@@ -81,6 +83,43 @@ function hasCompletePrfKeySet(body: Record<string, any>): boolean {
|
||||
return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey);
|
||||
}
|
||||
|
||||
function twoFactorWebAuthnResponse(credentials: AccountPasskeyCredential[]): Record<string, unknown> {
|
||||
return {
|
||||
Enabled: credentials.length > 0,
|
||||
enabled: credentials.length > 0,
|
||||
Keys: credentials.map((credential, index) => ({
|
||||
Id: index + 1,
|
||||
id: index + 1,
|
||||
Name: credential.name,
|
||||
name: credential.name,
|
||||
Migrated: false,
|
||||
migrated: false,
|
||||
})),
|
||||
keys: credentials.map((credential, index) => ({
|
||||
Id: index + 1,
|
||||
id: index + 1,
|
||||
Name: credential.name,
|
||||
name: credential.name,
|
||||
Migrated: false,
|
||||
migrated: false,
|
||||
})),
|
||||
Object: 'twoFactorWebAuthn',
|
||||
object: 'twoFactorWebAuthn',
|
||||
};
|
||||
}
|
||||
|
||||
function readRegistrationChallenge(response: ReturnType<typeof normalizeRegistrationResponse>): string | null {
|
||||
if (!response) return null;
|
||||
const clientData = parseClientDataJSON(response.response.clientDataJSON);
|
||||
return String(clientData?.challenge || '').trim() || null;
|
||||
}
|
||||
|
||||
function readAuthenticationChallenge(response: ReturnType<typeof normalizeAuthenticationResponse>): string | null {
|
||||
if (!response) return null;
|
||||
const clientData = parseClientDataJSON(response.response.clientDataJSON);
|
||||
return String(clientData?.challenge || '').trim() || null;
|
||||
}
|
||||
|
||||
function readPrfKeySet(body: Record<string, any>): {
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
@@ -176,6 +215,9 @@ export async function assertAccountPasskeyCredential(
|
||||
if (payload.userId && credential.userId !== payload.userId) {
|
||||
throw new Error('Passkey does not belong to this user');
|
||||
}
|
||||
if (credential.purpose !== 'login') {
|
||||
throw new Error('Passkey is not registered for login');
|
||||
}
|
||||
|
||||
const userHandleUserId = userHandleToUserId(response.response.userHandle);
|
||||
const resolvedUserId = payload.userId || userHandleUserId || credential.userId;
|
||||
@@ -225,6 +267,268 @@ export async function handleGetAccountPasskeyCredentials(request: Request, env:
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildTwoFactorPasskeyAssertionOptions(
|
||||
request: Request,
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
user: User
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
if (!credentials.length) return null;
|
||||
|
||||
const { rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
allowCredentials: credentials.map((credential) => ({
|
||||
id: credential.credentialId,
|
||||
transports: (credential.transports || undefined) as any,
|
||||
})),
|
||||
userVerification: 'discouraged',
|
||||
timeout: 60000,
|
||||
});
|
||||
await saveChallenge(storage, 'TwoFactorAuthentication', options.challenge, user.id);
|
||||
return options as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function assertTwoFactorPasskeyCredential(
|
||||
request: Request,
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
user: User,
|
||||
deviceResponse: unknown
|
||||
): Promise<AccountPasskeyCredential> {
|
||||
const response = normalizeAuthenticationResponse(deviceResponse);
|
||||
if (!response) {
|
||||
throw new Error('Invalid passkey assertion response');
|
||||
}
|
||||
|
||||
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
|
||||
if (!credential || credential.userId !== user.id || credential.purpose !== 'twoFactor') {
|
||||
throw new Error('Passkey is not registered for two-step login');
|
||||
}
|
||||
|
||||
const challenge = readAuthenticationChallenge(response);
|
||||
if (!challenge) {
|
||||
throw new Error('Passkey assertion challenge is missing');
|
||||
}
|
||||
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||
await sha256Base64Url(challenge),
|
||||
'TwoFactorAuthentication',
|
||||
user.id,
|
||||
Date.now()
|
||||
);
|
||||
if (!consumed) {
|
||||
throw new Error('Passkey challenge has expired or was already used');
|
||||
}
|
||||
|
||||
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rpId,
|
||||
credential: toSimpleWebAuthnCredential(credential),
|
||||
requireUserVerification: false,
|
||||
});
|
||||
if (!verification.verified) {
|
||||
throw new Error('Passkey assertion could not be verified');
|
||||
}
|
||||
|
||||
await storage.updateAccountPasskeyCounter(
|
||||
credential.userId,
|
||||
credential.credentialId,
|
||||
verification.authenticationInfo.newCounter,
|
||||
new Date().toISOString()
|
||||
);
|
||||
credential.counter = verification.authenticationInfo.newCounter;
|
||||
return credential;
|
||||
}
|
||||
|
||||
export async function handleGetTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
return jsonResponse(twoFactorWebAuthnResponse(credentials));
|
||||
}
|
||||
|
||||
export async function handleGetTwoFactorWebAuthnChallenge(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
if (credentials.length >= MAX_TWO_FACTOR_PASSKEYS) {
|
||||
return errorResponse('Maximum WebAuthn credential count reached.', 400);
|
||||
}
|
||||
|
||||
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpID: rpId,
|
||||
rpName,
|
||||
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
|
||||
userName: user.email,
|
||||
userDisplayName: user.name || user.email,
|
||||
attestationType: 'none',
|
||||
timeout: 60000,
|
||||
excludeCredentials: credentials.map((credential) => ({
|
||||
id: credential.credentialId,
|
||||
transports: (credential.transports || undefined) as any,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'discouraged',
|
||||
requireResidentKey: false,
|
||||
userVerification: 'discouraged',
|
||||
},
|
||||
});
|
||||
await saveChallenge(storage, 'TwoFactorCreate', options.challenge, userId);
|
||||
return jsonResponse(options);
|
||||
}
|
||||
|
||||
export async function handlePutTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
if (currentCount >= MAX_TWO_FACTOR_PASSKEYS) {
|
||||
return errorResponse('Maximum WebAuthn credential count reached.', 400);
|
||||
}
|
||||
|
||||
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
|
||||
if (!registrationResponse) {
|
||||
return errorResponse('Invalid passkey registration response', 400);
|
||||
}
|
||||
const challenge = readRegistrationChallenge(registrationResponse);
|
||||
if (!challenge) {
|
||||
return errorResponse('Passkey challenge is missing', 400);
|
||||
}
|
||||
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||
await sha256Base64Url(challenge),
|
||||
'TwoFactorCreate',
|
||||
userId,
|
||||
Date.now()
|
||||
);
|
||||
if (!consumed) {
|
||||
return errorResponse('Passkey challenge has expired or was already used', 400);
|
||||
}
|
||||
|
||||
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: registrationResponse,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rpId,
|
||||
requireUserPresence: true,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch {
|
||||
return errorResponse('Passkey registration could not be verified', 400);
|
||||
}
|
||||
if (!verification.verified) {
|
||||
return errorResponse('Passkey registration could not be verified', 400);
|
||||
}
|
||||
|
||||
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
|
||||
if (existing) {
|
||||
return errorResponse('Passkey is already registered', 409);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const transports = normalizeTransports(registrationResponse.response.transports);
|
||||
await storage.saveAccountPasskeyCredential({
|
||||
id: generateUUID(),
|
||||
userId,
|
||||
purpose: 'twoFactor',
|
||||
name: normalizeAccountPasskeyName(body.name || `Passkey ${currentCount + 1}`),
|
||||
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
||||
credentialId: verification.registrationInfo.credential.id,
|
||||
counter: verification.registrationInfo.credential.counter,
|
||||
type: verification.registrationInfo.credentialType || 'public-key',
|
||||
aaGuid: verification.registrationInfo.aaguid || null,
|
||||
transports,
|
||||
encryptedUserKey: null,
|
||||
encryptedPublicKey: null,
|
||||
encryptedPrivateKey: null,
|
||||
supportsPrf: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
if (!user.totpRecoveryCode) {
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
user.updatedAt = now;
|
||||
await storage.saveUser(user);
|
||||
}
|
||||
await storage.deleteRefreshTokensByUserId(userId);
|
||||
AuthService.invalidateUserCache(userId);
|
||||
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.webauthn_2fa.enable',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: null,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
return jsonResponse(twoFactorWebAuthnResponse(credentials));
|
||||
}
|
||||
|
||||
export async function handleDeleteTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const requestedId = Number(body.id ?? body.Id);
|
||||
if (!Number.isInteger(requestedId) || requestedId <= 0) {
|
||||
return errorResponse('Invalid key id', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
if (credentials.length < 2) {
|
||||
return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||
}
|
||||
const credential = credentials[requestedId - 1];
|
||||
if (!credential) {
|
||||
return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||
}
|
||||
|
||||
const deleted = await storage.deleteAccountPasskeyCredential(userId, credential.id, 'twoFactor');
|
||||
if (!deleted) return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||
await storage.deleteRefreshTokensByUserId(userId);
|
||||
AuthService.invalidateUserCache(userId);
|
||||
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.webauthn_2fa.delete',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: credential.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
return jsonResponse(twoFactorWebAuthnResponse(await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor')));
|
||||
}
|
||||
|
||||
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
@@ -380,6 +684,7 @@ export async function handleCreateAccountPasskeyCredential(request: Request, env
|
||||
const credential: AccountPasskeyCredential = {
|
||||
id: generateUUID(),
|
||||
userId,
|
||||
purpose: 'login',
|
||||
name: normalizeAccountPasskeyName(body.name),
|
||||
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
||||
credentialId: verification.registrationInfo.credential.id,
|
||||
|
||||
+359
-50
@@ -1,4 +1,4 @@
|
||||
import { Env, User, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { Env, User } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
@@ -6,14 +6,20 @@ import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../s
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||
import { hashApiKey } from '../utils/api-key';
|
||||
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
|
||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||
import { buildAccountKeys } from '../utils/user-decryption';
|
||||
import { buildProfileResponse } from '../utils/profile-response';
|
||||
import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp';
|
||||
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
|
||||
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
|
||||
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
|
||||
|
||||
// CONTRACT:
|
||||
// users.master_password_hash is server-side login verification only. It does
|
||||
@@ -36,6 +42,9 @@ function looksLikeEncString(value: string): boolean {
|
||||
*/
|
||||
function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null {
|
||||
const type = kdfType ?? 0;
|
||||
if (type !== 0 && type !== 1) {
|
||||
return 'KDF type must be PBKDF2-SHA256 or Argon2id';
|
||||
}
|
||||
if (type === 0) {
|
||||
// PBKDF2-SHA256: minimum 100 000 iterations
|
||||
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) {
|
||||
@@ -149,10 +158,9 @@ function normalizeMasterPasswordHint(input: string | null | undefined): string |
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret) return 'missing';
|
||||
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
||||
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
||||
return null;
|
||||
}
|
||||
@@ -193,6 +201,31 @@ function readNestedNumber(source: unknown, path: string[]): number | undefined {
|
||||
return typeof current === 'number' ? current : undefined;
|
||||
}
|
||||
|
||||
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
|
||||
const fromEnv = yubicoCredentialsFromEnv(env);
|
||||
if (fromEnv) return fromEnv;
|
||||
const clientId = String(await storage.getConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY) || '').trim();
|
||||
if (!clientId) return null;
|
||||
const secretKey = String(await storage.getConfigValue(YUBICO_KEY_CONFIG_KEY) || '').trim();
|
||||
return { clientId, secretKey };
|
||||
}
|
||||
|
||||
async function ensureStoredYubicoCredentials(
|
||||
storage: StorageService,
|
||||
env: Env,
|
||||
email: string,
|
||||
otp: string
|
||||
): Promise<YubicoApiCredentials | null> {
|
||||
const existing = await getStoredYubicoCredentials(storage, env);
|
||||
if (existing) return existing;
|
||||
|
||||
const credentials = await requestYubicoApiCredentials(email, otp);
|
||||
if (!credentials) return null;
|
||||
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId);
|
||||
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey);
|
||||
return credentials;
|
||||
}
|
||||
|
||||
async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
@@ -241,9 +274,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
if (unsafe) {
|
||||
const message = unsafe === 'missing'
|
||||
? 'JWT_SECRET is not set'
|
||||
: unsafe === 'default'
|
||||
? 'JWT_SECRET is using the default/sample value. Please change it.'
|
||||
: 'JWT_SECRET must be at least 32 characters';
|
||||
: 'JWT_SECRET must be at least 32 characters';
|
||||
return errorResponse(message, 400);
|
||||
}
|
||||
|
||||
@@ -324,6 +355,12 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
verifyDevices: true,
|
||||
totpSecret: null,
|
||||
totpRecoveryCode: null,
|
||||
yubikeyKey1: null,
|
||||
yubikeyKey2: null,
|
||||
yubikeyKey3: null,
|
||||
yubikeyKey4: null,
|
||||
yubikeyKey5: null,
|
||||
yubikeyNfc: false,
|
||||
apiKey: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -414,7 +451,7 @@ export async function handleGetPasswordHint(request: Request, env: Env): Promise
|
||||
}
|
||||
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const minuteBudget = await rateLimit.consumeBudgetWithWindow(
|
||||
const minuteBudget = await rateLimit.consumeStrictBudgetWithWindow(
|
||||
`${clientIdentifier}:password-hint`,
|
||||
LIMITS.rateLimit.passwordHintRequestsPerMinute,
|
||||
60
|
||||
@@ -436,7 +473,7 @@ export async function handleGetPasswordHint(request: Request, env: Env): Promise
|
||||
);
|
||||
}
|
||||
|
||||
const hourlyBudget = await rateLimit.consumeBudgetWithWindow(
|
||||
const hourlyBudget = await rateLimit.consumeStrictBudgetWithWindow(
|
||||
`${clientIdentifier}:password-hint-hour`,
|
||||
LIMITS.rateLimit.passwordHintRequestsPerHour,
|
||||
60 * 60
|
||||
@@ -700,6 +737,11 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
|
||||
const nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']);
|
||||
const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism);
|
||||
if (kdfErr) return errorResponse(kdfErr, 400);
|
||||
const shouldUpdateHint = typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null;
|
||||
const nextMasterPasswordHint = shouldUpdateHint ? normalizeMasterPasswordHint(body.masterPasswordHint) : undefined;
|
||||
if (nextMasterPasswordHint && nextMasterPasswordHint.length > 120) {
|
||||
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
|
||||
}
|
||||
|
||||
user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email);
|
||||
if (nextKey) user.key = nextKey;
|
||||
@@ -709,8 +751,8 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
|
||||
if (typeof nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations;
|
||||
if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory;
|
||||
if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism;
|
||||
if (typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null) {
|
||||
user.masterPasswordHint = body.masterPasswordHint;
|
||||
if (shouldUpdateHint) {
|
||||
user.masterPasswordHint = nextMasterPasswordHint ?? null;
|
||||
}
|
||||
user.securityStamp = generateUUID();
|
||||
user.updatedAt = new Date().toISOString();
|
||||
@@ -764,6 +806,41 @@ function twoFactorAuthenticatorResponse(
|
||||
};
|
||||
}
|
||||
|
||||
function yubiKeyResponse(user: User): Record<string, unknown> {
|
||||
return {
|
||||
Enabled: isYubiKeyEnabled(user),
|
||||
Key1: user.yubikeyKey1,
|
||||
Key2: user.yubikeyKey2,
|
||||
Key3: user.yubikeyKey3,
|
||||
Key4: user.yubikeyKey4,
|
||||
Key5: user.yubikeyKey5,
|
||||
Nfc: !!user.yubikeyNfc,
|
||||
Object: 'twoFactorYubiKey',
|
||||
};
|
||||
}
|
||||
|
||||
function deviceVerificationSettingsResponse(user: User): Record<string, unknown> {
|
||||
const enabled = user.verifyDevices !== false;
|
||||
return {
|
||||
Enabled: enabled,
|
||||
enabled,
|
||||
VerifyDevices: enabled,
|
||||
verifyDevices: enabled,
|
||||
Object: 'deviceVerificationSettings',
|
||||
object: 'deviceVerificationSettings',
|
||||
};
|
||||
}
|
||||
|
||||
async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> {
|
||||
const credentials = await getStoredYubicoCredentials(storage, env);
|
||||
return {
|
||||
...yubiKeyResponse(user),
|
||||
YubicoConfigured: !!credentials?.clientId,
|
||||
YubicoClientId: credentials?.clientId ?? '',
|
||||
YubicoSecretKey: credentials?.secretKey ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/two-factor
|
||||
export async function handleGetTwoFactorProviders(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
void request;
|
||||
@@ -771,9 +848,11 @@ export async function handleGetTwoFactorProviders(request: Request, env: Env, us
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
const data = user.totpSecret
|
||||
? [twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true)]
|
||||
: [];
|
||||
const data = [];
|
||||
if (isTotpEnabled(user.totpSecret)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true));
|
||||
if (isYubiKeyEnabled(user)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_YUBIKEY, true));
|
||||
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
if (webAuthnCredentials.length > 0) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_WEBAUTHN, true));
|
||||
|
||||
return jsonResponse({
|
||||
Data: data,
|
||||
@@ -805,6 +884,79 @@ export async function handleGetTwoFactorAuthenticator(request: Request, env: Env
|
||||
return jsonResponse(twoFactorAuthenticatorResponse(!!user.totpSecret, key, userVerificationToken));
|
||||
}
|
||||
|
||||
// POST /api/two-factor/get-yubikey
|
||||
export async function handleGetTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readRequestBody(request);
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||
const verified = await verifyUserSecret(auth, user, secret);
|
||||
if (!verified) return errorResponse('User verification failed.', 400);
|
||||
|
||||
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||
}
|
||||
|
||||
// POST /api/two-factor/get-device-verification-settings
|
||||
export async function handleGetDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
return jsonResponse(deviceVerificationSettingsResponse(user));
|
||||
}
|
||||
|
||||
// PUT/POST /api/two-factor/device-verification-settings
|
||||
export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readRequestBody(request);
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
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']);
|
||||
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, {
|
||||
actorUserId: user.id,
|
||||
action: 'account.verify_devices.update',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
verifyDevices: user.verifyDevices,
|
||||
source: 'two-factor.device-verification-settings',
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
|
||||
return jsonResponse(deviceVerificationSettingsResponse(user));
|
||||
}
|
||||
|
||||
// PUT/POST /api/two-factor/authenticator
|
||||
export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
@@ -828,7 +980,10 @@ export async function handlePutTwoFactorAuthenticator(request: Request, env: Env
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
if (!isTotpEnabled(key)) return errorResponse('Invalid TOTP secret', 400);
|
||||
if (!await verifyTotpToken(key, token)) return errorResponse('Invalid token.', 400);
|
||||
const matchedCounter = await findMatchingTotpCounter(key, token);
|
||||
if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) {
|
||||
return errorResponse('Invalid token.', 400);
|
||||
}
|
||||
|
||||
user.totpSecret = key;
|
||||
if (!user.totpRecoveryCode) {
|
||||
@@ -851,6 +1006,141 @@ export async function handlePutTwoFactorAuthenticator(request: Request, env: Env
|
||||
return jsonResponse(twoFactorAuthenticatorResponse(true, key));
|
||||
}
|
||||
|
||||
// PUT/POST /api/two-factor/yubikey
|
||||
export async function handlePutTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readRequestBody(request);
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||
const verified = await verifyUserSecret(auth, user, secret);
|
||||
if (!verified) return errorResponse('User verification failed.', 400);
|
||||
|
||||
const keys = [
|
||||
readBodyString(body, ['key1', 'Key1']),
|
||||
readBodyString(body, ['key2', 'Key2']),
|
||||
readBodyString(body, ['key3', 'Key3']),
|
||||
readBodyString(body, ['key4', 'Key4']),
|
||||
readBodyString(body, ['key5', 'Key5']),
|
||||
];
|
||||
const publicIds: Array<string | null> = [];
|
||||
let credentials = await getStoredYubicoCredentials(storage, env);
|
||||
let apiKeyBootstrapOtpIndex: number | null = null;
|
||||
for (const key of keys) {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) {
|
||||
publicIds.push(null);
|
||||
continue;
|
||||
}
|
||||
const publicId = yubiKeyPublicIdFromOtp(trimmed);
|
||||
if (!publicId) return errorResponse('Invalid YubiKey OTP.', 400);
|
||||
if (isYubiKeyPublicId(trimmed)) {
|
||||
publicIds.push(publicId);
|
||||
continue;
|
||||
}
|
||||
if (!credentials) {
|
||||
credentials = await ensureStoredYubicoCredentials(storage, env, user.email, trimmed);
|
||||
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
|
||||
apiKeyBootstrapOtpIndex = publicIds.length;
|
||||
}
|
||||
if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) {
|
||||
return errorResponse('Invalid YubiKey OTP.', 400);
|
||||
}
|
||||
publicIds.push(publicId);
|
||||
}
|
||||
if (!publicIds.some(Boolean)) return errorResponse('At least one YubiKey OTP is required.', 400);
|
||||
|
||||
user.yubikeyKey1 = publicIds[0] ?? null;
|
||||
user.yubikeyKey2 = publicIds[1] ?? null;
|
||||
user.yubikeyKey3 = publicIds[2] ?? null;
|
||||
user.yubikeyKey4 = publicIds[3] ?? null;
|
||||
user.yubikeyKey5 = publicIds[4] ?? null;
|
||||
user.yubikeyNfc = !!(body.nfc ?? body.Nfc);
|
||||
if (!user.totpRecoveryCode) {
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'account.yubikey.enable',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||
}
|
||||
|
||||
// PUT/POST /api/two-factor/yubikey/config
|
||||
export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readRequestBody(request);
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||
const verified = await verifyUserSecret(auth, user, secret);
|
||||
if (!verified) return errorResponse('User verification failed.', 400);
|
||||
|
||||
const clientId = readBodyString(body, ['yubicoClientId', 'YubicoClientId', 'clientId', 'ClientId']).trim();
|
||||
const secretKey = readBodyString(body, ['yubicoSecretKey', 'YubicoSecretKey', 'secretKey', 'SecretKey']).trim();
|
||||
if (!clientId) return errorResponse('Yubico Client ID is required.', 400);
|
||||
|
||||
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, clientId);
|
||||
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, secretKey);
|
||||
|
||||
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||
}
|
||||
|
||||
// POST /api/two-factor/yubikey/bootstrap
|
||||
export async function handleBootstrapTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readRequestBody(request);
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']);
|
||||
const verified = await verifyUserSecret(auth, user, secret);
|
||||
if (!verified) return errorResponse('User verification failed.', 400);
|
||||
|
||||
const otp = readBodyString(body, ['otp', 'OTP', 'token', 'Token']).trim();
|
||||
if (!yubiKeyPublicIdFromOtp(otp)) return errorResponse('Invalid YubiKey OTP.', 400);
|
||||
const credentials = await requestYubicoApiCredentials(user.email, otp);
|
||||
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
|
||||
|
||||
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId);
|
||||
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey);
|
||||
|
||||
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||
}
|
||||
|
||||
// DELETE /api/two-factor/authenticator and PUT/POST /api/two-factor/disable
|
||||
export async function handleDisableTwoFactorProvider(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
@@ -867,30 +1157,40 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
||||
|
||||
const typeRaw = body.type ?? body.Type ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
|
||||
const type = typeof typeRaw === 'number' ? typeRaw : Number.parseInt(String(typeRaw), 10);
|
||||
if (type !== TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||
if (![TWO_FACTOR_PROVIDER_AUTHENTICATOR, TWO_FACTOR_PROVIDER_YUBIKEY, TWO_FACTOR_PROVIDER_WEBAUTHN].includes(type)) {
|
||||
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']);
|
||||
let verified = false;
|
||||
if (key && userVerificationToken) {
|
||||
verified = await verifyTotpUserVerificationToken(env, user, key, userVerificationToken);
|
||||
}
|
||||
if (!verified) {
|
||||
verified = await verifyUserSecret(auth, user, secret);
|
||||
}
|
||||
const verified = await verifyUserSecret(auth, user, secret);
|
||||
if (!verified) return errorResponse('User verification failed.', 400);
|
||||
|
||||
user.totpSecret = null;
|
||||
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||
user.totpSecret = null;
|
||||
} else if (type === TWO_FACTOR_PROVIDER_YUBIKEY) {
|
||||
user.yubikeyKey1 = null;
|
||||
user.yubikeyKey2 = null;
|
||||
user.yubikeyKey3 = null;
|
||||
user.yubikeyKey4 = null;
|
||||
user.yubikeyKey5 = null;
|
||||
user.yubikeyNfc = false;
|
||||
} else {
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
for (const credential of credentials) {
|
||||
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
|
||||
}
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'account.totp.disable',
|
||||
action: type === TWO_FACTOR_PROVIDER_AUTHENTICATOR
|
||||
? 'account.totp.disable'
|
||||
: type === TWO_FACTOR_PROVIDER_YUBIKEY
|
||||
? 'account.yubikey.disable'
|
||||
: 'account.webauthn_2fa.disable',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
@@ -898,7 +1198,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
return jsonResponse(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, false));
|
||||
return jsonResponse(twoFactorProviderResponse(type, false));
|
||||
}
|
||||
|
||||
// PUT /api/accounts/totp
|
||||
@@ -943,8 +1243,8 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
|
||||
if (!verifiedUser) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
const verified = await verifyTotpToken(normalizedSecret, body.token);
|
||||
if (!verified) {
|
||||
const matchedCounter = await findMatchingTotpCounter(normalizedSecret, body.token);
|
||||
if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) {
|
||||
return errorResponse('Invalid TOTP token', 400);
|
||||
}
|
||||
user.totpSecret = normalizedSecret;
|
||||
@@ -1092,6 +1392,16 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
|
||||
}
|
||||
|
||||
user.totpSecret = null;
|
||||
user.yubikeyKey1 = null;
|
||||
user.yubikeyKey2 = null;
|
||||
user.yubikeyKey3 = null;
|
||||
user.yubikeyKey4 = null;
|
||||
user.yubikeyKey5 = null;
|
||||
user.yubikeyNfc = false;
|
||||
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
for (const credential of webAuthnCredentials) {
|
||||
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
|
||||
}
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
user.securityStamp = generateUUID();
|
||||
user.updatedAt = new Date().toISOString();
|
||||
@@ -1194,29 +1504,28 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
|
||||
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
|
||||
if (!valid) return errorResponse('Invalid password', 400);
|
||||
|
||||
if (rotate || user.apiKey === null) {
|
||||
// Upstream apikeys are 30-character random alphanumeric strings
|
||||
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
|
||||
if (rotate) {
|
||||
user.securityStamp = generateUUID();
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: rotate ? 'account.api_key.rotate' : 'account.api_key.create',
|
||||
category: 'security',
|
||||
level: rotate ? 'security' : 'info',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
// Only the fresh secret is returned once; the database stores a hash.
|
||||
const plainApiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
|
||||
user.apiKey = await hashApiKey(plainApiKey);
|
||||
if (rotate) {
|
||||
user.securityStamp = generateUUID();
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: rotate ? 'account.api_key.rotate' : 'account.api_key.create',
|
||||
category: 'security',
|
||||
level: rotate ? 'security' : 'info',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
apiKey: user.apiKey,
|
||||
apiKey: plainApiKey,
|
||||
revisionDate: user.updatedAt,
|
||||
object: 'apiKey',
|
||||
});
|
||||
|
||||
+11
-4
@@ -69,18 +69,22 @@ export async function handleAdminListUsers(
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const users = await storage.getAllUsers();
|
||||
return jsonResponse({
|
||||
data: users.map(user => ({
|
||||
const data = await Promise.all(users.map(async user => {
|
||||
const hasTwoFactorPasskey = await storage.countAccountPasskeyCredentialsByUserId(user.id, 'twoFactor') > 0;
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5) || hasTwoFactorPasskey,
|
||||
creationDate: user.createdAt,
|
||||
revisionDate: user.updatedAt,
|
||||
object: 'user',
|
||||
})),
|
||||
};
|
||||
}));
|
||||
return jsonResponse({
|
||||
data,
|
||||
object: 'list',
|
||||
continuationToken: null,
|
||||
});
|
||||
@@ -183,6 +187,9 @@ export async function handleAdminClearAuditLogs(
|
||||
}
|
||||
const storage = new StorageService(env.DB);
|
||||
const deleted = await storage.clearAuditLogs();
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.audit.clear', 'auditLog', null, {
|
||||
deleted,
|
||||
}, request);
|
||||
return jsonResponse({ object: 'auditLogClear', deleted });
|
||||
}
|
||||
|
||||
|
||||
+28
-27
@@ -1,4 +1,4 @@
|
||||
import { Env, Attachment, Cipher, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { Env, Attachment, Cipher } from '../types';
|
||||
import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
@@ -124,6 +124,10 @@ async function processAttachmentUpload(
|
||||
}
|
||||
|
||||
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||
if (await getBlobObject(env, path)) {
|
||||
return errorResponse('Attachment file has already been uploaded', 409);
|
||||
}
|
||||
|
||||
try {
|
||||
await putBlobObject(env, path, upload.body, {
|
||||
size: upload.size,
|
||||
@@ -167,7 +171,7 @@ export async function handleCreateAttachment(
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
@@ -205,7 +209,7 @@ export async function handleCreateAttachment(
|
||||
await storage.saveAttachment(attachment);
|
||||
|
||||
// Add attachment to cipher
|
||||
await storage.addAttachmentToCipher(cipherId, attachmentId);
|
||||
await storage.addAttachmentToCipherForUser(cipherId, attachmentId, userId);
|
||||
|
||||
// Update cipher revision date
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
@@ -215,7 +219,7 @@ export async function handleCreateAttachment(
|
||||
}
|
||||
|
||||
// Get updated cipher for response
|
||||
const updatedCipher = await storage.getCipher(cipherId);
|
||||
const updatedCipher = await storage.getCipherForUser(cipherId, userId);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
const jwtSecret = getSafeJwtSecret(env);
|
||||
if (!jwtSecret) {
|
||||
@@ -244,13 +248,13 @@ export async function handleUploadAttachment(
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
@@ -283,12 +287,12 @@ export async function handlePublicUploadAttachment(
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, claims.userId);
|
||||
if (!cipher || cipher.userId !== claims.userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
const attachment = await storage.getAttachmentForUser(attachmentId, claims.userId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
@@ -308,13 +312,13 @@ export async function handleGetAttachment(
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
@@ -349,12 +353,12 @@ export async function handleUpdateAttachmentMetadata(
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
@@ -405,10 +409,8 @@ export async function handlePublicDownloadAttachment(
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||
return errorResponse('Server configuration error', 500);
|
||||
}
|
||||
const secret = getSafeJwtSecret(env);
|
||||
if (!secret) return errorResponse('Server configuration error', 500);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const token = url.searchParams.get('token');
|
||||
@@ -418,7 +420,7 @@ export async function handlePublicDownloadAttachment(
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const claims = await verifyFileDownloadToken(token, env.JWT_SECRET);
|
||||
const claims = await verifyFileDownloadToken(token, secret);
|
||||
if (!claims) {
|
||||
return errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
@@ -437,17 +439,16 @@ export async function handlePublicDownloadAttachment(
|
||||
}
|
||||
|
||||
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);
|
||||
if (!firstUse) {
|
||||
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, {
|
||||
headers: {
|
||||
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||
@@ -471,13 +472,13 @@ export async function handleDeleteAttachment(
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
@@ -486,7 +487,7 @@ export async function handleDeleteAttachment(
|
||||
await deleteBlobObject(env, path);
|
||||
|
||||
// Delete attachment metadata
|
||||
await storage.deleteAttachment(attachmentId);
|
||||
await storage.deleteAttachmentForUser(attachmentId, userId);
|
||||
|
||||
// Update cipher revision date
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
@@ -501,7 +502,7 @@ export async function handleDeleteAttachment(
|
||||
}
|
||||
|
||||
// Get updated cipher for response
|
||||
const updatedCipher = await storage.getCipher(cipherId);
|
||||
const updatedCipher = await storage.getCipherForUser(cipherId, userId);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
const cipherResponse = cipherToResponse(updatedCipher!, attachments);
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { readAuthRequestDeviceInfo, readActingDeviceIdentifier } from '../utils/
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||
import { notifyAuthRequestResponse, notifyUserAuthRequest } from '../durable/notifications-hub';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
const AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK = 0;
|
||||
const AUTH_REQUEST_TYPE_UNLOCK = 1;
|
||||
@@ -94,8 +96,8 @@ function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord,
|
||||
RequestCountryName: authRequest.requestCountryName,
|
||||
key: authRequest.key,
|
||||
Key: authRequest.key,
|
||||
masterPasswordHash: authRequest.masterPasswordHash,
|
||||
MasterPasswordHash: authRequest.masterPasswordHash,
|
||||
masterPasswordHash: null,
|
||||
MasterPasswordHash: null,
|
||||
creationDate: authRequest.creationDate,
|
||||
CreationDate: authRequest.creationDate,
|
||||
responseDate: authRequest.responseDate,
|
||||
@@ -131,6 +133,30 @@ async function readJsonBody(request: Request): Promise<Record<string, any> | nul
|
||||
}
|
||||
}
|
||||
|
||||
async function enforceAuthRequestCreateRateLimit(
|
||||
request: Request,
|
||||
env: Env,
|
||||
email: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<Response | null> {
|
||||
const clientIdentifier = getClientIdentifier(request);
|
||||
if (!clientIdentifier) return errorResponse('Client IP is required', 403);
|
||||
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const limit = LIMITS.rateLimit.authRequestRequestsPerMinute;
|
||||
const encodedEmail = encodeURIComponent(email || 'missing');
|
||||
const encodedDevice = encodeURIComponent(deviceIdentifier || 'missing');
|
||||
const budgets = await Promise.all([
|
||||
rateLimit.consumeStrictBudget(`auth-request:ip:${clientIdentifier}`, limit),
|
||||
rateLimit.consumeStrictBudget(`auth-request:email:${encodedEmail}`, limit),
|
||||
rateLimit.consumeStrictBudget(`auth-request:device:${encodedDevice}`, limit),
|
||||
]);
|
||||
const blocked = budgets.find((budget) => !budget.allowed);
|
||||
if (!blocked) return null;
|
||||
|
||||
return errorResponse('Too many authentication requests. Try again later.', 429);
|
||||
}
|
||||
|
||||
function readBodyValue(body: Record<string, any>, names: string[]): unknown {
|
||||
for (const name of names) {
|
||||
if (body[name] !== undefined) return body[name];
|
||||
@@ -164,6 +190,8 @@ export async function handleCreateAuthRequest(request: Request, env: Env): Promi
|
||||
if (!email || !publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
|
||||
return errorResponse('Email, public key, device identifier, and access code are required.', 400);
|
||||
}
|
||||
const rateLimitResponse = await enforceAuthRequestCreateRateLimit(request, env, email, deviceInfo.deviceIdentifier);
|
||||
if (rateLimitResponse) return rateLimitResponse;
|
||||
if (!isSupportedAuthRequestType(type) || type === AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
|
||||
return errorResponse('Invalid auth request type.', 400);
|
||||
}
|
||||
@@ -199,9 +227,75 @@ export async function handleCreateAuthRequest(request: Request, env: Env): Promi
|
||||
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||
}
|
||||
|
||||
export async function handleCreateAdminAuthRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
userEmail: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
const email = normalizeText(readBodyValue(body, ['email', 'Email']), 320).toLowerCase() || userEmail.toLowerCase();
|
||||
const publicKey = normalizeText(readBodyValue(body, ['publicKey', 'PublicKey']), 8192);
|
||||
const accessCode = normalizeText(readBodyValue(body, ['accessCode', 'AccessCode']), 25);
|
||||
const requestedType = Number(readBodyValue(body, ['type', 'Type']));
|
||||
const deviceInfo = readAuthRequestDeviceInfo(
|
||||
{
|
||||
deviceIdentifier: normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128),
|
||||
deviceName: normalizeText(readBodyValue(body, ['deviceName', 'DeviceName']), 128),
|
||||
deviceType: String(readBodyValue(body, ['deviceType', 'DeviceType']) ?? ''),
|
||||
},
|
||||
request
|
||||
);
|
||||
|
||||
if (requestedType !== AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
|
||||
return errorResponse('Invalid AuthRequestType. Expected AdminApproval.', 400);
|
||||
}
|
||||
if (email !== userEmail.toLowerCase()) {
|
||||
return errorResponse('Email does not match authenticated user.', 400);
|
||||
}
|
||||
if (!publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
|
||||
return errorResponse('Public key, device identifier, and access code are required.', 400);
|
||||
}
|
||||
const rateLimitResponse = await enforceAuthRequestCreateRateLimit(request, env, email, deviceInfo.deviceIdentifier);
|
||||
if (rateLimitResponse) return rateLimitResponse;
|
||||
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user || user.status !== 'active') {
|
||||
return errorResponse('User not found.', 404);
|
||||
}
|
||||
|
||||
await storage.pruneExpiredAuthRequests();
|
||||
const now = new Date().toISOString();
|
||||
const authRequest: AuthRequestRecord = {
|
||||
id: generateUUID(),
|
||||
userId: user.id,
|
||||
organizationId: null,
|
||||
type: AUTH_REQUEST_TYPE_ADMIN_APPROVAL,
|
||||
requestDeviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
requestDeviceType: deviceInfo.deviceType,
|
||||
requestIpAddress: getClientIp(request),
|
||||
requestCountryName: getCountryName(request),
|
||||
responseDeviceIdentifier: null,
|
||||
accessCode,
|
||||
publicKey,
|
||||
key: null,
|
||||
masterPasswordHash: null,
|
||||
approved: null,
|
||||
creationDate: now,
|
||||
responseDate: null,
|
||||
authenticationDate: null,
|
||||
};
|
||||
await storage.createAuthRequest(authRequest);
|
||||
notifyUserAuthRequest(env, user.id, authRequest.id, deviceInfo.deviceIdentifier);
|
||||
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||
}
|
||||
|
||||
export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const authRequest = await storage.getAuthRequestById(id);
|
||||
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||
if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404);
|
||||
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||
}
|
||||
@@ -239,7 +333,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
const authRequest = await storage.getAuthRequestById(id);
|
||||
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||
if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) {
|
||||
return errorResponse('Not found', 404);
|
||||
}
|
||||
@@ -255,7 +349,6 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
|
||||
|
||||
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
|
||||
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
|
||||
const masterPasswordHash = normalizeText(readBodyValue(body, ['masterPasswordHash', 'MasterPasswordHash']), 20000) || null;
|
||||
const responseDeviceIdentifier =
|
||||
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
|
||||
readActingDeviceIdentifier(request) ||
|
||||
@@ -272,10 +365,10 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
|
||||
approved,
|
||||
responseDeviceIdentifier,
|
||||
key,
|
||||
masterPasswordHash,
|
||||
masterPasswordHash: null,
|
||||
});
|
||||
if (!updated) return errorResponse('Auth request has already been answered.', 409);
|
||||
const updatedRequest = await storage.getAuthRequestById(id);
|
||||
const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||
// Match Bitwarden upstream behavior: only approval wakes the originating anonymous
|
||||
// client. Denials are not pushed to avoid leaking that a login attempt was rejected.
|
||||
if (approved) {
|
||||
|
||||
+90
-16
@@ -2,8 +2,10 @@ import type { Env, User } from '../types';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import {
|
||||
type BackupArchiveBundle,
|
||||
MAX_BACKUP_ARCHIVE_BYTES,
|
||||
buildBackupArchive,
|
||||
inspectBackupArchiveFileNameChecksum,
|
||||
isSafeBackupAttachmentBlobName,
|
||||
parseBackupArchive,
|
||||
verifyBackupArchiveFileNameChecksum,
|
||||
} from '../services/backup-archive';
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
loadBackupSettings,
|
||||
normalizeBackupSettingsInput,
|
||||
normalizeImportedBackupSettings,
|
||||
redactBackupSettingsSecrets,
|
||||
repairBackupSettings,
|
||||
requireBackupDestination,
|
||||
saveBackupSettings,
|
||||
@@ -45,6 +48,7 @@ import { AuthService } from '../services/auth';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
import { getBlobObject } from '../services/blob-store';
|
||||
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
||||
import { getMultipartRequestMaxBytes } from '../utils/direct-upload';
|
||||
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
@@ -52,6 +56,14 @@ function isAdmin(user: User): boolean {
|
||||
return user.role === 'admin' && user.status === 'active';
|
||||
}
|
||||
|
||||
function parseRequestContentLength(request: Request): number | null {
|
||||
const raw = request.headers.get('content-length');
|
||||
if (!raw) return null;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value < 0) return null;
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> {
|
||||
const normalized = String(masterPasswordHash || '').trim();
|
||||
if (!normalized) {
|
||||
@@ -129,11 +141,18 @@ function ensureBackupBlobName(value: string): string {
|
||||
if (!normalized) {
|
||||
throw new Error('Backup attachment blob is required');
|
||||
}
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
if (!parts.length || parts.some((part) => part === '.' || part === '..')) {
|
||||
if (!isSafeBackupAttachmentBlobName(normalized)) {
|
||||
throw new Error('Backup attachment blob is invalid');
|
||||
}
|
||||
return parts.join('/');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function contentDispositionBackup(fileName: string | null | undefined): string {
|
||||
const fallback = 'nodewarden_backup.zip';
|
||||
const value = String(fileName || fallback)
|
||||
.replace(/[\\/\r\n"]/g, '_')
|
||||
.trim() || fallback;
|
||||
return `attachment; filename="${value}"`;
|
||||
}
|
||||
|
||||
const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json';
|
||||
@@ -654,6 +673,7 @@ function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): str
|
||||
if (parsed.files[inlinePath]) continue;
|
||||
const ref = refs.get(`${cipherId}/${attachmentId}`);
|
||||
const blobName = String(ref?.blobName || '').trim();
|
||||
if (!isSafeBackupAttachmentBlobName(blobName)) continue;
|
||||
if (blobName && !seen.has(blobName)) {
|
||||
seen.add(blobName);
|
||||
names.push(blobName);
|
||||
@@ -666,6 +686,7 @@ function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): str
|
||||
function toImportStatusCode(message: string): number {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes('checksum')) return 400;
|
||||
if (lower.includes('invalid remote backup path') || lower.includes('please select a backup zip file')) return 409;
|
||||
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
|
||||
if (lower.includes('fresh instance')) return 409;
|
||||
if (lower.includes('not configured') || lower.includes('kv')) return 409;
|
||||
@@ -849,7 +870,7 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
|
||||
const storage = new StorageService(env.DB);
|
||||
try {
|
||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||
return jsonResponse(settings);
|
||||
return jsonResponse(redactBackupSettingsSecrets(settings));
|
||||
} catch (error) {
|
||||
return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409);
|
||||
}
|
||||
@@ -888,7 +909,7 @@ export async function handleUpdateAdminBackupSettings(request: Request, env: Env
|
||||
destinationCount: next.destinations.length,
|
||||
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
||||
}, request);
|
||||
return jsonResponse(next);
|
||||
return jsonResponse(redactBackupSettingsSecrets(next));
|
||||
}
|
||||
|
||||
export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||
@@ -941,7 +962,7 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env
|
||||
destinationCount: next.destinations.length,
|
||||
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
||||
}, request);
|
||||
return jsonResponse(next);
|
||||
return jsonResponse(redactBackupSettingsSecrets(next));
|
||||
}
|
||||
|
||||
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||
@@ -978,7 +999,7 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
|
||||
provider: outcome.result.provider,
|
||||
remotePath: outcome.result.remotePath,
|
||||
},
|
||||
settings: outcome.settings,
|
||||
settings: redactBackupSettingsSecrets(outcome.settings),
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
|
||||
@@ -1028,8 +1049,9 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': remoteFile.contentType || 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${remoteFile.fileName}"`,
|
||||
'Content-Disposition': contentDispositionBackup(remoteFile.fileName),
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1040,12 +1062,21 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
|
||||
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||
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);
|
||||
try {
|
||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||
const url = new URL(request.url);
|
||||
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
||||
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
||||
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||
return jsonResponse({
|
||||
@@ -1063,12 +1094,21 @@ export async function handleInspectAdminRemoteBackup(request: Request, env: Env,
|
||||
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||
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 delete payload is invalid', 400);
|
||||
}
|
||||
|
||||
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
|
||||
if (verificationError) return verificationError;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
try {
|
||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||
const url = new URL(request.url);
|
||||
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
||||
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
||||
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||
await deleteRemoteBackupFile(destination, path);
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
|
||||
...getBackupDestinationSummary(destination),
|
||||
@@ -1196,8 +1236,9 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${archive.fileName}"`,
|
||||
'Content-Disposition': contentDispositionBackup(archive.fileName),
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1207,7 +1248,28 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
|
||||
|
||||
try {
|
||||
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);
|
||||
if (!object) {
|
||||
return errorResponse('Backup attachment blob not found', 404);
|
||||
@@ -1228,6 +1290,15 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
|
||||
export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||
|
||||
const contentType = request.headers.get('Content-Type') || '';
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
return errorResponse('Content-Type must be multipart/form-data', 400);
|
||||
}
|
||||
const declaredSize = parseRequestContentLength(request);
|
||||
if (declaredSize !== null && declaredSize > getMultipartRequestMaxBytes(MAX_BACKUP_ARCHIVE_BYTES)) {
|
||||
return errorResponse(`Backup file too large. Maximum size is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))}MB`, 413);
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
@@ -1239,6 +1310,9 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
|
||||
if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
|
||||
return errorResponse('Backup file is required', 400);
|
||||
}
|
||||
if ('size' in file && typeof (file as File).size === 'number' && (file as File).size > MAX_BACKUP_ARCHIVE_BYTES) {
|
||||
return errorResponse(`Backup file too large. Maximum size is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))}MB`, 413);
|
||||
}
|
||||
|
||||
const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env);
|
||||
if (verificationError) return verificationError;
|
||||
|
||||
+129
-13
@@ -7,6 +7,9 @@ import {
|
||||
CipherResponse,
|
||||
CipherSecureNote,
|
||||
CipherSshKey,
|
||||
CipherBankAccount,
|
||||
CipherDriversLicense,
|
||||
CipherPassport,
|
||||
Attachment,
|
||||
PasswordHistory,
|
||||
} from '../types';
|
||||
@@ -254,6 +257,49 @@ function sanitizeEncryptedObject<T extends Record<string, any>>(
|
||||
return next as T;
|
||||
}
|
||||
|
||||
const BANK_ACCOUNT_ENCRYPTED_KEYS = [
|
||||
'bankName',
|
||||
'nameOnAccount',
|
||||
'accountType',
|
||||
'accountNumber',
|
||||
'routingNumber',
|
||||
'branchNumber',
|
||||
'pin',
|
||||
'swiftCode',
|
||||
'iban',
|
||||
'bankContactPhone',
|
||||
] as const;
|
||||
|
||||
const DRIVERS_LICENSE_ENCRYPTED_KEYS = [
|
||||
'firstName',
|
||||
'middleName',
|
||||
'lastName',
|
||||
'dateOfBirth',
|
||||
'licenseNumber',
|
||||
'issuingCountry',
|
||||
'issuingState',
|
||||
'issueDate',
|
||||
'expirationDate',
|
||||
'issuingAuthority',
|
||||
'licenseClass',
|
||||
] as const;
|
||||
|
||||
const PASSPORT_ENCRYPTED_KEYS = [
|
||||
'surname',
|
||||
'givenName',
|
||||
'dateOfBirth',
|
||||
'sex',
|
||||
'birthPlace',
|
||||
'nationality',
|
||||
'issuingCountry',
|
||||
'passportNumber',
|
||||
'passportType',
|
||||
'nationalIdentificationNumber',
|
||||
'issuingAuthority',
|
||||
'issueDate',
|
||||
'expirationDate',
|
||||
] as const;
|
||||
|
||||
function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||
@@ -354,6 +400,48 @@ export function validateCipherEncryptedFieldsForCompatibility(cipher: Cipher): s
|
||||
if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate FIDO2 credentials — all encrypted-string fields, both required and optional, must be valid.
|
||||
if (Array.isArray(login.fido2Credentials)) {
|
||||
const fido2EncryptedKeys = ['credentialId', 'keyType', 'keyAlgorithm', 'keyCurve', 'keyValue', 'rpId', 'counter', 'discoverable', 'userHandle', 'userName', 'rpName', 'userDisplayName'];
|
||||
for (const cred of login.fido2Credentials) {
|
||||
if (!cred || typeof cred !== 'object') continue;
|
||||
for (const key of fido2EncryptedKeys) {
|
||||
if (cred[key] != null && !isValidEncString(cred[key])) return `FIDO2 credential ${key} must be an encrypted string.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate SSH key fields — all three must be encrypted strings.
|
||||
const sshKey = cipher.sshKey as any;
|
||||
if (sshKey && typeof sshKey === 'object') {
|
||||
if (sshKey.privateKey != null && !isValidEncString(sshKey.privateKey)) return 'SSH key private key must be an encrypted string.';
|
||||
if (sshKey.publicKey != null && !isValidEncString(sshKey.publicKey)) return 'SSH key public key must be an encrypted string.';
|
||||
const fingerprint = sshKey.keyFingerprint ?? sshKey.fingerprint;
|
||||
if (fingerprint != null && !isValidEncString(fingerprint)) return 'SSH key fingerprint must be an encrypted string.';
|
||||
}
|
||||
|
||||
const typedEncryptedObjects: Array<[string, any, readonly string[]]> = [
|
||||
['Bank account', (cipher as any).bankAccount, BANK_ACCOUNT_ENCRYPTED_KEYS],
|
||||
['Drivers license', (cipher as any).driversLicense, DRIVERS_LICENSE_ENCRYPTED_KEYS],
|
||||
['Passport', (cipher as any).passport, PASSPORT_ENCRYPTED_KEYS],
|
||||
];
|
||||
for (const [label, source, keys] of typedEncryptedObjects) {
|
||||
if (!source || typeof source !== 'object') continue;
|
||||
for (const key of keys) {
|
||||
if (source[key] != null && !optionalEncStringWithin(source[key], 10000)) {
|
||||
return `${label} ${key} must be an encrypted string.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate password history — each password must be an encrypted string.
|
||||
if (Array.isArray(cipher.passwordHistory)) {
|
||||
for (const entry of cipher.passwordHistory) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
if (entry.password != null && !isValidEncString(entry.password)) return 'Password history entry must be an encrypted string.';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -724,7 +812,20 @@ export function cipherToResponse(
|
||||
'licenseNumber',
|
||||
]);
|
||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||
const normalizedSecureNote = Number(cipher.type) === 2
|
||||
const normalizedBankAccount = sanitizeEncryptedObject(
|
||||
(passthrough as any).bankAccount ?? null,
|
||||
BANK_ACCOUNT_ENCRYPTED_KEYS
|
||||
);
|
||||
const normalizedDriversLicense = sanitizeEncryptedObject(
|
||||
(passthrough as any).driversLicense ?? null,
|
||||
DRIVERS_LICENSE_ENCRYPTED_KEYS
|
||||
);
|
||||
const normalizedPassport = sanitizeEncryptedObject(
|
||||
(passthrough as any).passport ?? null,
|
||||
PASSPORT_ENCRYPTED_KEYS
|
||||
);
|
||||
const responseType = Number(cipher.type) || 1;
|
||||
const normalizedSecureNote = responseType === 2
|
||||
? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 }
|
||||
: null;
|
||||
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
|
||||
@@ -735,7 +836,7 @@ export function cipherToResponse(
|
||||
...passthrough,
|
||||
// Server-computed / enforced fields (always override)
|
||||
folderId: normalizeResponseFolderId(cipher.folderId, options.validFolderIds),
|
||||
type: Number(cipher.type) || 1,
|
||||
type: responseType,
|
||||
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
|
||||
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
|
||||
creationDate: createdAt,
|
||||
@@ -757,6 +858,9 @@ export function cipherToResponse(
|
||||
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
|
||||
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
|
||||
sshKey: normalizedSshKey,
|
||||
bankAccount: responseType === 6 ? normalizedBankAccount : null,
|
||||
driversLicense: responseType === 7 ? normalizedDriversLicense : null,
|
||||
passport: responseType === 8 ? normalizedPassport : null,
|
||||
key: responseCipherKey,
|
||||
data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null,
|
||||
encryptedFor: (passthrough as any).encryptedFor ?? null,
|
||||
@@ -812,7 +916,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
||||
// GET /api/ciphers/:id
|
||||
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -827,8 +931,8 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
|
||||
|
||||
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
|
||||
if (!folderId) return true;
|
||||
const folder = await storage.getFolder(folderId);
|
||||
return !!(folder && folder.userId === userId);
|
||||
const folder = await storage.getFolderForUser(folderId, userId);
|
||||
return !!folder;
|
||||
}
|
||||
|
||||
// POST /api/ciphers
|
||||
@@ -852,6 +956,9 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
||||
const createBankAccount = readCipherProp<CipherBankAccount | null>(cipherData, ['bankAccount', 'BankAccount']);
|
||||
const createDriversLicense = readCipherProp<CipherDriversLicense | null>(cipherData, ['driversLicense', 'DriversLicense']);
|
||||
const createPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
|
||||
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||
|
||||
if (createKey.present && !shouldAcceptCipherKey(createKey.value)) {
|
||||
@@ -881,6 +988,9 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
|
||||
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
|
||||
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null);
|
||||
cipher.bankAccount = createBankAccount.present ? (createBankAccount.value ?? null) : ((cipher as any).bankAccount ?? null);
|
||||
cipher.driversLicense = createDriversLicense.present ? (createDriversLicense.value ?? null) : ((cipher as any).driversLicense ?? null);
|
||||
cipher.passport = createPassport.present ? (createPassport.value ?? null) : ((cipher as any).passport ?? null);
|
||||
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
|
||||
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
||||
@@ -909,7 +1019,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
// PUT /api/ciphers/:id
|
||||
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const existingCipher = await storage.getCipher(id);
|
||||
const existingCipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!existingCipher || existingCipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -932,6 +1042,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
||||
const incomingBankAccount = readCipherProp<CipherBankAccount | null>(cipherData, ['bankAccount', 'BankAccount']);
|
||||
const incomingDriversLicense = readCipherProp<CipherDriversLicense | null>(cipherData, ['driversLicense', 'DriversLicense']);
|
||||
const incomingPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
|
||||
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
||||
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
|
||||
@@ -980,6 +1093,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null;
|
||||
cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null;
|
||||
cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null;
|
||||
cipher.bankAccount = nextType === 6 ? (incomingBankAccount.present ? (incomingBankAccount.value ?? null) : ((existingCipher as any).bankAccount ?? null)) : null;
|
||||
cipher.driversLicense = nextType === 7 ? (incomingDriversLicense.present ? (incomingDriversLicense.value ?? null) : ((existingCipher as any).driversLicense ?? null)) : null;
|
||||
cipher.passport = nextType === 8 ? (incomingPassport.present ? (incomingPassport.value ?? null) : ((existingCipher as any).passport ?? null)) : null;
|
||||
if (incomingPasswordHistory.present) {
|
||||
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
|
||||
}
|
||||
@@ -1020,7 +1136,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
// DELETE /api/ciphers/:id
|
||||
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1052,7 +1168,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
||||
// - If item is already soft-deleted -> hard delete.
|
||||
export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1079,7 +1195,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
|
||||
// DELETE /api/ciphers/:id (permanent)
|
||||
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1104,7 +1220,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
|
||||
// PUT /api/ciphers/:id/restore
|
||||
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1126,7 +1242,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
||||
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
||||
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1218,7 +1334,7 @@ function parseCipherIdList(body: { ids?: unknown }): string[] | null {
|
||||
// PUT/POST /api/ciphers/:id/archive
|
||||
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1244,7 +1360,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
|
||||
// PUT/POST /api/ciphers/:id/unarchive
|
||||
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
|
||||
+80
-1
@@ -6,7 +6,7 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
|
||||
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { readKnownDeviceProbe } from '../utils/device';
|
||||
import { readAuthRequestDeviceInfo, readKnownDeviceProbe } from '../utils/device';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
|
||||
const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59);
|
||||
@@ -125,6 +125,85 @@ function parseDeviceName(value: unknown): string {
|
||||
return String(value || '').trim().slice(0, 128);
|
||||
}
|
||||
|
||||
function parseDeviceType(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
|
||||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
// POST /api/devices
|
||||
export async function handleRegisterDevice(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
const identifier = normalizeIdentifier(body.identifier ?? body.Identifier ?? body.deviceIdentifier ?? body.DeviceIdentifier);
|
||||
const name = parseDeviceName(body.name ?? body.Name ?? body.deviceName ?? body.DeviceName) || 'Unknown device';
|
||||
const type = parseDeviceType(body.type ?? body.Type ?? body.deviceType ?? body.DeviceType);
|
||||
if (!identifier || type == null) return errorResponse('Device identifier and type are required', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
await storage.upsertDevice(userId, identifier, name, type, undefined, parseKeysBody(body));
|
||||
|
||||
const pushToken = String(body.pushToken ?? body.PushToken ?? '').trim();
|
||||
if (pushToken) {
|
||||
const device = await storage.getDevice(userId, identifier);
|
||||
const pushUuid = device?.pushUuid || generateUUID();
|
||||
const updated = await storage.updateDevicePushToken(userId, identifier, pushUuid, pushToken);
|
||||
if (updated) {
|
||||
await registerMobilePushDevice(env, {
|
||||
userId,
|
||||
deviceIdentifier: identifier,
|
||||
type,
|
||||
pushUuid,
|
||||
pushToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const device = await storage.getDevice(userId, identifier);
|
||||
if (!device) return errorResponse('Device registration failed', 500);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.register',
|
||||
category: 'device',
|
||||
level: 'info',
|
||||
targetType: 'device',
|
||||
targetId: identifier,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
return jsonResponse(buildDeviceResponse(device));
|
||||
}
|
||||
|
||||
// POST /api/devices/lost-trust
|
||||
export async function handleReportLostTrust(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const body = await readJsonBody(request) || {};
|
||||
const deviceInfo = readAuthRequestDeviceInfo(
|
||||
{
|
||||
deviceIdentifier: String(body.identifier ?? body.Identifier ?? body.deviceIdentifier ?? body.DeviceIdentifier ?? ''),
|
||||
deviceName: String(body.name ?? body.Name ?? body.deviceName ?? body.DeviceName ?? ''),
|
||||
deviceType: String(body.type ?? body.Type ?? body.deviceType ?? body.DeviceType ?? ''),
|
||||
},
|
||||
request
|
||||
);
|
||||
if (!deviceInfo.deviceIdentifier) return errorResponse('Please provide a device identifier', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.lost_trust',
|
||||
category: 'device',
|
||||
level: 'warn',
|
||||
targetType: 'device',
|
||||
targetId: deviceInfo.deviceIdentifier,
|
||||
metadata: {
|
||||
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
deviceType: deviceInfo.deviceType,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
// GET /api/devices/knowndevice
|
||||
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||
// - X-Request-Email: base64url(email) without padding
|
||||
|
||||
@@ -1,19 +1,46 @@
|
||||
const EMPTY_FORMS_FILENAME = 'forms.v1.json';
|
||||
const EMPTY_FORMS_SCHEMA_FILENAME = 'forms.v1.schema.json';
|
||||
const EMPTY_FORMS_CID = 'sha256:189fa7c9bcf8951e65c18b5d9feacf74a5223c75e01667c4235388cbc67091fe';
|
||||
|
||||
const EMPTY_FORMS_BODY = JSON.stringify({
|
||||
schemaVersion: '1.0.0',
|
||||
hosts: {},
|
||||
});
|
||||
|
||||
const EMPTY_FORMS_SCHEMA_BODY = JSON.stringify({
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
title: 'Bitwarden Fill Assist Forms v1',
|
||||
type: 'object',
|
||||
required: ['schemaVersion', 'hosts'],
|
||||
properties: {
|
||||
schemaVersion: { type: 'string' },
|
||||
hosts: { type: 'object' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
});
|
||||
|
||||
const EMPTY_MANIFEST_BODY = JSON.stringify({
|
||||
buildId: 'nodewarden-empty-fill-assist-v1',
|
||||
timestamp: '2026-07-06T00:00:00.000Z',
|
||||
gitSha: 'nodewarden',
|
||||
maps: {
|
||||
forms: {
|
||||
v1: {
|
||||
filename: EMPTY_FORMS_FILENAME,
|
||||
cid: 'sha256:nodewarden-empty-fill-assist-v1',
|
||||
cid: EMPTY_FORMS_CID,
|
||||
schema: EMPTY_FORMS_SCHEMA_FILENAME,
|
||||
deprecated: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const DIGITAL_ASSET_LINK_CHECK_BODY = JSON.stringify({
|
||||
linked: false,
|
||||
maxAge: '86400s',
|
||||
debugString: 'No matching digital asset link policy is configured for this server.',
|
||||
});
|
||||
|
||||
function fillAssistJsonResponse(body: string): Response {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
@@ -24,13 +51,30 @@ function fillAssistJsonResponse(body: string): Response {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeFilename(filename: string): string {
|
||||
const raw = String(filename || '').trim();
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleFillAssistManifest(): Response {
|
||||
return fillAssistJsonResponse(EMPTY_MANIFEST_BODY);
|
||||
}
|
||||
|
||||
export function handleFillAssistForms(filename: string): Response {
|
||||
if (String(filename || '').trim() !== EMPTY_FORMS_FILENAME) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
const normalized = normalizeFilename(filename);
|
||||
if (normalized === EMPTY_FORMS_FILENAME) {
|
||||
return fillAssistJsonResponse(EMPTY_FORMS_BODY);
|
||||
}
|
||||
return fillAssistJsonResponse(EMPTY_FORMS_BODY);
|
||||
if (normalized === EMPTY_FORMS_SCHEMA_FILENAME) {
|
||||
return fillAssistJsonResponse(EMPTY_FORMS_SCHEMA_BODY);
|
||||
}
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
export function handleDigitalAssetLinkCheck(): Response {
|
||||
return fillAssistJsonResponse(DIGITAL_ASSET_LINK_CHECK_BODY);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function handleGetFolders(request: Request, env: Env, userId: strin
|
||||
// GET /api/folders/:id
|
||||
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const folder = await storage.getFolder(id);
|
||||
const folder = await storage.getFolderForUser(id, userId);
|
||||
|
||||
if (!folder || folder.userId !== userId) {
|
||||
return errorResponse('Folder not found', 404);
|
||||
@@ -129,7 +129,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
|
||||
// PUT /api/folders/:id
|
||||
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const folder = await storage.getFolder(id);
|
||||
const folder = await storage.getFolderForUser(id, userId);
|
||||
|
||||
if (!folder || folder.userId !== userId) {
|
||||
return errorResponse('Folder not found', 404);
|
||||
@@ -163,7 +163,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
|
||||
// DELETE /api/folders/:id
|
||||
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const folder = await storage.getFolder(id);
|
||||
const folder = await storage.getFolderForUser(id, userId);
|
||||
|
||||
if (!folder || folder.userId !== userId) {
|
||||
return errorResponse('Folder not found', 404);
|
||||
@@ -204,8 +204,8 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
|
||||
|
||||
const folders = (
|
||||
await Promise.all(ids.map(async (id) => {
|
||||
const folder = await storage.getFolder(id);
|
||||
return folder && folder.userId === userId ? folder : null;
|
||||
const folder = await storage.getFolderForUser(id, userId);
|
||||
return folder;
|
||||
}))
|
||||
).filter((folder): folder is Folder => !!folder);
|
||||
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
||||
|
||||
+132
-29
@@ -1,4 +1,4 @@
|
||||
import { Env, TokenResponse } from '../types';
|
||||
import { Env, TokenResponse, User } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
@@ -18,16 +18,24 @@ import {
|
||||
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||
import {
|
||||
assertAccountPasskeyCredential,
|
||||
assertTwoFactorPasskeyCredential,
|
||||
buildAccountPasskeyTokenUserDecryptionOption,
|
||||
buildTwoFactorPasskeyAssertionOptions,
|
||||
} from './account-passkeys';
|
||||
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
|
||||
import { constantTimeEquals, verifyApiKey } from '../utils/api-key';
|
||||
import { isYubiKeyEnabled, userYubiKeyPublicIds, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
|
||||
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
|
||||
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
|
||||
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
|
||||
// Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows
|
||||
// the official Identity provider enum (RecoveryCode = 8), while request parsing remains
|
||||
// compatible with older/local provider values.
|
||||
@@ -106,18 +114,6 @@ function parseCookieValue(request: Request, name: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function constantTimeEquals(a: string, b: string): boolean {
|
||||
const encA = new TextEncoder().encode(a);
|
||||
const encB = new TextEncoder().encode(b);
|
||||
if (encA.length !== encB.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < encA.length; i++) {
|
||||
diff |= encA[i] ^ encB[i];
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function readBodyValue(body: Record<string, string>, names: string[]): string | undefined {
|
||||
for (const name of names) {
|
||||
const value = body[name];
|
||||
@@ -126,6 +122,25 @@ function readBodyValue(body: Record<string, string>, names: string[]): string |
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function sha256Hex(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function loginRateLimitKey(clientIdentifier: string, grantType: string, subject: string): Promise<string> {
|
||||
const subjectHash = await sha256Hex(`${grantType}:${String(subject || '').trim() || 'unknown'}`);
|
||||
return `${clientIdentifier}:login:${grantType}:${subjectHash}`;
|
||||
}
|
||||
|
||||
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
|
||||
const fromEnv = yubicoCredentialsFromEnv(env);
|
||||
if (fromEnv) return fromEnv;
|
||||
const clientId = String(await storage.getConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY) || '').trim();
|
||||
if (!clientId) return null;
|
||||
const secretKey = String(await storage.getConfigValue(YUBICO_KEY_CONFIG_KEY) || '').trim();
|
||||
return { clientId, secretKey };
|
||||
}
|
||||
|
||||
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
||||
const isHttps = new URL(request.url).protocol === 'https:';
|
||||
const parts = [
|
||||
@@ -158,6 +173,30 @@ 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(
|
||||
email: string,
|
||||
kdfType: number,
|
||||
@@ -194,13 +233,32 @@ function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] {
|
||||
};
|
||||
}
|
||||
|
||||
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
|
||||
async function twoFactorRequiredResponse(
|
||||
request: Request,
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
user?: User,
|
||||
message: string = 'Two factor required.'
|
||||
): Promise<Response> {
|
||||
// Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
|
||||
// Clients expose recovery-code entry points themselves; Android 2026.4 fails to
|
||||
// parse the challenge if an unknown recovery provider key such as "8" is included.
|
||||
const providers = [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
|
||||
const providers2: Record<string, { Email: null }> = {};
|
||||
for (const provider of providers) providers2[provider] = { Email: null };
|
||||
const providers: string[] = [];
|
||||
let webAuthnOptions: Record<string, unknown> | null = null;
|
||||
if (!user || resolveTotpSecret(user.totpSecret)) providers.push(String(TWO_FACTOR_PROVIDER_AUTHENTICATOR));
|
||||
if (user && isYubiKeyEnabled(user)) providers.push(String(TWO_FACTOR_PROVIDER_YUBIKEY));
|
||||
if (user) {
|
||||
webAuthnOptions = await buildTwoFactorPasskeyAssertionOptions(request, env, storage, user) as Record<string, unknown> | null;
|
||||
if (webAuthnOptions) providers.push(String(TWO_FACTOR_PROVIDER_WEBAUTHN));
|
||||
}
|
||||
const providers2: Record<string, Record<string, unknown> | null> = {};
|
||||
for (const provider of providers) {
|
||||
providers2[provider] = provider === String(TWO_FACTOR_PROVIDER_YUBIKEY)
|
||||
? { Nfc: user?.yubikeyNfc ?? false }
|
||||
: provider === String(TWO_FACTOR_PROVIDER_WEBAUTHN) && webAuthnOptions
|
||||
? webAuthnOptions
|
||||
: null;
|
||||
}
|
||||
const customResponse = {
|
||||
TwoFactorProviders: providers,
|
||||
TwoFactorProviders2: providers2,
|
||||
@@ -295,13 +353,13 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']);
|
||||
const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']);
|
||||
const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
|
||||
const loginIdentifier = clientIdentifier;
|
||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||
|
||||
if (!email || !passwordHash) {
|
||||
// Bitwarden clients expect OAuth-style error fields.
|
||||
return identityErrorResponse('Email and password are required', 'invalid_request', 400);
|
||||
}
|
||||
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, email);
|
||||
|
||||
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||
@@ -341,7 +399,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
let valid = false;
|
||||
const normalizedAuthRequestId = String(authRequestId || '').trim();
|
||||
if (normalizedAuthRequestId) {
|
||||
const authRequest = await storage.getAuthRequestById(normalizedAuthRequestId);
|
||||
const authRequest = await storage.getAuthRequestByIdForUser(normalizedAuthRequestId, user.id);
|
||||
valid = !!(
|
||||
authRequest &&
|
||||
authRequest.userId === user.id &&
|
||||
@@ -381,10 +439,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
);
|
||||
}
|
||||
|
||||
// Optional 2FA: enabled only by per-user secret.
|
||||
// Optional 2FA: enabled by any supported per-user provider.
|
||||
let trustedTwoFactorTokenToReturn: string | undefined;
|
||||
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
|
||||
if (effectiveTotpSecret) {
|
||||
const effectiveYubiKeyPublicIds = userYubiKeyPublicIds(user);
|
||||
const effectiveWebAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
if (effectiveTotpSecret || effectiveYubiKeyPublicIds.length > 0 || effectiveWebAuthnCredentials.length > 0) {
|
||||
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
|
||||
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
|
||||
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
|
||||
@@ -394,7 +454,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
|
||||
// respond with a 2FA challenge payload.
|
||||
if (!hasProvider || !hasToken) {
|
||||
return twoFactorRequiredResponse('Two factor required.');
|
||||
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
|
||||
}
|
||||
|
||||
let passedByRememberToken = false;
|
||||
@@ -409,9 +469,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
|
||||
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
|
||||
if (!passedByRememberToken) {
|
||||
return twoFactorRequiredResponse('Two factor required.');
|
||||
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
|
||||
}
|
||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
||||
if (!effectiveTotpSecret) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken);
|
||||
if (matchedCounter == null) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
@@ -420,6 +483,30 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
if (!consumed) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_YUBIKEY)) {
|
||||
const publicId = yubiKeyPublicIdFromOtp(normalizedTwoFactorToken);
|
||||
if (!publicId || !effectiveYubiKeyPublicIds.includes(publicId)) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
const credentials = await getStoredYubicoCredentials(storage, env);
|
||||
if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_WEBAUTHN)) {
|
||||
if (!effectiveWebAuthnCredentials.length) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
let deviceResponse: unknown;
|
||||
try {
|
||||
deviceResponse = JSON.parse(normalizedTwoFactorToken);
|
||||
} catch {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
try {
|
||||
await assertTwoFactorPasskeyCredential(request, env, storage, user, deviceResponse);
|
||||
} catch {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
} else if (
|
||||
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
|
||||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) ||
|
||||
@@ -429,10 +516,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
user.totpSecret = null;
|
||||
user.yubikeyKey1 = null;
|
||||
user.yubikeyKey2 = null;
|
||||
user.yubikeyKey3 = null;
|
||||
user.yubikeyKey4 = null;
|
||||
user.yubikeyKey5 = null;
|
||||
user.yubikeyNfc = false;
|
||||
for (const credential of effectiveWebAuthnCredentials) {
|
||||
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
|
||||
}
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
user.securityStamp = generateUUID();
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
rememberRequested = false;
|
||||
} else {
|
||||
// Unsupported provider for this server profile behaves as an invalid 2FA attempt.
|
||||
@@ -520,7 +618,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
: baseResponse;
|
||||
|
||||
} else if (grantType === 'webauthn') {
|
||||
const loginIdentifier = clientIdentifier;
|
||||
const token = String(body.token || '').trim();
|
||||
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, token || 'missing-token');
|
||||
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||
if (!loginCheck.allowed) {
|
||||
return identityErrorResponse(
|
||||
@@ -530,7 +629,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
);
|
||||
}
|
||||
|
||||
const token = String(body.token || '').trim();
|
||||
let deviceResponse: unknown = body.deviceResponse;
|
||||
if (typeof deviceResponse === 'string') {
|
||||
try {
|
||||
@@ -648,11 +746,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
const scope = body.scope;
|
||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||
|
||||
const loginIdentifier = clientIdentifier;
|
||||
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
|
||||
if (!parmValid) {
|
||||
return identityErrorResponse('Parameter error', 'invalid_request', 400);
|
||||
}
|
||||
const uid = clientId.slice(5);
|
||||
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, uid);
|
||||
|
||||
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||
@@ -664,7 +763,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
);
|
||||
}
|
||||
|
||||
const uid = clientId.slice(5);
|
||||
const user = await storage.getUserById(uid);
|
||||
if (!user) {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
@@ -688,7 +786,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
|
||||
if (!user.apiKey || !(await verifyApiKey(clientSecret, user.apiKey))) {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
@@ -807,7 +905,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
passwordHashB64,
|
||||
password,
|
||||
rateLimit,
|
||||
`${clientIdentifier}:send-password`
|
||||
clientIdentifier
|
||||
);
|
||||
if ('error' in result) {
|
||||
return result.error;
|
||||
@@ -946,6 +1044,11 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
|
||||
// RFC 7009 allows returning 200 even if token is unknown.
|
||||
export async function handleRevocation(request: Request, env: Env): Promise<Response> {
|
||||
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>;
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
|
||||
+27
-7
@@ -17,6 +17,9 @@ interface CiphersImportRequest {
|
||||
favorite?: boolean;
|
||||
reprompt?: number;
|
||||
sshKey?: any | null;
|
||||
bankAccount?: any | null;
|
||||
driversLicense?: any | null;
|
||||
passport?: any | null;
|
||||
key?: string | null;
|
||||
login?: {
|
||||
uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
|
||||
@@ -92,6 +95,12 @@ function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T |
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeOptionalId(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const normalized = String(value).trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
|
||||
for (let i = 0; i < statements.length; i += chunkSize) {
|
||||
const chunk = statements.slice(i, i + chunkSize);
|
||||
@@ -112,9 +121,9 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const folders = importData.folders || [];
|
||||
const ciphers = importData.ciphers || [];
|
||||
const folderRelationships = importData.folderRelationships || [];
|
||||
const folders = Array.isArray(importData.folders) ? importData.folders : [];
|
||||
const ciphers = Array.isArray(importData.ciphers) ? importData.ciphers : [];
|
||||
const folderRelationships = Array.isArray(importData.folderRelationships) ? importData.folderRelationships : [];
|
||||
|
||||
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
|
||||
return errorResponse(`Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`, 400);
|
||||
@@ -128,13 +137,14 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
const folderRows: Folder[] = [];
|
||||
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
const importedFolder = folders[i] && typeof folders[i] === 'object' ? folders[i] : null;
|
||||
const folderId = generateUUID();
|
||||
folderIdMap.set(i, folderId);
|
||||
|
||||
const folder: Folder = {
|
||||
id: folderId,
|
||||
userId: userId,
|
||||
name: folders[i].name,
|
||||
name: typeof importedFolder?.name === 'string' && importedFolder.name ? importedFolder.name : 'Folder',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -157,24 +167,31 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
// Build cipher index -> folder id mapping from relationships
|
||||
const cipherFolderMap = new Map<number, string>();
|
||||
for (const rel of folderRelationships) {
|
||||
if (!rel || typeof rel !== 'object') continue;
|
||||
const folderId = folderIdMap.get(rel.value);
|
||||
if (folderId) {
|
||||
cipherFolderMap.set(rel.key, folderId);
|
||||
}
|
||||
}
|
||||
const existingFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
|
||||
|
||||
// Create ciphers
|
||||
const cipherRows: Cipher[] = [];
|
||||
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
||||
for (let i = 0; i < ciphers.length; i++) {
|
||||
const c = ciphers[i];
|
||||
const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null;
|
||||
const c = ciphers[i] && typeof ciphers[i] === 'object' ? ciphers[i] : {} as CiphersImportRequest['ciphers'][number];
|
||||
const importedFolderId = normalizeOptionalId(readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']));
|
||||
const folderId = cipherFolderMap.get(i) || (importedFolderId && existingFolderIds.has(importedFolderId) ? importedFolderId : null);
|
||||
const sourceIdRaw = String(c?.id ?? '').trim();
|
||||
const sourceId = sourceIdRaw || null;
|
||||
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
|
||||
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
|
||||
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
|
||||
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
|
||||
const sshKey = readAliasedImportProp<any | null>(c, ['sshKey', 'SshKey']);
|
||||
const bankAccount = readAliasedImportProp<any | null>(c, ['bankAccount', 'BankAccount']);
|
||||
const driversLicense = readAliasedImportProp<any | null>(c, ['driversLicense', 'DriversLicense']);
|
||||
const passport = readAliasedImportProp<any | null>(c, ['passport', 'Passport']);
|
||||
const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
|
||||
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
|
||||
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
|
||||
@@ -244,7 +261,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
})) || null,
|
||||
passwordHistory: passwordHistory ?? null,
|
||||
reprompt: c.reprompt ?? 0,
|
||||
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
|
||||
sshKey: normalizeCipherSshKeyForCompatibility(sshKey ?? null),
|
||||
bankAccount: bankAccount ?? null,
|
||||
driversLicense: driversLicense ?? null,
|
||||
passport: passport ?? null,
|
||||
key: key ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AuthService } from '../services/auth';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||
import type { Env, JWTPayload } from '../types';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
@@ -65,6 +67,12 @@ export async function handleAnonymousNotificationsHub(request: Request, env: Env
|
||||
return errorResponse('Expected websocket', 426);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const authRequest = await storage.getAuthRequestById(authRequestId);
|
||||
if (!authRequest || isAuthRequestExpired(authRequest)) {
|
||||
return errorResponse('Not found', 404);
|
||||
}
|
||||
|
||||
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
|
||||
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||
const forwardedUrl = new URL(request.url);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { LIMITS } from '../config/limits';
|
||||
import {
|
||||
getBlobStorageMaxBytes,
|
||||
getSendFileObjectKey,
|
||||
getBlobObject,
|
||||
putBlobObject,
|
||||
deleteBlobObject,
|
||||
} from '../services/blob-store';
|
||||
@@ -34,6 +35,8 @@ import {
|
||||
} from './sends-shared';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
|
||||
const SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE = 'Send email verification is not supported by this server.';
|
||||
|
||||
async function writeSendAudit(
|
||||
storage: StorageService,
|
||||
request: Request,
|
||||
@@ -82,8 +85,13 @@ async function processSendFileUpload(
|
||||
return upload;
|
||||
}
|
||||
|
||||
const path = getSendFileObjectKey(send.id, fileId);
|
||||
if (await getBlobObject(env, path)) {
|
||||
return errorResponse('Send file has already been uploaded', 409);
|
||||
}
|
||||
|
||||
try {
|
||||
await putBlobObject(env, getSendFileObjectKey(send.id, fileId), upload.body, {
|
||||
await putBlobObject(env, path, upload.body, {
|
||||
size: upload.size,
|
||||
contentType: upload.contentType,
|
||||
customMetadata: {
|
||||
@@ -134,7 +142,7 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
|
||||
export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
@@ -210,11 +218,17 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
|
||||
if (authTypeRaw.present && requestedAuthType === null) {
|
||||
return errorResponse('Invalid authType', 400);
|
||||
}
|
||||
if (requestedAuthType === SendAuthType.Email) {
|
||||
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||
}
|
||||
|
||||
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||
return errorResponse('Invalid emails', 400);
|
||||
}
|
||||
if (normalizedEmails) {
|
||||
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const send: Send = {
|
||||
@@ -334,11 +348,17 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
|
||||
if (authTypeRaw.present && requestedAuthType === null) {
|
||||
return errorResponse('Invalid authType', 400);
|
||||
}
|
||||
if (requestedAuthType === SendAuthType.Email) {
|
||||
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||
}
|
||||
|
||||
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||
return errorResponse('Invalid emails', 400);
|
||||
}
|
||||
if (normalizedEmails) {
|
||||
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const send: Send = {
|
||||
@@ -401,7 +421,7 @@ export async function handleGetSendFileUpload(
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
@@ -436,7 +456,7 @@ export async function handleUploadSendFile(
|
||||
fileId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found. Unable to save the file.', 404);
|
||||
}
|
||||
@@ -472,7 +492,7 @@ export async function handlePublicUploadSendFile(
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, claims.userId);
|
||||
if (!send || send.userId !== claims.userId) {
|
||||
return errorResponse('Send not found. Unable to save the file.', 404);
|
||||
}
|
||||
@@ -485,7 +505,7 @@ export async function handlePublicUploadSendFile(
|
||||
|
||||
export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
@@ -592,10 +612,11 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
|
||||
if (parsedAuthType === null) {
|
||||
return errorResponse('Invalid authType', 400);
|
||||
}
|
||||
send.authType = parsedAuthType;
|
||||
if (parsedAuthType !== SendAuthType.Email) {
|
||||
send.emails = null;
|
||||
if (parsedAuthType === SendAuthType.Email) {
|
||||
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||
}
|
||||
send.authType = parsedAuthType;
|
||||
send.emails = null;
|
||||
}
|
||||
|
||||
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
||||
@@ -604,10 +625,13 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
|
||||
if (emailsRaw.value !== null && normalizedEmails === null) {
|
||||
return errorResponse('Invalid emails', 400);
|
||||
}
|
||||
if (normalizedEmails) {
|
||||
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||
}
|
||||
send.emails = normalizedEmails;
|
||||
if (send.emails) {
|
||||
send.authType = SendAuthType.Email;
|
||||
} else if (send.authType === SendAuthType.Email) {
|
||||
} else if (Number(send.authType) === SendAuthType.Email) {
|
||||
send.authType = SendAuthType.None;
|
||||
}
|
||||
}
|
||||
@@ -632,7 +656,7 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
|
||||
|
||||
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
@@ -698,7 +722,7 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
|
||||
|
||||
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
@@ -719,7 +743,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
|
||||
|
||||
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { StorageService } from '../services/storage';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { sanitizeDownloadContentType } from '../utils/content-type';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import {
|
||||
createSendAccessToken,
|
||||
createSendFileDownloadToken,
|
||||
@@ -69,7 +68,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
|
||||
if (!clientIdentifier) {
|
||||
return errorResponse('Client IP is required', 403);
|
||||
}
|
||||
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
|
||||
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id);
|
||||
sendPasswordRateLimit = new RateLimitService(env.DB);
|
||||
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||
if (!sendPasswordCheck.allowed) {
|
||||
@@ -113,10 +112,9 @@ export async function handleAccessSendFile(
|
||||
idOrAccessId: string,
|
||||
fileId: string
|
||||
): Promise<Response> {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
|
||||
return errorResponse('Server configuration error', 500);
|
||||
}
|
||||
const safeSecret = getSafeJwtSecret(env);
|
||||
if (!safeSecret.ok) return safeSecret.response;
|
||||
const { secret } = safeSecret;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
|
||||
@@ -144,7 +142,7 @@ export async function handleAccessSendFile(
|
||||
if (!clientIdentifier) {
|
||||
return errorResponse('Client IP is required', 403);
|
||||
}
|
||||
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
|
||||
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id);
|
||||
sendPasswordRateLimit = new RateLimitService(env.DB);
|
||||
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||
if (!sendPasswordCheck.allowed) {
|
||||
@@ -292,19 +290,27 @@ export async function handleDownloadSendFile(
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
||||
if (!object) {
|
||||
return errorResponse('Send file not found', 404);
|
||||
}
|
||||
const send = await storage.getSend(sendId);
|
||||
const data = send ? parseStoredSendData(send) : {};
|
||||
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
|
||||
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
|
||||
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
const data = parseStoredSendData(send);
|
||||
const expectedFileId = typeof data.id === 'string' ? data.id : null;
|
||||
if (!expectedFileId || expectedFileId !== fileId) {
|
||||
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));
|
||||
if (!object) {
|
||||
return errorResponse('Send file not found', 404);
|
||||
}
|
||||
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
|
||||
|
||||
return new Response(object.body, {
|
||||
headers: {
|
||||
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||
@@ -322,7 +328,7 @@ export async function issueSendAccessToken(
|
||||
passwordHashB64?: string | null,
|
||||
password?: string | null,
|
||||
rateLimit?: RateLimitService,
|
||||
sendPasswordLimitIpKey?: string
|
||||
clientIdentifier?: string
|
||||
): Promise<{ token: string } | { error: Response }> {
|
||||
const jwt = getSafeJwtSecret(env);
|
||||
if (!jwt.ok) {
|
||||
@@ -362,11 +368,14 @@ export async function issueSendAccessToken(
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
400
|
||||
501
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const sendPasswordLimitIpKey =
|
||||
rateLimit && clientIdentifier ? sendPasswordLimitKey(clientIdentifier, send.id) : null;
|
||||
|
||||
if (send.passwordHash) {
|
||||
if (rateLimit && sendPasswordLimitIpKey) {
|
||||
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { Env, Send, SendAuthType, SendResponse, SendType } from '../types';
|
||||
import {
|
||||
notifyUserSendCreate,
|
||||
notifyUserSendDelete,
|
||||
@@ -371,7 +371,7 @@ export function hasEmailAuth(send: Send): boolean {
|
||||
|
||||
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
|
||||
return { ok: false, response: errorResponse('Server configuration error', 500) };
|
||||
}
|
||||
return { ok: true, secret };
|
||||
@@ -434,8 +434,8 @@ export type PublicSendAccessValidationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
|
||||
|
||||
export function sendPasswordLimitKey(clientIdentifier: string): string {
|
||||
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`;
|
||||
export function sendPasswordLimitKey(clientIdentifier: string, sendId: string): string {
|
||||
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}:${String(sendId || '').trim() || 'unknown-send'}`;
|
||||
}
|
||||
|
||||
function sendPasswordLockMessage(retryAfterSeconds: number): string {
|
||||
@@ -464,7 +464,11 @@ export function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Resp
|
||||
|
||||
export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> {
|
||||
if (hasEmailAuth(send)) {
|
||||
return { ok: false, response: errorResponse(SEND_INACCESSIBLE_MSG, 404), reason: 'email_auth_unsupported' };
|
||||
return {
|
||||
ok: false,
|
||||
response: errorResponse('Send email verification is not supported by this server.', 501),
|
||||
reason: 'email_auth_unsupported',
|
||||
};
|
||||
}
|
||||
|
||||
if (!send.passwordHash) return { ok: true };
|
||||
|
||||
+3
-3
@@ -89,7 +89,7 @@ export default {
|
||||
const normalizedRequest = normalizeRequestUrl(request);
|
||||
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
||||
if (assetResponse) {
|
||||
return applyCors(normalizedRequest, assetResponse);
|
||||
return applyCors(normalizedRequest, assetResponse, env);
|
||||
}
|
||||
|
||||
await ensureDatabaseInitialized(env);
|
||||
@@ -107,11 +107,11 @@ export default {
|
||||
},
|
||||
500
|
||||
);
|
||||
return applyCors(normalizedRequest, resp);
|
||||
return applyCors(normalizedRequest, resp, 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> {
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function handleAdminBackupRoute(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function handleAdminBackupRoute(
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,23 @@ import {
|
||||
handleAdminClearAuditLogs,
|
||||
} from './handlers/admin';
|
||||
import { handleAdminBackupRoute } from './router-admin-backup';
|
||||
import { errorResponse } from './utils/response';
|
||||
|
||||
function isKnownAdminPath(path: string): boolean {
|
||||
return (
|
||||
path === '/api/admin/users' ||
|
||||
path === '/api/admin/logs' ||
|
||||
path === '/api/admin/logs/settings' ||
|
||||
path === '/api/admin/invites' ||
|
||||
path.startsWith('/api/admin/backup') ||
|
||||
/^\/api\/admin\/invites\/[^/]+$/i.test(path) ||
|
||||
/^\/api\/admin\/users\/[a-f0-9-]+(?:\/status)?$/i.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
function isActiveAdmin(user: User): boolean {
|
||||
return user.role === 'admin' && user.status === 'active';
|
||||
}
|
||||
|
||||
export async function handleAdminRoute(
|
||||
request: Request,
|
||||
@@ -21,6 +38,13 @@ export async function handleAdminRoute(
|
||||
path: string,
|
||||
method: string
|
||||
): Promise<Response | null> {
|
||||
if (!isKnownAdminPath(path)) {
|
||||
return null;
|
||||
}
|
||||
if (!isActiveAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/users' && method === 'GET') {
|
||||
return handleAdminListUsers(request, env, actorUser);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Env, User } from './types';
|
||||
import { errorResponse, jsonResponse } from './utils/response';
|
||||
import { errorResponse, jsonResponse, unsupportedResponse } from './utils/response';
|
||||
import {
|
||||
handleGetProfile,
|
||||
handleUpdateProfile,
|
||||
@@ -15,6 +15,12 @@ import {
|
||||
handleGetTwoFactorProviders,
|
||||
handleGetTwoFactorAuthenticator,
|
||||
handlePutTwoFactorAuthenticator,
|
||||
handleGetTwoFactorYubiKey,
|
||||
handlePutTwoFactorYubiKey,
|
||||
handlePutTwoFactorYubiKeyConfig,
|
||||
handleBootstrapTwoFactorYubiKeyConfig,
|
||||
handleGetDeviceVerificationSettings,
|
||||
handlePutDeviceVerificationSettings,
|
||||
handleDisableTwoFactorProvider,
|
||||
handleGetApiKey,
|
||||
handleRotateApiKey,
|
||||
@@ -74,12 +80,17 @@ import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
|
||||
import {
|
||||
handleCreateAccountPasskeyCredential,
|
||||
handleDeleteAccountPasskeyCredential,
|
||||
handleDeleteTwoFactorWebAuthn,
|
||||
handleGetAccountPasskeyAttestationOptions,
|
||||
handleGetAccountPasskeyCredentials,
|
||||
handleGetAccountPasskeyUpdateAssertionOptions,
|
||||
handleGetTwoFactorWebAuthn,
|
||||
handleGetTwoFactorWebAuthnChallenge,
|
||||
handlePutTwoFactorWebAuthn,
|
||||
handleUpdateAccountPasskeyEncryption,
|
||||
} from './handlers/account-passkeys';
|
||||
import {
|
||||
handleCreateAdminAuthRequest,
|
||||
handleGetAuthRequest,
|
||||
handleListAuthRequests,
|
||||
handleListPendingAuthRequests,
|
||||
@@ -106,6 +117,40 @@ export async function handleAuthenticatedRoute(
|
||||
}
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/kdf' || path === '/accounts/kdf') && (method === 'POST' || method === 'PUT')) {
|
||||
return unsupportedResponse('KDF changes are not supported by this server.');
|
||||
}
|
||||
|
||||
const mailBackedAccountPaths = new Set([
|
||||
'/api/accounts/email-token',
|
||||
'/accounts/email-token',
|
||||
'/api/accounts/verify-email',
|
||||
'/accounts/verify-email',
|
||||
'/api/accounts/verify-email-token',
|
||||
'/accounts/verify-email-token',
|
||||
'/api/accounts/request-otp',
|
||||
'/accounts/request-otp',
|
||||
'/api/accounts/verify-otp',
|
||||
'/accounts/verify-otp',
|
||||
]);
|
||||
if (mailBackedAccountPaths.has(path) && (method === 'POST' || method === 'PUT')) {
|
||||
return unsupportedResponse('Email delivery is not supported by this server.');
|
||||
}
|
||||
|
||||
const emailTwoFactorPaths = new Set([
|
||||
'/api/two-factor/get-email',
|
||||
'/two-factor/get-email',
|
||||
'/api/two-factor/send-email',
|
||||
'/two-factor/send-email',
|
||||
'/api/two-factor/send-email-login',
|
||||
'/two-factor/send-email-login',
|
||||
'/api/two-factor/email',
|
||||
'/two-factor/email',
|
||||
]);
|
||||
if (emailTwoFactorPaths.has(path) && (method === 'POST' || method === 'PUT' || method === 'DELETE')) {
|
||||
return unsupportedResponse('Email two-step login is not supported by this server.');
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/profile') {
|
||||
if (method === 'GET') return handleGetProfile(request, env, userId);
|
||||
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
|
||||
@@ -141,12 +186,53 @@ export async function handleAuthenticatedRoute(
|
||||
return handleGetTwoFactorAuthenticator(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/get-yubikey' || path === '/api/two-factor/get-yubi-key') && method === 'POST') {
|
||||
return handleGetTwoFactorYubiKey(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/get-device-verification-settings' && method === 'POST') {
|
||||
return handleGetDeviceVerificationSettings(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/device-verification-settings') {
|
||||
if (method === 'PUT' || method === 'POST') return handlePutDeviceVerificationSettings(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/get-webauthn' && method === 'POST') {
|
||||
return handleGetTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/get-webauthn-challenge' && method === 'POST') {
|
||||
return handleGetTwoFactorWebAuthnChallenge(request, env, userId, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/authenticator') {
|
||||
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
|
||||
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/yubikey' || path === '/api/two-factor/yubi-key')) {
|
||||
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorYubiKey(request, env, userId);
|
||||
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/webauthn') {
|
||||
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||
if (method === 'DELETE') return handleDeleteTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/yubikey/config' || path === '/api/two-factor/yubi-key/config') && (method === 'PUT' || method === 'POST')) {
|
||||
return handlePutTwoFactorYubiKeyConfig(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/yubikey/bootstrap' || path === '/api/two-factor/yubi-key/bootstrap') && method === 'POST') {
|
||||
return handleBootstrapTwoFactorYubiKeyConfig(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/disable' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleDisableTwoFactorProvider(request, env, userId);
|
||||
}
|
||||
@@ -294,17 +380,22 @@ export async function handleAuthenticatedRoute(
|
||||
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
||||
}
|
||||
|
||||
if (path === '/api/auth-requests' || path === '/api/auth-requests/') {
|
||||
if (path === '/api/auth-requests' || path === '/api/auth-requests/' || path === '/auth-requests' || path === '/auth-requests/') {
|
||||
if (method === 'GET') return handleListAuthRequests(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if (path === '/api/auth-requests/pending') {
|
||||
if (path === '/api/auth-requests/pending' || path === '/auth-requests/pending') {
|
||||
if (method === 'GET') return handleListPendingAuthRequests(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
const authRequestMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)$/i);
|
||||
if (path === '/api/auth-requests/admin-request' || path === '/auth-requests/admin-request') {
|
||||
if (method === 'POST') return handleCreateAdminAuthRequest(request, env, userId, currentUser.email);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
const authRequestMatch = path.match(/^\/(?:api\/)?auth-requests\/([a-f0-9-]+)$/i);
|
||||
if (authRequestMatch) {
|
||||
if (method === 'GET') return handleGetAuthRequest(request, env, userId, authRequestMatch[1]);
|
||||
if (method === 'PUT') return handleUpdateAuthRequest(request, env, userId, authRequestMatch[1]);
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
handleUpdateDeviceToken,
|
||||
handleUpdateDeviceWebPushAuth,
|
||||
handleClearDeviceToken,
|
||||
handleRegisterDevice,
|
||||
handleReportLostTrust,
|
||||
} from './handlers/devices';
|
||||
|
||||
function devicesPath(pattern: string): RegExp {
|
||||
@@ -33,10 +35,15 @@ export async function handleAuthenticatedDeviceRoute(
|
||||
): Promise<Response | null> {
|
||||
if (path === '/api/devices' || path === '/devices') {
|
||||
if (method === 'GET') return handleGetDevices(request, env, userId);
|
||||
if (method === 'POST') return handleRegisterDevice(request, env, userId);
|
||||
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((path === '/api/devices/lost-trust' || path === '/devices/lost-trust') && method === 'POST') {
|
||||
return handleReportLostTrust(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/devices/authorized' || path === '/devices/authorized') {
|
||||
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
||||
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
||||
|
||||
+59
-12
@@ -1,5 +1,4 @@
|
||||
import { LIMITS } from './config/limits';
|
||||
import { DEFAULT_DEV_SECRET } from './types';
|
||||
import {
|
||||
handleAccessSend,
|
||||
handleAccessSendFile,
|
||||
@@ -8,7 +7,11 @@ import {
|
||||
handleDownloadSendFile,
|
||||
} from './handlers/sends';
|
||||
import { handleKnownDevice } from './handlers/devices';
|
||||
import { handleFillAssistForms, handleFillAssistManifest } from './handlers/fill-assist';
|
||||
import {
|
||||
handleDigitalAssetLinkCheck,
|
||||
handleFillAssistForms,
|
||||
handleFillAssistManifest,
|
||||
} from './handlers/fill-assist';
|
||||
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
|
||||
import {
|
||||
@@ -29,18 +32,25 @@ import {
|
||||
} from './handlers/notifications';
|
||||
import { handlePublicUploadSendFile } from './handlers/sends';
|
||||
import { isSafeWebsiteIconContentType } from './utils/content-type';
|
||||
import { jsonResponse } from './utils/response';
|
||||
import { jsonResponse, unsupportedResponse } from './utils/response';
|
||||
import { StorageService } from './services/storage';
|
||||
import type { Env } from './types';
|
||||
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
|
||||
|
||||
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
||||
type JwtUnsafeReason = 'missing' | 'default' | 'too_short' | null;
|
||||
type JwtUnsafeReason = 'missing' | 'too_short' | null;
|
||||
|
||||
export interface WebBootstrapResponse {
|
||||
defaultKdfIterations: number;
|
||||
jwtUnsafeReason: JwtUnsafeReason;
|
||||
jwtSecretMinLength: number;
|
||||
registrationInviteRequired: boolean;
|
||||
webAuthnAllowedOrigins: string[];
|
||||
websiteIconsEnabled: boolean;
|
||||
}
|
||||
|
||||
function isWebsiteIconProxyEnabled(env: Env): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSameOriginWriteRequest(request: Request): boolean {
|
||||
@@ -98,7 +108,7 @@ function buildIconServiceCsp(origin: string): string {
|
||||
}
|
||||
|
||||
function buildConfigResponse(origin: string) {
|
||||
const fillAssistBase = `${origin}/fill-assist`;
|
||||
const fillAssistBase = `${origin}/fill-assist/`;
|
||||
return {
|
||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||
gitHash: 'nodewarden',
|
||||
@@ -252,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);
|
||||
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
|
||||
@@ -308,9 +322,7 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
|
||||
const jwtUnsafeReason =
|
||||
!secret
|
||||
? 'missing'
|
||||
: secret === DEFAULT_DEV_SECRET
|
||||
? 'default'
|
||||
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||
? 'too_short'
|
||||
: null;
|
||||
const storage = new StorageService(env.DB);
|
||||
@@ -321,6 +333,8 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
|
||||
jwtUnsafeReason,
|
||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||
registrationInviteRequired: userCount > 0,
|
||||
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
|
||||
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -353,6 +367,12 @@ export async function handlePublicRoute(
|
||||
return handleFillAssistManifest();
|
||||
}
|
||||
|
||||
if ((path === '/v1/assetlinks:check' || path === '/api/v1/assetlinks:check') && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleDigitalAssetLinkCheck();
|
||||
}
|
||||
|
||||
const fillAssistFormsMatch = path.match(/^\/fill-assist\/([^/]+)$/i);
|
||||
if (fillAssistFormsMatch && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
@@ -365,7 +385,7 @@ export async function handlePublicRoute(
|
||||
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
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);
|
||||
@@ -415,13 +435,13 @@ export async function handlePublicRoute(
|
||||
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
|
||||
}
|
||||
|
||||
if ((path === '/api/auth-requests' || path === '/api/auth-requests/') && method === 'POST') {
|
||||
if ((path === '/api/auth-requests' || path === '/api/auth-requests/' || path === '/auth-requests' || path === '/auth-requests/') && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleCreateAuthRequest(request, env);
|
||||
}
|
||||
|
||||
const authRequestResponseMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)\/response$/i);
|
||||
const authRequestResponseMatch = path.match(/^\/(?:api\/)?auth-requests\/([a-f0-9-]+)\/response$/i);
|
||||
if (authRequestResponseMatch && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
@@ -468,9 +488,34 @@ export async function handlePublicRoute(
|
||||
}
|
||||
|
||||
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleRecoverTwoFactor(request, env);
|
||||
}
|
||||
|
||||
const publicMailBackedPaths = new Set([
|
||||
'/api/accounts/resend-new-device-otp',
|
||||
'/accounts/resend-new-device-otp',
|
||||
'/api/accounts/register/send-verification-email',
|
||||
'/accounts/register/send-verification-email',
|
||||
'/identity/accounts/register/send-verification-email',
|
||||
'/api/accounts/register/verification-email-clicked',
|
||||
'/accounts/register/verification-email-clicked',
|
||||
'/identity/accounts/register/verification-email-clicked',
|
||||
'/api/accounts/register/finish',
|
||||
'/accounts/register/finish',
|
||||
'/identity/accounts/register/finish',
|
||||
'/api/accounts/verify-email-token',
|
||||
'/accounts/verify-email-token',
|
||||
'/api/two-factor/send-email-login',
|
||||
'/two-factor/send-email-login',
|
||||
]);
|
||||
if (publicMailBackedPaths.has(path) && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return unsupportedResponse('Email delivery is not supported by this server.');
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/password-hint' && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
@@ -517,6 +562,8 @@ export async function handlePublicRoute(
|
||||
}
|
||||
|
||||
if (path === '/notifications/anonymous-hub' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleAnonymousNotificationsHub(request, env);
|
||||
}
|
||||
return null;
|
||||
|
||||
+92
-19
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_DEV_SECRET, Env } from './types';
|
||||
import { Env } from './types';
|
||||
import { AuthService } from './services/auth';
|
||||
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
|
||||
import { handleCors, errorResponse } from './utils/response';
|
||||
@@ -6,14 +6,25 @@ import { LIMITS } from './config/limits';
|
||||
import { handleAuthenticatedRoute } from './router-authenticated';
|
||||
import { handlePublicRoute } from './router-public';
|
||||
|
||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret) return 'missing';
|
||||
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
||||
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
||||
return null;
|
||||
}
|
||||
|
||||
function canServeWithUnsafeJwtSecret(path: string, method: string): boolean {
|
||||
if (method === 'OPTIONS') return true;
|
||||
if (method === 'GET' && (path === '/api/web-bootstrap' || path === '/web-bootstrap')) return true;
|
||||
if (method === 'GET' && (path === '/config' || path === '/api/config' || path === '/api/version')) return true;
|
||||
if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true;
|
||||
if (method === 'GET' && path === '/fill-assist/manifest.json') return true;
|
||||
if (method === 'GET' && /^\/fill-assist\/[^/]+$/i.test(path)) return true;
|
||||
if (method === 'GET' && (path === '/v1/assetlinks:check' || path === '/api/v1/assetlinks:check')) return true;
|
||||
if (method === 'GET' && /^\/icons\/[^/]+\/icon\.png$/i.test(path)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isImportBypassRequest(request: Request, path: string, method: string): boolean {
|
||||
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
|
||||
|
||||
@@ -26,6 +37,70 @@ function isImportBypassRequest(request: Request, path: string, method: string):
|
||||
return false;
|
||||
}
|
||||
|
||||
const BODY_LIMIT_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
function isLargeUploadPath(path: string): boolean {
|
||||
return (
|
||||
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
|
||||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
|
||||
path === '/api/admin/backup/import'
|
||||
);
|
||||
}
|
||||
|
||||
async function enforceRequestBodyLimit(
|
||||
request: Request,
|
||||
path: string,
|
||||
method: string
|
||||
): Promise<Request | Response> {
|
||||
if (!BODY_LIMIT_METHODS.has(method) || isLargeUploadPath(path) || !request.body) {
|
||||
return request;
|
||||
}
|
||||
|
||||
const contentLengthRaw = request.headers.get('Content-Length');
|
||||
if (contentLengthRaw) {
|
||||
const contentLength = Number(contentLengthRaw);
|
||||
if (Number.isFinite(contentLength) && contentLength > LIMITS.request.maxBodyBytes) {
|
||||
return errorResponse('Request body too large', 413);
|
||||
}
|
||||
if (Number.isFinite(contentLength) && contentLength >= 0) {
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
const reader = request.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
total += value.byteLength;
|
||||
if (total > LIMITS.request.maxBodyBytes) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// Ignore cancellation races after the oversized body is rejected.
|
||||
}
|
||||
return errorResponse('Request body too large', 413);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const body = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
return new Request(request.url, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body,
|
||||
redirect: request.redirect,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleRequest(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
@@ -50,7 +125,10 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
}
|
||||
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
|
||||
const shouldUseStrictBudget = category === 'public-sensitive' || category === 'register';
|
||||
const check = shouldUseStrictBudget
|
||||
? await rateLimit.consumeStrictBudget(`${clientId}:${category}`, maxRequests)
|
||||
: await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
|
||||
if (check.allowed) return null;
|
||||
|
||||
return new Response(
|
||||
@@ -70,29 +148,24 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
}
|
||||
|
||||
if (method === 'OPTIONS') {
|
||||
return handleCors(request);
|
||||
return handleCors(request, env);
|
||||
}
|
||||
|
||||
try {
|
||||
const isLargeUploadPath =
|
||||
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
|
||||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
|
||||
path === '/api/admin/backup/import';
|
||||
if (!isLargeUploadPath) {
|
||||
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
|
||||
if (contentLength > LIMITS.request.maxBodyBytes) {
|
||||
return errorResponse('Request body too large', 413);
|
||||
}
|
||||
const bodyLimitResult = await enforceRequestBodyLimit(request, path, method);
|
||||
if (bodyLimitResult instanceof Response) {
|
||||
return bodyLimitResult;
|
||||
}
|
||||
request = bodyLimitResult;
|
||||
|
||||
const secretIssue = jwtSecretUnsafeReason(env);
|
||||
if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) {
|
||||
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
|
||||
}
|
||||
|
||||
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
|
||||
if (publicResponse) return publicResponse;
|
||||
|
||||
const secretIssue = jwtSecretUnsafeReason(env);
|
||||
if (secretIssue) {
|
||||
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
|
||||
}
|
||||
|
||||
const auth = new AuthService(env);
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const verified = await auth.verifyAccessTokenWithUser(authHeader);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zipSync, unzipSync } from 'fflate';
|
||||
import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate';
|
||||
import type { Env } from '../types';
|
||||
import { APP_VERSION } from '../../shared/app-version';
|
||||
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
|
||||
@@ -28,10 +28,11 @@ const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
|
||||
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
|
||||
const BACKUP_JSON_INDENT = 2;
|
||||
const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
||||
export const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 10_000;
|
||||
const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
|
||||
const MAX_BACKUP_PATH_SEGMENT_LENGTH = 128;
|
||||
|
||||
export interface BackupManifest {
|
||||
formatVersion: 1;
|
||||
@@ -186,6 +187,61 @@ function validateArchiveSize(bytes: Uint8Array): void {
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeBackupPathSegment(value: string): boolean {
|
||||
if (!value || value.length > MAX_BACKUP_PATH_SEGMENT_LENGTH) return false;
|
||||
if (value === '.' || value === '..') return false;
|
||||
return /^[A-Za-z0-9._-]+$/.test(value);
|
||||
}
|
||||
|
||||
export function isSafeBackupAttachmentBlobName(value: unknown): boolean {
|
||||
const normalized = String(value ?? '').trim();
|
||||
const parts = normalized.split('/');
|
||||
return parts.length === 2 && parts.every(isSafeBackupPathSegment);
|
||||
}
|
||||
|
||||
function isSafeBackupAttachmentEntryName(value: string): boolean {
|
||||
if (!value.startsWith('attachments/') || !value.endsWith('.bin')) return false;
|
||||
const relative = value.slice('attachments/'.length, -'.bin'.length);
|
||||
return isSafeBackupAttachmentBlobName(relative);
|
||||
}
|
||||
|
||||
function validateBackupEntryName(name: string): void {
|
||||
const normalized = String(name || '').trim();
|
||||
if (normalized !== name || !normalized) {
|
||||
throw new Error('Backup archive contains an invalid file name');
|
||||
}
|
||||
if (normalized.includes('\\') || normalized.includes('\0') || normalized.startsWith('/') || normalized.includes('//')) {
|
||||
throw new Error(`Backup archive contains an unsafe file name: ${normalized}`);
|
||||
}
|
||||
if (normalized !== 'manifest.json' && normalized !== 'db.json' && !isSafeBackupAttachmentEntryName(normalized)) {
|
||||
throw new Error(`Backup archive contains an unsupported file: ${normalized}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createBackupUnzipFilter(): (file: UnzipFileInfo) => boolean {
|
||||
let entryCount = 0;
|
||||
let totalOriginalBytes = 0;
|
||||
return (file: UnzipFileInfo): boolean => {
|
||||
entryCount += 1;
|
||||
if (entryCount > MAX_BACKUP_ARCHIVE_ENTRY_COUNT) {
|
||||
throw new Error('Backup archive contains too many files');
|
||||
}
|
||||
validateBackupEntryName(file.name);
|
||||
const originalSize = Number(file.originalSize);
|
||||
if (!Number.isFinite(originalSize) || originalSize < 0) {
|
||||
throw new Error(`Backup archive contains an invalid file size: ${file.name}`);
|
||||
}
|
||||
if (file.name === 'db.json' && originalSize > MAX_BACKUP_DB_JSON_BYTES) {
|
||||
throw new Error('Backup archive database payload is too large');
|
||||
}
|
||||
totalOriginalBytes += originalSize;
|
||||
if (totalOriginalBytes > MAX_BACKUP_EXTRACTED_BYTES) {
|
||||
throw new Error('Backup archive expands beyond the current restore limit');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function getRequiredZipEntries(db: BackupPayload['db']): string[] {
|
||||
const entries: string[] = [];
|
||||
for (const row of db.attachments) {
|
||||
@@ -223,8 +279,11 @@ export function parseBackupArchive(
|
||||
validateArchiveSize(bytes);
|
||||
let zipped: Record<string, Uint8Array>;
|
||||
try {
|
||||
zipped = unzipSync(bytes);
|
||||
} catch {
|
||||
zipped = unzipSync(bytes, { filter: createBackupUnzipFilter() });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Backup archive ')) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Invalid backup archive');
|
||||
}
|
||||
|
||||
@@ -235,6 +294,7 @@ export function parseBackupArchive(
|
||||
|
||||
let totalExtractedBytes = 0;
|
||||
for (const entry of entryNames) {
|
||||
validateBackupEntryName(entry);
|
||||
const entryBytes = zipped[entry];
|
||||
totalExtractedBytes += entryBytes.byteLength;
|
||||
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
|
||||
@@ -368,7 +428,7 @@ export function validateBackupPayloadContents(
|
||||
for (const row of attachmentRows) {
|
||||
const id = String(row.id || '').trim();
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
if (!id || !cipherId || !cipherIds.has(cipherId)) {
|
||||
if (!id || !cipherId || !isSafeBackupPathSegment(id) || !isSafeBackupPathSegment(cipherId) || !cipherIds.has(cipherId)) {
|
||||
throw new Error('Backup archive contains an invalid attachment row');
|
||||
}
|
||||
const attachmentPath = `attachments/${cipherId}/${id}.bin`;
|
||||
@@ -382,9 +442,10 @@ export function validateBackupPayloadContents(
|
||||
for (const row of accountPasskeyRows) {
|
||||
const id = String(row.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 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');
|
||||
}
|
||||
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
|
||||
@@ -427,13 +488,13 @@ export async function buildBackupArchive(
|
||||
const encoder = new TextEncoder();
|
||||
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
|
||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
|
||||
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id 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, 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()),
|
||||
]);
|
||||
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
|
||||
const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1';
|
||||
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
|
||||
export const REDACTED_BACKUP_SECRET = '********';
|
||||
const MAX_BACKUP_DESTINATIONS = 24;
|
||||
|
||||
export type {
|
||||
@@ -67,6 +68,114 @@ function normalizePath(value: unknown): string {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
function isBlockedIpv6Address(hostname: string): boolean {
|
||||
if (!hostname.includes(':')) return false;
|
||||
const normalized = hostname.toLowerCase();
|
||||
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
||||
if (mappedIpv4) {
|
||||
const octets = parseIpv4Address(mappedIpv4[1]);
|
||||
return !octets || isBlockedIpv4Address(octets);
|
||||
}
|
||||
const firstHextetText = normalized.split(':').find((part) => part.length > 0) || '0';
|
||||
const firstHextet = Number.parseInt(firstHextetText, 16);
|
||||
if (!Number.isFinite(firstHextet)) return true;
|
||||
return (
|
||||
firstHextet === 0 ||
|
||||
(firstHextet & 0xfe00) === 0xfc00 ||
|
||||
(firstHextet & 0xffc0) === 0xfe80 ||
|
||||
(firstHextet & 0xff00) === 0xff00 ||
|
||||
normalized.startsWith('2001:db8:')
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
|
||||
@@ -122,7 +231,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
||||
|
||||
if (!allowIncomplete || endpoint) {
|
||||
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 (!bucket) throw new Error('S3 bucket is required');
|
||||
@@ -135,7 +244,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
||||
endpoint: endpoint ? normalizeBackupEndpointUrl(endpoint, 'S3 endpoint') : '',
|
||||
bucket,
|
||||
addressingStyle,
|
||||
region,
|
||||
@@ -154,7 +263,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
|
||||
|
||||
if (!allowIncomplete || baseUrl) {
|
||||
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 (!username) throw new Error('WebDAV username is required');
|
||||
@@ -164,7 +273,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
|
||||
baseUrl: baseUrl ? normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL') : '',
|
||||
username,
|
||||
password,
|
||||
remotePath,
|
||||
@@ -180,6 +289,32 @@ function normalizeDestination(
|
||||
return normalizeWebDavDestination(destination, allowIncomplete);
|
||||
}
|
||||
|
||||
function shouldPreserveBackupSecret(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return true;
|
||||
const raw = String(value);
|
||||
return raw === '' || raw === REDACTED_BACKUP_SECRET;
|
||||
}
|
||||
|
||||
function withPreservedDestinationSecret(
|
||||
destinationType: BackupDestinationType,
|
||||
inputDestination: unknown,
|
||||
previous: BackupDestinationRecord | undefined
|
||||
): unknown {
|
||||
const source = isPlainObject(inputDestination) ? { ...inputDestination } : {};
|
||||
if (destinationType === 's3') {
|
||||
const previousDestination = previous?.type === 's3' ? previous.destination as S3BackupDestination : null;
|
||||
if (shouldPreserveBackupSecret(source.secretAccessKey)) {
|
||||
source.secretAccessKey = previousDestination?.secretAccessKey || '';
|
||||
}
|
||||
} else {
|
||||
const previousDestination = previous?.type === 'webdav' ? previous.destination as WebDavBackupDestination : null;
|
||||
if (shouldPreserveBackupSecret(source.password)) {
|
||||
source.password = previousDestination?.password || '';
|
||||
}
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function normalizeRuntime(value: unknown): BackupRuntimeState {
|
||||
const source = isPlainObject(value) ? value : {};
|
||||
const asIso = (input: unknown): string | null => {
|
||||
@@ -250,7 +385,11 @@ function normalizeDestinationRecord(
|
||||
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
||||
};
|
||||
|
||||
const destination = normalizeDestination(type, input.destination, !schedule.enabled);
|
||||
const destination = normalizeDestination(
|
||||
type,
|
||||
withPreservedDestinationSecret(type, input.destination, previous),
|
||||
!schedule.enabled
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -432,6 +571,31 @@ export function serializeBackupSettings(settings: BackupSettings): string {
|
||||
return JSON.stringify(stripRuntimeFromSettings(settings));
|
||||
}
|
||||
|
||||
export function redactBackupSettingsSecrets(settings: BackupSettings): BackupSettings {
|
||||
return {
|
||||
destinations: settings.destinations.map((destination) => {
|
||||
if (destination.type === 's3') {
|
||||
const config = destination.destination as S3BackupDestination;
|
||||
return {
|
||||
...destination,
|
||||
destination: {
|
||||
...config,
|
||||
secretAccessKey: config.secretAccessKey ? REDACTED_BACKUP_SECRET : '',
|
||||
},
|
||||
};
|
||||
}
|
||||
const config = destination.destination as WebDavBackupDestination;
|
||||
return {
|
||||
...destination,
|
||||
destination: {
|
||||
...config,
|
||||
password: config.password ? REDACTED_BACKUP_SECRET : '',
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
|
||||
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from
|
||||
import {
|
||||
type BackupManifestAttachmentBlob,
|
||||
type BackupPayload,
|
||||
isSafeBackupAttachmentBlobName,
|
||||
parseBackupArchive,
|
||||
validateBackupPayloadContents,
|
||||
} from './backup-archive';
|
||||
@@ -253,6 +254,10 @@ function cloneRows(rows: SqlRow[]): SqlRow[] {
|
||||
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[] {
|
||||
let replaced = false;
|
||||
const nextRows = rows.map((row) => {
|
||||
@@ -297,11 +302,15 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
||||
users: cloneRows(payload.users || []).map((row) => ({
|
||||
...row,
|
||||
verify_devices: row.verify_devices ?? 1,
|
||||
yubikey_nfc: row.yubikey_nfc ?? 0,
|
||||
})),
|
||||
domain_settings: cloneRows(payload.domain_settings || []),
|
||||
user_revisions: cloneRows(payload.user_revisions || []),
|
||||
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 || []),
|
||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||
...row,
|
||||
@@ -461,9 +470,20 @@ async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record
|
||||
}
|
||||
|
||||
function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> {
|
||||
return new Map(
|
||||
(manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item])
|
||||
);
|
||||
const lookup = new Map<string, BackupManifestAttachmentBlob>();
|
||||
for (const item of manifest.attachmentBlobs || []) {
|
||||
const cipherId = String(item.cipherId || '').trim();
|
||||
const attachmentId = String(item.attachmentId || '').trim();
|
||||
const blobName = String(item.blobName || '').trim();
|
||||
if (!cipherId || !attachmentId || !isSafeBackupAttachmentBlobName(blobName)) continue;
|
||||
lookup.set(`${cipherId}/${attachmentId}`, {
|
||||
...item,
|
||||
cipherId,
|
||||
attachmentId,
|
||||
blobName,
|
||||
});
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
|
||||
async function prepareRemoteAttachmentPayload(
|
||||
@@ -619,7 +639,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
||||
buildInsertStatements(
|
||||
db,
|
||||
tableName('users'),
|
||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'yubikey_key1', 'yubikey_key2', 'yubikey_key3', 'yubikey_key4', 'yubikey_key5', 'yubikey_nfc', 'created_at', 'updated_at'],
|
||||
payload.users || []
|
||||
)
|
||||
);
|
||||
@@ -655,7 +675,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
||||
buildInsertStatements(
|
||||
db,
|
||||
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 || []
|
||||
)
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BackupDestinationType,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
normalizeBackupEndpointUrl,
|
||||
} from './backup-config';
|
||||
|
||||
export interface BackupUploadResult {
|
||||
@@ -215,7 +216,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
|
||||
if (destination.type === 'webdav') {
|
||||
const config = destination.destination as WebDavBackupDestination;
|
||||
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.password || '')) throw new Error('WebDAV password is required');
|
||||
return;
|
||||
@@ -223,7 +224,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
|
||||
if (destination.type === 's3') {
|
||||
const config = destination.destination as S3BackupDestination;
|
||||
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.accessKeyId || '').trim()) throw new Error('S3 access 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,
|
||||
},
|
||||
});
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -275,7 +276,7 @@ async function ensureWebDavDirectoryCached(
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
|
||||
if ([200, 201, 204, 405].includes(response.status)) {
|
||||
ensuredDirectories.add(current);
|
||||
continue;
|
||||
}
|
||||
@@ -518,7 +519,7 @@ async function signedS3Request(
|
||||
config.region || 'auto'
|
||||
);
|
||||
|
||||
return fetch(url.toString(), {
|
||||
return fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
|
||||
@@ -62,27 +62,10 @@ export async function ensurePushInstallationCredentials(db: D1Database): Promise
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'cache-control': 'no-cache',
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://bitwarden.com',
|
||||
pragma: 'no-cache',
|
||||
priority: 'u=1, i',
|
||||
referer: 'https://bitwarden.com/host/',
|
||||
'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Windows"',
|
||||
'sec-fetch-dest': 'empty',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-site': 'same-site',
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
formName: 'request_host',
|
||||
url: '/host/',
|
||||
locale: 'zh-CN',
|
||||
email: randomInstallationEmail(),
|
||||
region: 'us',
|
||||
}),
|
||||
},
|
||||
'Failed to request Bitwarden push installation:'
|
||||
@@ -94,9 +77,9 @@ export async function ensurePushInstallationCredentials(db: D1Database): Promise
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as { id?: string; key?: string; enabled?: boolean } | null;
|
||||
const id = String(body?.id || '').trim();
|
||||
const key = String(body?.key || '').trim();
|
||||
const body = (await response.json().catch(() => null)) as { id?: string; Id?: string; key?: string; Key?: string; enabled?: boolean; Enabled?: boolean } | null;
|
||||
const id = String(body?.id || body?.Id || '').trim();
|
||||
const key = String(body?.key || body?.Key || '').trim();
|
||||
if (!id || !key) {
|
||||
console.error('Bitwarden push installation response did not include id/key');
|
||||
return null;
|
||||
@@ -234,7 +217,7 @@ export async function registerMobilePushDevice(
|
||||
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
|
||||
const normalized = String(pushUuid || '').trim();
|
||||
if (!normalized) return false;
|
||||
return postToPushRelay(env, `/push/delete/${encodeURIComponent(normalized)}`);
|
||||
return postToPushRelay(env, '/push/delete', { id: normalized });
|
||||
}
|
||||
|
||||
export async function notifyMobilePush(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LIMITS } from '../config/limits';
|
||||
// Rate limiting service.
|
||||
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
|
||||
// - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes).
|
||||
// - Strict budgets: D1-backed fixed windows for low-volume anonymous sensitive endpoints.
|
||||
|
||||
const CONFIG = {
|
||||
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
|
||||
@@ -12,11 +13,14 @@ const CONFIG = {
|
||||
|
||||
export class RateLimitService {
|
||||
private static loginIpTableReady = false;
|
||||
private static strictBudgetTableReady = false;
|
||||
private static lastLoginIpCleanupAt = 0;
|
||||
private static lastStrictBudgetCleanupAt = 0;
|
||||
|
||||
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
|
||||
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
|
||||
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
|
||||
private static readonly STRICT_BUDGET_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
|
||||
|
||||
constructor(private db: D1Database) {}
|
||||
|
||||
@@ -58,6 +62,35 @@ export class RateLimitService {
|
||||
RateLimitService.loginIpTableReady = true;
|
||||
}
|
||||
|
||||
private async ensureStrictBudgetTable(): Promise<void> {
|
||||
if (RateLimitService.strictBudgetTableReady) return;
|
||||
|
||||
await this.db
|
||||
.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS rate_limit_buckets (' +
|
||||
'bucket_key TEXT PRIMARY KEY, ' +
|
||||
'count INTEGER NOT NULL, ' +
|
||||
'expires_at INTEGER NOT NULL, ' +
|
||||
'updated_at INTEGER NOT NULL' +
|
||||
')'
|
||||
)
|
||||
.run();
|
||||
|
||||
await this.db
|
||||
.prepare('CREATE INDEX IF NOT EXISTS idx_rate_limit_buckets_expires ON rate_limit_buckets(expires_at)')
|
||||
.run();
|
||||
RateLimitService.strictBudgetTableReady = true;
|
||||
}
|
||||
|
||||
private async maybeCleanupStrictBudgets(nowMs: number): Promise<void> {
|
||||
if (!this.shouldRunCleanup(RateLimitService.lastStrictBudgetCleanupAt, RateLimitService.STRICT_BUDGET_CLEANUP_INTERVAL_MS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db.prepare('DELETE FROM rate_limit_buckets WHERE expires_at < ?').bind(nowMs).run();
|
||||
RateLimitService.lastStrictBudgetCleanupAt = nowMs;
|
||||
}
|
||||
|
||||
async checkLoginAttempt(ip: string): Promise<{
|
||||
allowed: boolean;
|
||||
remainingAttempts: number;
|
||||
@@ -174,6 +207,59 @@ export class RateLimitService {
|
||||
return { allowed: true, remaining: Math.max(0, maxRequests - count) };
|
||||
}
|
||||
|
||||
async consumeStrictBudget(
|
||||
identifier: string,
|
||||
maxRequests: number
|
||||
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||
return this.consumeStrictBudgetWithWindow(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
|
||||
}
|
||||
|
||||
async consumeStrictBudgetWithWindow(
|
||||
identifier: string,
|
||||
maxRequests: number,
|
||||
windowSeconds: number
|
||||
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||
await this.ensureStrictBudgetTable();
|
||||
|
||||
const key = String(identifier || '').trim() || 'unknown';
|
||||
const max = Math.max(1, Math.floor(maxRequests));
|
||||
const windowSize = Math.max(1, Math.floor(windowSeconds));
|
||||
const nowMs = Date.now();
|
||||
const nowSec = Math.floor(nowMs / 1000);
|
||||
const windowStart = nowSec - (nowSec % windowSize);
|
||||
const windowEndMs = (windowStart + windowSize) * 1000;
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((windowEndMs - nowMs) / 1000));
|
||||
const bucketKey = `${key}:${windowStart}`;
|
||||
|
||||
await this.maybeCleanupStrictBudgets(nowMs);
|
||||
await this.db
|
||||
.prepare(
|
||||
'INSERT OR IGNORE INTO rate_limit_buckets(bucket_key, count, expires_at, updated_at) VALUES(?, 0, ?, ?)'
|
||||
)
|
||||
.bind(bucketKey, windowEndMs, nowMs)
|
||||
.run();
|
||||
|
||||
const update = await this.db
|
||||
.prepare(
|
||||
'UPDATE rate_limit_buckets SET count = count + 1, expires_at = ?, updated_at = ? ' +
|
||||
'WHERE bucket_key = ? AND count < ?'
|
||||
)
|
||||
.bind(windowEndMs, nowMs, bucketKey, max)
|
||||
.run();
|
||||
|
||||
const allowed = Number(update.meta?.changes ?? 0) > 0;
|
||||
const row = await this.db
|
||||
.prepare('SELECT count FROM rate_limit_buckets WHERE bucket_key = ?')
|
||||
.bind(bucketKey)
|
||||
.first<{ count: number }>();
|
||||
const count = Math.max(0, Number(row?.count || 0));
|
||||
|
||||
if (!allowed) {
|
||||
return { allowed: false, remaining: 0, retryAfterSeconds };
|
||||
}
|
||||
return { allowed: true, remaining: Math.max(0, max - count) };
|
||||
}
|
||||
|
||||
// General-purpose fixed-window budget.
|
||||
// Callers supply an identifier (must be unique per rate-limit category) and the
|
||||
// per-window maximum. This single method replaces all previous specialised
|
||||
|
||||
@@ -7,6 +7,7 @@ let accountPasskeySchemaReady = false;
|
||||
const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [
|
||||
{ name: 'id', sql: 'id TEXT' },
|
||||
{ name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" },
|
||||
{ name: 'purpose', sql: "purpose TEXT NOT NULL DEFAULT 'login'" },
|
||||
{ name: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" },
|
||||
{ name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" },
|
||||
{ name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" },
|
||||
@@ -42,7 +43,7 @@ async function ensureAccountPasskeySchema(db: D1Database): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
||||
"id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT 'login', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, " +
|
||||
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)'
|
||||
@@ -100,6 +101,7 @@ function parseTransports(value: string | null): string[] | null {
|
||||
function mapCredentialRow(row: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
purpose?: string | null;
|
||||
name: string;
|
||||
public_key: string;
|
||||
credential_id: string;
|
||||
@@ -117,6 +119,7 @@ function mapCredentialRow(row: {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
purpose: row.purpose === 'twoFactor' ? 'twoFactor' : 'login',
|
||||
name: row.name,
|
||||
publicKey: row.public_key,
|
||||
credentialId: row.credential_id,
|
||||
@@ -160,16 +163,17 @@ export async function saveAccountPasskeyCredential(
|
||||
await safeBind(
|
||||
db.prepare(
|
||||
'INSERT INTO webauthn_credentials(' +
|
||||
'id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
|
||||
'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' +
|
||||
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
|
||||
'purpose=excluded.purpose, name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
|
||||
'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' +
|
||||
'encrypted_public_key=excluded.encrypted_public_key, encrypted_private_key=excluded.encrypted_private_key, supports_prf=excluded.supports_prf, updated_at=excluded.updated_at'
|
||||
),
|
||||
credential.id,
|
||||
credential.userId,
|
||||
credential.purpose,
|
||||
credential.name,
|
||||
credential.publicKey,
|
||||
credential.credentialId,
|
||||
@@ -188,12 +192,13 @@ export async function saveAccountPasskeyCredential(
|
||||
|
||||
export async function listAccountPasskeyCredentialsByUserId(
|
||||
db: D1Database,
|
||||
userId: string
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<AccountPasskeyCredential[]> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const rows = await db
|
||||
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC')
|
||||
.bind(userId)
|
||||
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND purpose = ? ORDER BY created_at ASC')
|
||||
.bind(userId, purpose)
|
||||
.all<any>();
|
||||
return (rows.results || []).map(mapCredentialRow);
|
||||
}
|
||||
@@ -225,12 +230,13 @@ export async function getAccountPasskeyCredentialByCredentialId(
|
||||
|
||||
export async function countAccountPasskeyCredentialsByUserId(
|
||||
db: D1Database,
|
||||
userId: string
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<number> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const row = await db
|
||||
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?')
|
||||
.bind(userId)
|
||||
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ? AND purpose = ?')
|
||||
.bind(userId, purpose)
|
||||
.first<{ count: number }>();
|
||||
return Number(row?.count || 0);
|
||||
}
|
||||
@@ -262,7 +268,7 @@ export async function updateAccountPasskeyEncryption(
|
||||
const result = await db
|
||||
.prepare(
|
||||
'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' +
|
||||
'WHERE user_id = ? AND credential_id = ?'
|
||||
"WHERE user_id = ? AND credential_id = ? AND purpose = 'login'"
|
||||
)
|
||||
.bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId)
|
||||
.run();
|
||||
@@ -272,12 +278,13 @@ export async function updateAccountPasskeyEncryption(
|
||||
export async function deleteAccountPasskeyCredential(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
id: string
|
||||
id: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<boolean> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const result = await db
|
||||
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ?')
|
||||
.bind(userId, id)
|
||||
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ? AND purpose = ?')
|
||||
.bind(userId, id, purpose)
|
||||
.run();
|
||||
return Number(result.meta.changes || 0) > 0;
|
||||
}
|
||||
|
||||
@@ -22,10 +22,35 @@ export async function getAttachment(db: D1Database, id: string): Promise<Attachm
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAttachmentForUser(db: D1Database, id: string, userId: string): Promise<Attachment | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key
|
||||
FROM attachments a
|
||||
INNER JOIN ciphers c ON c.id = a.cipher_id
|
||||
WHERE a.id = ? AND c.user_id = ?`
|
||||
)
|
||||
.bind(id, userId)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
cipherId: row.cipher_id,
|
||||
fileName: row.file_name,
|
||||
size: row.size,
|
||||
sizeName: row.size_name,
|
||||
key: row.key,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> {
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key'
|
||||
'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key ' +
|
||||
'WHERE EXISTS (' +
|
||||
'SELECT 1 FROM ciphers current_cipher INNER JOIN ciphers next_cipher ON next_cipher.id = excluded.cipher_id ' +
|
||||
'WHERE current_cipher.id = attachments.cipher_id AND current_cipher.user_id = next_cipher.user_id' +
|
||||
')'
|
||||
);
|
||||
await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run();
|
||||
}
|
||||
@@ -34,6 +59,20 @@ export async function deleteAttachment(db: D1Database, id: string): Promise<void
|
||||
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
export async function deleteAttachmentForUser(db: D1Database, id: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`DELETE FROM attachments
|
||||
WHERE id = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM ciphers c
|
||||
WHERE c.id = attachments.cipher_id AND c.user_id = ?
|
||||
)`
|
||||
)
|
||||
.bind(id, userId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function bulkDeleteAttachmentsByIds(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
@@ -135,6 +174,30 @@ export async function addAttachmentToCipher(db: D1Database, cipherId: string, at
|
||||
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
|
||||
}
|
||||
|
||||
export async function addAttachmentToCipherForUser(
|
||||
db: D1Database,
|
||||
cipherId: string,
|
||||
attachmentId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE attachments
|
||||
SET cipher_id = ?
|
||||
WHERE id = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM ciphers target_cipher
|
||||
WHERE target_cipher.id = ? AND target_cipher.user_id = ?
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM ciphers current_cipher
|
||||
WHERE current_cipher.id = attachments.cipher_id AND current_cipher.user_id = ?
|
||||
)`
|
||||
)
|
||||
.bind(cipherId, attachmentId, cipherId, userId, userId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
|
||||
}
|
||||
|
||||
@@ -68,6 +68,11 @@ export async function getAuthRequestById(db: D1Database, id: string): Promise<Au
|
||||
return row ? mapAuthRequestRow(row) : null;
|
||||
}
|
||||
|
||||
export async function getAuthRequestByIdForUser(db: D1Database, id: string, userId: string): Promise<AuthRequestRecord | null> {
|
||||
const row = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE id = ? AND user_id = ? LIMIT 1`).bind(id, userId).first<any>();
|
||||
return row ? mapAuthRequestRow(row) : null;
|
||||
}
|
||||
|
||||
export async function listAuthRequestsByUserId(db: D1Database, userId: string): Promise<AuthRequestRecord[]> {
|
||||
const res = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE user_id = ? ORDER BY creation_date DESC`).bind(userId).all<any>();
|
||||
return (res.results || []).map(mapAuthRequestRow);
|
||||
|
||||
@@ -107,6 +107,14 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
|
||||
return parseCipherRow(row);
|
||||
}
|
||||
|
||||
export async function getCipherForUser(db: D1Database, id: string, userId: string): Promise<Cipher | null> {
|
||||
const row = await db
|
||||
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE id = ? AND user_id = ?`)
|
||||
.bind(id, userId)
|
||||
.first<CipherRow>();
|
||||
return parseCipherRow(row);
|
||||
}
|
||||
|
||||
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
|
||||
const folderId = normalizeOptionalId(cipher.folderId);
|
||||
const data = buildCipherData(cipher, folderId);
|
||||
@@ -114,7 +122,8 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
|
||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
|
||||
'type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at ' +
|
||||
'WHERE user_id=excluded.user_id'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
|
||||
@@ -97,6 +97,20 @@ export async function touchDeviceLastSeen(
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function rotateDeviceSessionStamp(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
sessionStamp: string
|
||||
): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare('UPDATE devices SET session_stamp = ?, updated_at = ? WHERE user_id = ? AND device_identifier = ?')
|
||||
.bind(sessionStamp, now, userId, deviceIdentifier)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function updateDeviceKeys(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
|
||||
@@ -19,11 +19,20 @@ export async function getFolder(db: D1Database, id: string): Promise<Folder | nu
|
||||
return mapFolderRow(row);
|
||||
}
|
||||
|
||||
export async function getFolderForUser(db: D1Database, id: string, userId: string): Promise<Folder | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ? AND user_id = ?')
|
||||
.bind(id, userId)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return mapFolderRow(row);
|
||||
}
|
||||
|
||||
export async function saveFolder(db: D1Database, folder: Folder): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at'
|
||||
'ON CONFLICT(id) DO UPDATE SET name=excluded.name, updated_at=excluded.updated_at WHERE user_id=excluded.user_id'
|
||||
)
|
||||
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
|
||||
.run();
|
||||
|
||||
@@ -14,13 +14,19 @@ 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, ' +
|
||||
'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, ' +
|
||||
'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, 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 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)',
|
||||
'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 status TEXT NOT NULL DEFAULT \'active\'',
|
||||
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key2 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key3 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key4 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key5 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_nfc INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE users ADD COLUMN api_key TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS domain_settings (' +
|
||||
@@ -134,10 +140,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT \'login\', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'ALTER TABLE webauthn_credentials ADD COLUMN purpose TEXT NOT NULL DEFAULT \'login\'',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)',
|
||||
|
||||
@@ -40,15 +40,27 @@ export async function getSend(db: D1Database, id: string): Promise<Send | null>
|
||||
return mapSendRow(row);
|
||||
}
|
||||
|
||||
export async function getSendForUser(db: D1Database, id: string, userId: string): Promise<Send | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE id = ? AND user_id = ?'
|
||||
)
|
||||
.bind(id, userId)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return mapSendRow(row);
|
||||
}
|
||||
|
||||
export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> {
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'user_id=excluded.user_id, type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' +
|
||||
'type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' +
|
||||
'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' +
|
||||
'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' +
|
||||
'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date'
|
||||
'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date ' +
|
||||
'WHERE user_id=excluded.user_id'
|
||||
);
|
||||
|
||||
await safeBind(
|
||||
@@ -81,9 +93,13 @@ export async function incrementSendAccessCount(db: D1Database, sendId: string):
|
||||
const result = await db
|
||||
.prepare(
|
||||
'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' +
|
||||
'WHERE id = ? AND (max_access_count IS NULL OR access_count < max_access_count)'
|
||||
'WHERE id = ? ' +
|
||||
'AND disabled = 0 ' +
|
||||
'AND (max_access_count IS NULL OR access_count < max_access_count) ' +
|
||||
'AND (expiration_date IS NULL OR expiration_date > ?) ' +
|
||||
'AND deletion_date > ?'
|
||||
)
|
||||
.bind(now, sendId)
|
||||
.bind(now, sendId, now, now)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState
|
||||
const USER_SELECT_COLUMNS =
|
||||
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
|
||||
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
|
||||
'totp_secret, totp_recovery_code, api_key, created_at, updated_at';
|
||||
'totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at';
|
||||
|
||||
function mapUserRow(row: any): User {
|
||||
return {
|
||||
@@ -26,6 +26,12 @@ function mapUserRow(row: any): User {
|
||||
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
||||
totpSecret: row.totp_secret ?? null,
|
||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||
yubikeyKey1: row.yubikey_key1 ?? null,
|
||||
yubikeyKey2: row.yubikey_key2 ?? null,
|
||||
yubikeyKey3: row.yubikey_key3 ?? null,
|
||||
yubikeyKey4: row.yubikey_key4 ?? null,
|
||||
yubikeyKey5: row.yubikey_key5 ?? null,
|
||||
yubikeyNfc: !!row.yubikey_nfc,
|
||||
apiKey: row.api_key ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
@@ -65,11 +71,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at'
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, yubikey_key1=excluded.yubikey_key1, yubikey_key2=excluded.yubikey_key2, yubikey_key3=excluded.yubikey_key3, yubikey_key4=excluded.yubikey_key4, yubikey_key5=excluded.yubikey_key5, yubikey_nfc=excluded.yubikey_nfc, api_key=excluded.api_key, updated_at=excluded.updated_at'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
@@ -91,6 +97,12 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
user.yubikeyNfc ? 1 : 0,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
@@ -104,8 +116,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
|
||||
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||
);
|
||||
const result = await safeBind(
|
||||
@@ -128,6 +140,12 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
user.yubikeyNfc ? 1 : 0,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
|
||||
+57
-7
@@ -41,6 +41,7 @@ import {
|
||||
deleteFolder as deleteStoredFolder,
|
||||
getAllFolders as listStoredFolders,
|
||||
getFolder as findStoredFolder,
|
||||
getFolderForUser as findStoredFolderForUser,
|
||||
getFoldersPage as listStoredFoldersPage,
|
||||
saveFolder as saveStoredFolder,
|
||||
} from './storage-folder-repo';
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
bulkUnarchiveCiphers as unarchiveStoredCiphers,
|
||||
getAllCiphers as listStoredCiphers,
|
||||
getCipher as findStoredCipher,
|
||||
getCipherForUser as findStoredCipherForUser,
|
||||
getCiphersByIds as listStoredCiphersByIds,
|
||||
getCiphersPage as listStoredCiphersPage,
|
||||
saveCipher as saveStoredCipher,
|
||||
@@ -60,10 +62,13 @@ import {
|
||||
} from './storage-cipher-repo';
|
||||
import {
|
||||
addAttachmentToCipher as attachStoredAttachmentToCipher,
|
||||
addAttachmentToCipherForUser as attachStoredAttachmentToCipherForUser,
|
||||
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
|
||||
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
|
||||
deleteAttachment as deleteStoredAttachment,
|
||||
deleteAttachmentForUser as deleteStoredAttachmentForUser,
|
||||
getAttachment as findStoredAttachment,
|
||||
getAttachmentForUser as findStoredAttachmentForUser,
|
||||
getAttachmentsByCipher as listStoredAttachmentsByCipher,
|
||||
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
|
||||
getAttachmentsByUserId as listStoredAttachmentsByUserId,
|
||||
@@ -75,6 +80,7 @@ import {
|
||||
deleteSend as deleteStoredSend,
|
||||
getAllSends as listStoredSends,
|
||||
getSend as findStoredSend,
|
||||
getSendForUser as findStoredSendForUser,
|
||||
getSendsByIds as listStoredSendsByIds,
|
||||
getSendsPage as listStoredSendsPage,
|
||||
incrementSendAccessCount as incrementStoredSendAccessCount,
|
||||
@@ -103,6 +109,7 @@ import {
|
||||
isKnownDevice as getKnownStoredDevice,
|
||||
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
||||
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
||||
rotateDeviceSessionStamp as rotateStoredDeviceSessionStamp,
|
||||
touchDeviceLastSeen as touchStoredDeviceLastSeen,
|
||||
upsertDevice as saveStoredDevice,
|
||||
updateDeviceName as updateStoredDeviceName,
|
||||
@@ -114,6 +121,7 @@ import {
|
||||
import {
|
||||
createAuthRequest as createStoredAuthRequest,
|
||||
getAuthRequestById as findStoredAuthRequestById,
|
||||
getAuthRequestByIdForUser as findStoredAuthRequestByIdForUser,
|
||||
listAuthRequestsByUserId as listStoredAuthRequestsByUserId,
|
||||
listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId,
|
||||
markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated,
|
||||
@@ -154,7 +162,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||
// differs from config.schema.version.
|
||||
const STORAGE_SCHEMA_VERSION = '2026-06-23-totp-login-replay';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-07-05-passkey-2fa';
|
||||
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
|
||||
|
||||
// D1-backed storage.
|
||||
@@ -391,8 +399,11 @@ export class StorageService {
|
||||
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
|
||||
}
|
||||
|
||||
async getAccountPasskeyCredentialsByUserId(userId: string): Promise<AccountPasskeyCredential[]> {
|
||||
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId);
|
||||
async getAccountPasskeyCredentialsByUserId(
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<AccountPasskeyCredential[]> {
|
||||
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
|
||||
}
|
||||
|
||||
async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> {
|
||||
@@ -403,8 +414,11 @@ export class StorageService {
|
||||
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
|
||||
}
|
||||
|
||||
async countAccountPasskeyCredentialsByUserId(userId: string): Promise<number> {
|
||||
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId);
|
||||
async countAccountPasskeyCredentialsByUserId(
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<number> {
|
||||
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
|
||||
}
|
||||
|
||||
async updateAccountPasskeyCounter(
|
||||
@@ -435,8 +449,12 @@ export class StorageService {
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAccountPasskeyCredential(userId: string, id: string): Promise<boolean> {
|
||||
return deleteStoredAccountPasskeyCredential(this.db, userId, id);
|
||||
async deleteAccountPasskeyCredential(
|
||||
userId: string,
|
||||
id: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<boolean> {
|
||||
return deleteStoredAccountPasskeyCredential(this.db, userId, id, purpose);
|
||||
}
|
||||
|
||||
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
|
||||
@@ -458,6 +476,10 @@ export class StorageService {
|
||||
return findStoredCipher(this.db, id);
|
||||
}
|
||||
|
||||
async getCipherForUser(id: string, userId: string): Promise<Cipher | null> {
|
||||
return findStoredCipherForUser(this.db, id, userId);
|
||||
}
|
||||
|
||||
async saveCipher(cipher: Cipher): Promise<void> {
|
||||
await saveStoredCipher(this.db, this.safeBind.bind(this), cipher);
|
||||
}
|
||||
@@ -508,6 +530,10 @@ export class StorageService {
|
||||
return findStoredFolder(this.db, id);
|
||||
}
|
||||
|
||||
async getFolderForUser(id: string, userId: string): Promise<Folder | null> {
|
||||
return findStoredFolderForUser(this.db, id, userId);
|
||||
}
|
||||
|
||||
async saveFolder(folder: Folder): Promise<void> {
|
||||
await saveStoredFolder(this.db, folder);
|
||||
}
|
||||
@@ -546,6 +572,10 @@ export class StorageService {
|
||||
return findStoredAttachment(this.db, id);
|
||||
}
|
||||
|
||||
async getAttachmentForUser(id: string, userId: string): Promise<Attachment | null> {
|
||||
return findStoredAttachmentForUser(this.db, id, userId);
|
||||
}
|
||||
|
||||
async saveAttachment(attachment: Attachment): Promise<void> {
|
||||
await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment);
|
||||
}
|
||||
@@ -554,6 +584,10 @@ export class StorageService {
|
||||
await deleteStoredAttachment(this.db, id);
|
||||
}
|
||||
|
||||
async deleteAttachmentForUser(id: string, userId: string): Promise<void> {
|
||||
await deleteStoredAttachmentForUser(this.db, id, userId);
|
||||
}
|
||||
|
||||
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
|
||||
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
|
||||
}
|
||||
@@ -574,6 +608,10 @@ export class StorageService {
|
||||
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
|
||||
}
|
||||
|
||||
async addAttachmentToCipherForUser(cipherId: string, attachmentId: string, userId: string): Promise<void> {
|
||||
await attachStoredAttachmentToCipherForUser(this.db, cipherId, attachmentId, userId);
|
||||
}
|
||||
|
||||
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
||||
await deleteStoredAttachmentsByCipher(this.db, cipherId);
|
||||
}
|
||||
@@ -634,6 +672,10 @@ export class StorageService {
|
||||
return findStoredSend(this.db, id);
|
||||
}
|
||||
|
||||
async getSendForUser(id: string, userId: string): Promise<Send | null> {
|
||||
return findStoredSendForUser(this.db, id, userId);
|
||||
}
|
||||
|
||||
async saveSend(send: Send): Promise<void> {
|
||||
await saveStoredSend(this.db, this.safeBind.bind(this), send);
|
||||
}
|
||||
@@ -720,6 +762,10 @@ export class StorageService {
|
||||
return findStoredDevice(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
async rotateDeviceSessionStamp(userId: string, deviceIdentifier: string, sessionStamp: string): Promise<boolean> {
|
||||
return rotateStoredDeviceSessionStamp(this.db, userId, deviceIdentifier, sessionStamp);
|
||||
}
|
||||
|
||||
async updateDeviceKeys(
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
@@ -783,6 +829,10 @@ export class StorageService {
|
||||
return findStoredAuthRequestById(this.db, id);
|
||||
}
|
||||
|
||||
async getAuthRequestByIdForUser(id: string, userId: string): Promise<AuthRequestRecord | null> {
|
||||
return findStoredAuthRequestByIdForUser(this.db, id, userId);
|
||||
}
|
||||
|
||||
async listAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
|
||||
return listStoredAuthRequestsByUserId(this.db, userId);
|
||||
}
|
||||
|
||||
+76
-5
@@ -14,15 +14,17 @@ export interface Env {
|
||||
WEBAUTHN_RP_ID?: string;
|
||||
WEBAUTHN_RP_NAME?: string;
|
||||
WEBAUTHN_ALLOWED_ORIGINS?: string;
|
||||
YUBICO_CLIENT_ID?: string;
|
||||
YUBICO_SECRET_KEY?: string;
|
||||
YUBICO_VALIDATION_URLS?: string;
|
||||
'globalSettings__yubico__clientId'?: string;
|
||||
'globalSettings__yubico__key'?: string;
|
||||
'globalSettings__yubico__validationUrls'?: string;
|
||||
}
|
||||
|
||||
export type UserRole = 'admin' | 'user';
|
||||
export type UserStatus = 'active' | 'banned';
|
||||
|
||||
// Sample JWT secret used by `.dev.vars.example`.
|
||||
// If runtime JWT_SECRET equals this value, treat it as unsafe.
|
||||
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
|
||||
|
||||
// Attachment model
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
@@ -53,6 +55,12 @@ export interface User {
|
||||
verifyDevices?: boolean;
|
||||
totpSecret: string | null;
|
||||
totpRecoveryCode: string | null;
|
||||
yubikeyKey1: string | null;
|
||||
yubikeyKey2: string | null;
|
||||
yubikeyKey3: string | null;
|
||||
yubikeyKey4: string | null;
|
||||
yubikeyKey5: string | null;
|
||||
yubikeyNfc: boolean;
|
||||
apiKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -116,6 +124,10 @@ export enum CipherType {
|
||||
SecureNote = 2,
|
||||
Card = 3,
|
||||
Identity = 4,
|
||||
SSHKey = 5,
|
||||
BankAccount = 6,
|
||||
DriversLicense = 7,
|
||||
Passport = 8,
|
||||
}
|
||||
|
||||
export interface CipherLoginUri {
|
||||
@@ -150,6 +162,52 @@ export interface CipherSshKey {
|
||||
keyFingerprint: string;
|
||||
}
|
||||
|
||||
export interface CipherBankAccount {
|
||||
bankName: string | null;
|
||||
nameOnAccount: string | null;
|
||||
accountType: string | null;
|
||||
accountNumber: string | null;
|
||||
routingNumber: string | null;
|
||||
branchNumber: string | null;
|
||||
pin: string | null;
|
||||
swiftCode: string | null;
|
||||
iban: string | null;
|
||||
bankContactPhone: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CipherDriversLicense {
|
||||
firstName: string | null;
|
||||
middleName: string | null;
|
||||
lastName: string | null;
|
||||
dateOfBirth: string | null;
|
||||
licenseNumber: string | null;
|
||||
issuingCountry: string | null;
|
||||
issuingState: string | null;
|
||||
issueDate: string | null;
|
||||
expirationDate: string | null;
|
||||
issuingAuthority: string | null;
|
||||
licenseClass: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CipherPassport {
|
||||
surname: string | null;
|
||||
givenName: string | null;
|
||||
dateOfBirth: string | null;
|
||||
sex: string | null;
|
||||
birthPlace: string | null;
|
||||
nationality: string | null;
|
||||
issuingCountry: string | null;
|
||||
passportNumber: string | null;
|
||||
passportType: string | null;
|
||||
nationalIdentificationNumber: string | null;
|
||||
issuingAuthority: string | null;
|
||||
issueDate: string | null;
|
||||
expirationDate: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CipherIdentity {
|
||||
title: string | null;
|
||||
firstName: string | null;
|
||||
@@ -200,6 +258,9 @@ export interface Cipher {
|
||||
identity: CipherIdentity | null;
|
||||
secureNote: CipherSecureNote | null;
|
||||
sshKey: CipherSshKey | null;
|
||||
bankAccount?: CipherBankAccount | null;
|
||||
driversLicense?: CipherDriversLicense | null;
|
||||
passport?: CipherPassport | null;
|
||||
fields: CipherField[] | null;
|
||||
passwordHistory: PasswordHistory[] | null;
|
||||
reprompt: number;
|
||||
@@ -244,6 +305,7 @@ export type AccountPasskeyPrfStatus = 0 | 1 | 2;
|
||||
export interface AccountPasskeyCredential {
|
||||
id: string;
|
||||
userId: string;
|
||||
purpose: 'login' | 'twoFactor';
|
||||
name: string;
|
||||
publicKey: string;
|
||||
credentialId: string;
|
||||
@@ -259,7 +321,12 @@ export interface AccountPasskeyCredential {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type AccountPasskeyChallengeScope = 'Authentication' | 'CreateCredential' | 'UpdateKeySet';
|
||||
export type AccountPasskeyChallengeScope =
|
||||
| 'Authentication'
|
||||
| 'CreateCredential'
|
||||
| 'UpdateKeySet'
|
||||
| 'TwoFactorAuthentication'
|
||||
| 'TwoFactorCreate';
|
||||
|
||||
export interface AccountPasskeyChallenge {
|
||||
challengeHash: string;
|
||||
@@ -502,6 +569,7 @@ export interface ProfileResponse {
|
||||
masterPasswordHint: string | null;
|
||||
culture: string;
|
||||
twoFactorEnabled: boolean;
|
||||
yubikeyEnabled?: boolean;
|
||||
key: string;
|
||||
privateKey: string | null;
|
||||
accountKeys: any | null;
|
||||
@@ -532,6 +600,9 @@ export interface CipherResponse {
|
||||
identity: CipherIdentity | null;
|
||||
secureNote: CipherSecureNote | null;
|
||||
sshKey: CipherSshKey | null;
|
||||
bankAccount: CipherBankAccount | null;
|
||||
driversLicense: CipherDriversLicense | null;
|
||||
passport: CipherPassport | null;
|
||||
fields: CipherField[] | null;
|
||||
passwordHistory: PasswordHistory[] | null;
|
||||
reprompt: number;
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
WebAuthnPrfDecryptionOption,
|
||||
} from '../types';
|
||||
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
|
||||
import { getConfiguredWebAuthnAllowedOrigins } from './origins';
|
||||
|
||||
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
|
||||
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
|
||||
@@ -32,6 +33,44 @@ function textBytes(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
function hexByte(value: number): string {
|
||||
return value.toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
function dotNetGuidBytesToUuid(bytes: Uint8Array): string | null {
|
||||
if (bytes.length !== 16) return null;
|
||||
return [
|
||||
[bytes[3], bytes[2], bytes[1], bytes[0]].map(hexByte).join(''),
|
||||
[bytes[5], bytes[4]].map(hexByte).join(''),
|
||||
[bytes[7], bytes[6]].map(hexByte).join(''),
|
||||
[bytes[8], bytes[9]].map(hexByte).join(''),
|
||||
Array.from(bytes.slice(10, 16)).map(hexByte).join(''),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
function uuidToDotNetGuidBytes(value: string): Uint8Array | null {
|
||||
const match = String(value || '').trim().match(
|
||||
/^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i
|
||||
);
|
||||
if (!match) return null;
|
||||
const hex = match.slice(1).join('');
|
||||
const bytes = new Uint8Array(16);
|
||||
for (let i = 0; i < 16; i += 1) {
|
||||
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return new Uint8Array([
|
||||
bytes[3], bytes[2], bytes[1], bytes[0],
|
||||
bytes[5], bytes[4],
|
||||
bytes[7], bytes[6],
|
||||
bytes[8], bytes[9],
|
||||
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizeWebAuthnBase64(value: unknown): string {
|
||||
return String(value || '').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
async function importHmacKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
||||
}
|
||||
@@ -59,7 +98,9 @@ export async function sha256Base64Url(value: string): Promise<string> {
|
||||
}
|
||||
|
||||
export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number {
|
||||
return scope === 'CreateCredential' ? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS : ACCOUNT_PASSKEY_TOKEN_TTL_MS;
|
||||
return scope === 'CreateCredential' || scope === 'TwoFactorCreate'
|
||||
? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS
|
||||
: ACCOUNT_PASSKEY_TOKEN_TTL_MS;
|
||||
}
|
||||
|
||||
export async function createAccountPasskeyToken(
|
||||
@@ -119,33 +160,22 @@ export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: s
|
||||
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
|
||||
const rpId = configuredRpId || url.hostname;
|
||||
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
|
||||
const configuredOrigins = String(env.WEBAUTHN_ALLOWED_ORIGINS || '')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
const configuredOrigins = getConfiguredWebAuthnAllowedOrigins(env);
|
||||
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) };
|
||||
}
|
||||
|
||||
export function userIdToWebAuthnUserId(userId: string): Uint8Array {
|
||||
return textBytes(userId);
|
||||
return uuidToDotNetGuidBytes(userId) || textBytes(userId);
|
||||
}
|
||||
|
||||
export function userHandleToUserId(userHandle: string | undefined): string | null {
|
||||
if (!userHandle) return null;
|
||||
try {
|
||||
const decoded = new TextDecoder().decode(base64UrlToBytes(userHandle));
|
||||
const bytes = base64UrlToBytes(userHandle);
|
||||
const officialGuid = dotNetGuidBytesToUuid(bytes);
|
||||
if (officialGuid) return officialGuid;
|
||||
const decoded = new TextDecoder().decode(bytes);
|
||||
return decoded.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -207,17 +237,17 @@ export function normalizeRegistrationResponse(raw: unknown): RegistrationRespons
|
||||
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
|
||||
if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null;
|
||||
return {
|
||||
id: String(input.id),
|
||||
rawId: String(input.rawId),
|
||||
id: normalizeWebAuthnBase64(input.id),
|
||||
rawId: normalizeWebAuthnBase64(input.rawId),
|
||||
type: 'public-key',
|
||||
authenticatorAttachment: input.authenticatorAttachment,
|
||||
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
|
||||
response: {
|
||||
attestationObject: String(response.attestationObject),
|
||||
clientDataJSON: String(clientDataJSON),
|
||||
authenticatorData: response.authenticatorData ? String(response.authenticatorData) : undefined,
|
||||
attestationObject: normalizeWebAuthnBase64(response.attestationObject),
|
||||
clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
|
||||
authenticatorData: response.authenticatorData ? normalizeWebAuthnBase64(response.authenticatorData) : undefined,
|
||||
transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransportFuture[] : undefined,
|
||||
publicKey: response.publicKey ? String(response.publicKey) : undefined,
|
||||
publicKey: response.publicKey ? normalizeWebAuthnBase64(response.publicKey) : undefined,
|
||||
publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined,
|
||||
},
|
||||
};
|
||||
@@ -230,16 +260,16 @@ export function normalizeAuthenticationResponse(raw: unknown): AuthenticationRes
|
||||
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
|
||||
if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null;
|
||||
return {
|
||||
id: String(input.id),
|
||||
rawId: String(input.rawId),
|
||||
id: normalizeWebAuthnBase64(input.id),
|
||||
rawId: normalizeWebAuthnBase64(input.rawId),
|
||||
type: 'public-key',
|
||||
authenticatorAttachment: input.authenticatorAttachment,
|
||||
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
|
||||
response: {
|
||||
authenticatorData: String(response.authenticatorData),
|
||||
clientDataJSON: String(clientDataJSON),
|
||||
signature: String(response.signature),
|
||||
userHandle: response.userHandle ? String(response.userHandle) : undefined,
|
||||
authenticatorData: normalizeWebAuthnBase64(response.authenticatorData),
|
||||
clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
|
||||
signature: normalizeWebAuthnBase64(response.signature),
|
||||
userHandle: response.userHandle ? normalizeWebAuthnBase64(response.userHandle) : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const API_KEY_HASH_PREFIX = 'sha256:';
|
||||
|
||||
export function constantTimeEquals(a: string, b: string): boolean {
|
||||
const encA = new TextEncoder().encode(a);
|
||||
const encB = new TextEncoder().encode(b);
|
||||
if (encA.length !== encB.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < encA.length; i++) {
|
||||
diff |= encA[i] ^ encB[i];
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function toHex(bytes: ArrayBuffer): string {
|
||||
return [...new Uint8Array(bytes)]
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function isStoredApiKeyHash(value: string | null | undefined): boolean {
|
||||
return String(value || '').startsWith(API_KEY_HASH_PREFIX);
|
||||
}
|
||||
|
||||
export async function hashApiKey(apiKey: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(apiKey));
|
||||
return `${API_KEY_HASH_PREFIX}${toHex(digest)}`;
|
||||
}
|
||||
|
||||
export async function verifyApiKey(apiKey: string, storedApiKey: string | null | undefined): Promise<boolean> {
|
||||
const stored = String(storedApiKey || '').trim();
|
||||
if (!isStoredApiKeyHash(stored)) return false;
|
||||
|
||||
const hashed = await hashApiKey(apiKey);
|
||||
return constantTimeEquals(hashed, stored);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { DEFAULT_DEV_SECRET, Env } from '../types';
|
||||
import { Env } from '../types';
|
||||
import { errorResponse } from './response';
|
||||
|
||||
export interface DirectUploadPayload {
|
||||
@@ -19,6 +19,8 @@ interface ParseDirectUploadOptions {
|
||||
fileNameMismatchMessage?: string;
|
||||
}
|
||||
|
||||
const MULTIPART_FORMDATA_OVERHEAD_BYTES = 256 * 1024;
|
||||
|
||||
export function buildDirectUploadUrl(request: Request, path: string, token: string): string {
|
||||
const version = '2023-11-03';
|
||||
const expiresAt = '2099-12-31T23:59:59Z';
|
||||
@@ -28,12 +30,16 @@ export function buildDirectUploadUrl(request: Request, path: string, token: stri
|
||||
|
||||
export function getSafeJwtSecret(env: Env): string | null {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
|
||||
return null;
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function getMultipartRequestMaxBytes(maxFileSize: number): number {
|
||||
return maxFileSize + MULTIPART_FORMDATA_OVERHEAD_BYTES;
|
||||
}
|
||||
|
||||
function parseContentLength(request: Request): number | null {
|
||||
const raw = request.headers.get('content-length');
|
||||
if (!raw) return null;
|
||||
@@ -59,6 +65,10 @@ export async function parseDirectUploadPayload(
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const declaredSize = parseContentLength(request);
|
||||
if (declaredSize !== null && declaredSize > getMultipartRequestMaxBytes(maxFileSize)) {
|
||||
return errorResponse(tooLargeMessage, 413);
|
||||
}
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('data') as File | null;
|
||||
if (!file) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Env } from '../types';
|
||||
|
||||
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>();
|
||||
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);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Env, ProfileResponse, User } from '../types';
|
||||
import { buildAccountKeys } from './user-decryption';
|
||||
import { isYubiKeyEnabled } from './yubico-otp';
|
||||
|
||||
export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
|
||||
void env;
|
||||
@@ -16,7 +17,8 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: user.masterPasswordHint,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
twoFactorEnabled: !!user.totpSecret || isYubiKeyEnabled(user),
|
||||
yubikeyEnabled: isYubiKeyEnabled(user),
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys,
|
||||
|
||||
+25
-18
@@ -1,4 +1,10 @@
|
||||
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 DEFAULT_CORS_HEADERS = [
|
||||
@@ -18,35 +24,31 @@ const DEFAULT_CORS_HEADERS = [
|
||||
'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 {
|
||||
return (
|
||||
path.startsWith('/icons/')
|
||||
|| path.startsWith('/fill-assist/')
|
||||
|| path === '/v1/assetlinks:check'
|
||||
|| path === '/api/v1/assetlinks:check'
|
||||
|| path === '/config'
|
||||
|| path === '/api/config'
|
||||
|| path === '/api/version'
|
||||
);
|
||||
}
|
||||
|
||||
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 origin = request.headers.get('Origin');
|
||||
if (!origin) {
|
||||
const originHeader = request.headers.get('Origin');
|
||||
if (!originHeader) {
|
||||
return isWildcardCorsPath(url.pathname)
|
||||
? { allowOrigin: '*', allowCredentials: false }
|
||||
: { allowOrigin: null, allowCredentials: false };
|
||||
}
|
||||
const origin = normalizeOrigin(originHeader);
|
||||
if (origin === url.origin) {
|
||||
return { allowOrigin: origin, allowCredentials: true };
|
||||
}
|
||||
if (isExtensionOrigin(origin)) {
|
||||
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
|
||||
return { allowOrigin: origin, allowCredentials: true };
|
||||
}
|
||||
if (isWildcardCorsPath(url.pathname)) {
|
||||
@@ -55,7 +57,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
|
||||
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') || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
@@ -69,7 +71,7 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
||||
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
||||
};
|
||||
|
||||
const corsPolicy = getCorsPolicy(request);
|
||||
const corsPolicy = getCorsPolicy(request, env);
|
||||
if (corsPolicy.allowOrigin) {
|
||||
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
||||
if (corsPolicy.allowCredentials) {
|
||||
@@ -83,7 +85,8 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
||||
|
||||
export function applyCors(
|
||||
request: Request,
|
||||
response: Response
|
||||
response: Response,
|
||||
env: Env
|
||||
): Response {
|
||||
// WebSocket upgrade responses must be returned untouched.
|
||||
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
|
||||
@@ -92,7 +95,7 @@ export function applyCors(
|
||||
}
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
const corsHeaders = buildCorsHeaders(request);
|
||||
const corsHeaders = buildCorsHeaders(request, env);
|
||||
for (const [k, v] of Object.entries(corsHeaders)) {
|
||||
headers.set(k, v);
|
||||
}
|
||||
@@ -136,6 +139,10 @@ export function errorResponse(message: string, status: number = 400): Response {
|
||||
);
|
||||
}
|
||||
|
||||
export function unsupportedResponse(message: string = 'This feature is not supported by this server.'): Response {
|
||||
return errorResponse(message, 501);
|
||||
}
|
||||
|
||||
// Identity endpoint error response (for /identity/connect/token)
|
||||
export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response {
|
||||
return jsonResponse(
|
||||
@@ -152,10 +159,10 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
|
||||
}
|
||||
|
||||
// Handle CORS preflight
|
||||
export function handleCors(request: Request): Response {
|
||||
export function handleCors(request: Request, env: Env): Response {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: buildCorsHeaders(request),
|
||||
headers: buildCorsHeaders(request, env),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { Env, User } from '../types';
|
||||
|
||||
const YUBIKEY_PUBLIC_ID_LENGTH = 12;
|
||||
const YUBIKEY_MIN_OTP_LENGTH = 32;
|
||||
const YUBIKEY_MAX_OTP_LENGTH = 48;
|
||||
const YUBICO_DEFAULT_VALIDATION_URL = 'https://api.yubico.com/wsapi/2.0/verify';
|
||||
const YUBICO_GET_API_KEY_URL = 'https://upgrade.yubico.com/getapikey/';
|
||||
const MODHEX_RE = /^[cbdefghijklnrtuv]+$/;
|
||||
|
||||
export interface YubicoApiCredentials {
|
||||
clientId: string;
|
||||
secretKey: string;
|
||||
}
|
||||
|
||||
export function normalizeYubiKeyOtp(input: string): string {
|
||||
return String(input || '').replace(/\s+/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
export function yubiKeyPublicIdFromOtp(input: string): string | null {
|
||||
const otp = normalizeYubiKeyOtp(input);
|
||||
if (otp.length === YUBIKEY_PUBLIC_ID_LENGTH && MODHEX_RE.test(otp)) return otp;
|
||||
if (otp.length < YUBIKEY_MIN_OTP_LENGTH || otp.length > YUBIKEY_MAX_OTP_LENGTH) return null;
|
||||
if (!MODHEX_RE.test(otp)) return null;
|
||||
return otp.slice(0, YUBIKEY_PUBLIC_ID_LENGTH);
|
||||
}
|
||||
|
||||
export function isYubiKeyPublicId(input: string): boolean {
|
||||
const value = normalizeYubiKeyOtp(input);
|
||||
return value.length === YUBIKEY_PUBLIC_ID_LENGTH && MODHEX_RE.test(value);
|
||||
}
|
||||
|
||||
function isYubiKeyOtp(input: string): boolean {
|
||||
const otp = normalizeYubiKeyOtp(input);
|
||||
return otp.length >= YUBIKEY_MIN_OTP_LENGTH && otp.length <= YUBIKEY_MAX_OTP_LENGTH && MODHEX_RE.test(otp);
|
||||
}
|
||||
|
||||
export function userYubiKeyPublicIds(user: User): string[] {
|
||||
return [
|
||||
user.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function isYubiKeyEnabled(user: User): boolean {
|
||||
return userYubiKeyPublicIds(user).length > 0;
|
||||
}
|
||||
|
||||
export function yubicoCredentialsFromEnv(env: Env): YubicoApiCredentials | null {
|
||||
const clientId = String(env['globalSettings__yubico__clientId'] || env.YUBICO_CLIENT_ID || '').trim();
|
||||
const secretKey = String(env['globalSettings__yubico__key'] || env.YUBICO_SECRET_KEY || '').trim();
|
||||
return clientId ? { clientId, secretKey } : null;
|
||||
}
|
||||
|
||||
function randomNonce(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function parseYubicoResponse(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const idx = line.indexOf('=');
|
||||
if (idx <= 0) continue;
|
||||
out[line.slice(0, idx)] = line.slice(idx + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function base64ToBytes(input: string): Uint8Array {
|
||||
const binary = atob(input);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) out[index] = binary.charCodeAt(index);
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToBase64(input: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of input) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function hmacSha1Base64(base64Key: string, message: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
base64ToBytes(base64Key),
|
||||
{ name: 'HMAC', hash: 'SHA-1' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
return bytesToBase64(new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message))));
|
||||
}
|
||||
|
||||
function constantTimeStringEquals(a: string, b: string): boolean {
|
||||
const aBytes = new TextEncoder().encode(a);
|
||||
const bBytes = new TextEncoder().encode(b);
|
||||
let diff = aBytes.length ^ bBytes.length;
|
||||
for (let index = 0; index < aBytes.length && index < bBytes.length; index += 1) {
|
||||
diff |= aBytes[index] ^ bBytes[index];
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function canonicalQuery(params: URLSearchParams): string {
|
||||
return Array.from(params.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
function validationUrls(env: Env): string[] {
|
||||
const configured = String(env['globalSettings__yubico__validationUrls'] || env.YUBICO_VALIDATION_URLS || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
return configured.length > 0 ? configured : [YUBICO_DEFAULT_VALIDATION_URL];
|
||||
}
|
||||
|
||||
export async function requestYubicoApiCredentials(email: string, otpInput: string): Promise<YubicoApiCredentials | null> {
|
||||
const otp = normalizeYubiKeyOtp(otpInput);
|
||||
if (!isYubiKeyOtp(otp)) return null;
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set('email', String(email || '').trim().toLowerCase());
|
||||
body.set('otp', otp);
|
||||
body.set('terms_conditions', 'consented');
|
||||
|
||||
const response = await fetch(YUBICO_GET_API_KEY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
|
||||
const html = await response.text();
|
||||
const clientId = /Client ID:<\/th>\s*<td><b>(\d+)<\/b>/i.exec(html)?.[1] || '';
|
||||
const secretKey = /Secret key:<\/th>\s*<td><code>([^<]+)<\/code>/i.exec(html)?.[1] || '';
|
||||
return clientId ? { clientId, secretKey } : null;
|
||||
}
|
||||
|
||||
export async function verifyYubicoOtp(
|
||||
env: Env,
|
||||
otpInput: string,
|
||||
credentials: YubicoApiCredentials | null = yubicoCredentialsFromEnv(env)
|
||||
): Promise<boolean> {
|
||||
const otp = normalizeYubiKeyOtp(otpInput);
|
||||
if (!isYubiKeyOtp(otp)) return false;
|
||||
|
||||
const clientId = String(credentials?.clientId || '').trim();
|
||||
if (!clientId) return false;
|
||||
|
||||
const nonce = randomNonce();
|
||||
const secretKey = String(credentials?.secretKey || '').trim();
|
||||
const params = new URLSearchParams({
|
||||
id: clientId,
|
||||
nonce,
|
||||
otp,
|
||||
});
|
||||
if (secretKey) {
|
||||
try {
|
||||
params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params)));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const baseUrl of validationUrls(env)) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}?${params.toString()}`, { method: 'GET' });
|
||||
if (!response.ok) continue;
|
||||
const parsed = parseYubicoResponse(await response.text());
|
||||
if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue;
|
||||
if (secretKey) {
|
||||
if (!parsed.h) continue;
|
||||
const signedParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (key !== 'h') signedParams.set(key, value);
|
||||
}
|
||||
if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>NodeWarden WebAuthn Connector</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--primary: #2563eb;
|
||||
--primary-strong: #1d4ed8;
|
||||
--text: #101828;
|
||||
--muted: #667085;
|
||||
--line: #d8e0ec;
|
||||
--panel: #ffffff;
|
||||
--surface: #f6f8fb;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 28px 18px;
|
||||
}
|
||||
|
||||
.connector-card {
|
||||
width: min(100%, 430px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 18px 44px rgba(16, 24, 40, 0.10);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 26px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.remember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: #344054;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.remember input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 48px;
|
||||
width: 100%;
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 10px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--primary-strong);
|
||||
border-color: var(--primary-strong);
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: none;
|
||||
border-radius: 10px;
|
||||
padding: 11px 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.msg.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.msg.error {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.msg.success {
|
||||
border: 1px solid #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="connector-card" aria-labelledby="title">
|
||||
<div class="brand">
|
||||
<img src="/nodewarden-logo.svg" alt="NodeWarden" />
|
||||
<strong>NodeWarden</strong>
|
||||
</div>
|
||||
<h1 id="title">Verify your identity</h1>
|
||||
<p id="subtitle">Use your security key to finish two-step verification.</p>
|
||||
<div class="form">
|
||||
<div id="msg" class="msg" role="status" aria-live="polite"></div>
|
||||
<label class="remember">
|
||||
<input id="remember" type="checkbox" />
|
||||
<span id="remember-label">Trust this device for 30 days</span>
|
||||
</label>
|
||||
<button id="webauthn-button" type="button">Read security key</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var sentSuccess = false;
|
||||
var allowedParentOriginsPromise = null;
|
||||
|
||||
var text = pickText(params.get("locale") || navigator.language || "en");
|
||||
document.documentElement.lang = params.get("locale") || navigator.language || "en";
|
||||
|
||||
var titleEl = document.getElementById("title");
|
||||
var subtitleEl = document.getElementById("subtitle");
|
||||
var rememberEl = document.getElementById("remember");
|
||||
var rememberLabelEl = document.getElementById("remember-label");
|
||||
var buttonEl = document.getElementById("webauthn-button");
|
||||
var msgEl = document.getElementById("msg");
|
||||
|
||||
titleEl.textContent = text.title;
|
||||
subtitleEl.textContent = text.subtitle;
|
||||
rememberLabelEl.textContent = text.remember;
|
||||
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
|
||||
|
||||
buttonEl.addEventListener("click", start);
|
||||
|
||||
function pickText(locale) {
|
||||
var normalized = String(locale || "en").toLowerCase();
|
||||
if (normalized.indexOf("zh") === 0) {
|
||||
return {
|
||||
title: "\u9a8c\u8bc1\u8eab\u4efd",
|
||||
subtitle: "\u4f7f\u7528\u5b89\u5168\u5bc6\u94a5\u5b8c\u6210\u4e24\u6b65\u9a8c\u8bc1\u3002",
|
||||
remember: "30 \u5929\u5185\u4fe1\u4efb\u6b64\u8bbe\u5907",
|
||||
button: "\u8bfb\u53d6\u5b89\u5168\u5bc6\u94a5",
|
||||
awaiting: "\u7b49\u5f85\u5b89\u5168\u5bc6\u94a5\u4ea4\u4e92...",
|
||||
success: "\u9a8c\u8bc1\u5b8c\u6210",
|
||||
unsupported: "\u5f53\u524d\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u5b89\u5168\u5bc6\u94a5",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Verify your identity",
|
||||
subtitle: "Use your security key to finish two-step verification.",
|
||||
remember: "Trust this device for 30 days",
|
||||
button: "Read security key",
|
||||
awaiting: "Awaiting security key interaction...",
|
||||
success: "Verification complete",
|
||||
unsupported: "This browser does not support security keys",
|
||||
};
|
||||
}
|
||||
|
||||
function decodeRepeated(value) {
|
||||
if (!value) return "";
|
||||
var out = String(value);
|
||||
for (var i = 0; i < 2; i += 1) {
|
||||
try {
|
||||
var next = decodeURIComponent(out);
|
||||
if (next === out) break;
|
||||
out = next;
|
||||
} catch (_error) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeOrigin(value) {
|
||||
if (!value) return "";
|
||||
try {
|
||||
var url = new URL(value);
|
||||
if (!url.protocol || !url.host) return "";
|
||||
return url.protocol + "//" + url.host;
|
||||
} catch (_error) {
|
||||
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 "";
|
||||
}
|
||||
|
||||
function safeShallowCopy(source) {
|
||||
var copy = {};
|
||||
if (!source || typeof source !== "object") return copy;
|
||||
Object.keys(source).forEach(function (key) {
|
||||
if (key === "__proto__" || key === "prototype" || key === "constructor") return;
|
||||
copy[key] = source[key];
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
|
||||
async function postResult(message) {
|
||||
var parentOrigin = trustedParentOrigin(await allowedParentOrigins());
|
||||
if (parentOrigin) {
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage(message, parentOrigin);
|
||||
}
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage(message, parentOrigin);
|
||||
}
|
||||
}
|
||||
window.postMessage(message, window.location.origin);
|
||||
}
|
||||
|
||||
function showMessage(kind, message) {
|
||||
msgEl.textContent = String(message || "");
|
||||
msgEl.className = "msg show " + kind;
|
||||
}
|
||||
|
||||
function decodeBase64Unicode(value) {
|
||||
var input = String(value || "").replace(/ /g, "+");
|
||||
try {
|
||||
return decodeURIComponent(Array.prototype.map.call(atob(input), function (char) {
|
||||
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(""));
|
||||
} catch (_error) {
|
||||
var normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
|
||||
return decodeURIComponent(Array.prototype.map.call(atob(normalized), function (char) {
|
||||
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(""));
|
||||
}
|
||||
}
|
||||
|
||||
function bytesFromBase64Url(value) {
|
||||
var normalized = String(value || "").replace(/-/g, "+").replace(/_/g, "/");
|
||||
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
|
||||
var binary = atob(normalized);
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function base64UrlFromBuffer(value) {
|
||||
if (!value) return undefined;
|
||||
var bytes = value instanceof Uint8Array
|
||||
? value
|
||||
: new Uint8Array(value);
|
||||
var binary = "";
|
||||
for (var i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function readPublicKeyOptions() {
|
||||
var data = params.get("data");
|
||||
if (!data) throw new Error("No data.");
|
||||
var decoded = decodeBase64Unicode(data);
|
||||
if (params.get("v") === "1") {
|
||||
return JSON.parse(decoded);
|
||||
}
|
||||
var payload = JSON.parse(decoded);
|
||||
return typeof payload.data === "string" ? JSON.parse(payload.data) : payload.data;
|
||||
}
|
||||
|
||||
function normalizeOptions(options) {
|
||||
if (!options || typeof options !== "object") throw new Error("Cannot parse data.");
|
||||
var copy = safeShallowCopy(options);
|
||||
copy.challenge = bytesFromBase64Url(copy.challenge);
|
||||
if (Array.isArray(copy.allowCredentials)) {
|
||||
copy.allowCredentials = copy.allowCredentials.map(function (credential) {
|
||||
var next = safeShallowCopy(credential);
|
||||
next.id = bytesFromBase64Url(credential && credential.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function credentialToDataString(credential) {
|
||||
var response = credential.response;
|
||||
var clientDataJSON = base64UrlFromBuffer(response.clientDataJSON);
|
||||
var data = {
|
||||
id: credential.id,
|
||||
rawId: base64UrlFromBuffer(credential.rawId),
|
||||
type: credential.type,
|
||||
extensions: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
|
||||
clientExtensionResults: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
|
||||
response: {
|
||||
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
|
||||
clientDataJson: clientDataJSON,
|
||||
clientDataJSON: clientDataJSON,
|
||||
signature: base64UrlFromBuffer(response.signature),
|
||||
userHandle: response.userHandle ? base64UrlFromBuffer(response.userHandle) : undefined,
|
||||
},
|
||||
};
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (sentSuccess) return;
|
||||
if (!("credentials" in navigator) || !window.PublicKeyCredential) {
|
||||
showMessage("error", text.unsupported);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
msgEl.className = "msg";
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.textContent = decodeRepeated(params.get("btnAwaitingInteractionText")) || text.awaiting;
|
||||
var publicKey = normalizeOptions(readPublicKeyOptions());
|
||||
var credential = await navigator.credentials.get({ publicKey: publicKey });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error("No security key was selected.");
|
||||
}
|
||||
await postResult({
|
||||
command: "webAuthnResult",
|
||||
data: credentialToDataString(credential),
|
||||
remember: rememberEl.checked,
|
||||
});
|
||||
sentSuccess = true;
|
||||
showMessage("success", text.success);
|
||||
} catch (error) {
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
|
||||
showMessage("error", error && error.message ? error.message : String(error || "WebAuthn failed."));
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+74
-21
@@ -20,7 +20,7 @@ import {
|
||||
loadProfileSnapshot,
|
||||
saveProfileSnapshot,
|
||||
revokeCurrentSession,
|
||||
getTotpStatus,
|
||||
getTwoFactorProviderStatus,
|
||||
getVaultRevisionDate,
|
||||
saveSession,
|
||||
stripProfileSecrets,
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
type PendingPasskeyPassword,
|
||||
type PendingTotp,
|
||||
} from '@/lib/app-auth';
|
||||
import { assertTwoFactorPasskey } from '@/lib/account-passkeys';
|
||||
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
|
||||
import useAdminActions from '@/hooks/useAdminActions';
|
||||
import useBackupActions from '@/hooks/useBackupActions';
|
||||
@@ -152,6 +153,8 @@ const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
|
||||
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
|
||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101;
|
||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
|
||||
type ThemePreference = 'system' | 'light' | 'dark';
|
||||
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
|
||||
@@ -225,6 +228,7 @@ export default function App() {
|
||||
hint: null,
|
||||
});
|
||||
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
|
||||
const [hashPathRaw, setHashPathRaw] = useState(() => (typeof window !== 'undefined' ? window.location.hash || '' : ''));
|
||||
const [unlockPassword, setUnlockPassword] = useState('');
|
||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
|
||||
@@ -292,15 +296,16 @@ export default function App() {
|
||||
}, [pushToast]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncInviteFromUrl = () => {
|
||||
const syncUrlState = () => {
|
||||
setInviteCodeFromUrl(readInviteCodeFromUrl());
|
||||
setHashPathRaw(window.location.hash || '');
|
||||
};
|
||||
syncInviteFromUrl();
|
||||
window.addEventListener('hashchange', syncInviteFromUrl);
|
||||
window.addEventListener('popstate', syncInviteFromUrl);
|
||||
syncUrlState();
|
||||
window.addEventListener('hashchange', syncUrlState);
|
||||
window.addEventListener('popstate', syncUrlState);
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', syncInviteFromUrl);
|
||||
window.removeEventListener('popstate', syncInviteFromUrl);
|
||||
window.removeEventListener('hashchange', syncUrlState);
|
||||
window.removeEventListener('popstate', syncUrlState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -654,19 +659,38 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectTotpProvider(providerType: number) {
|
||||
if (totpSubmitting) return;
|
||||
setPendingTotp((current) => {
|
||||
if (!current || current.providerType === providerType) return current;
|
||||
const canUseProvider = current.availableProviders.includes(providerType);
|
||||
if (!canUseProvider) return current;
|
||||
return {
|
||||
...current,
|
||||
providerType,
|
||||
providerData: current.providerDataByType[providerType],
|
||||
};
|
||||
});
|
||||
setTotpCode('');
|
||||
}
|
||||
|
||||
async function handleTotpVerify() {
|
||||
if (totpSubmitting) return;
|
||||
if (!pendingTotp) return;
|
||||
if (!totpCode.trim()) {
|
||||
pushToast('error', t('txt_please_input_totp_code'));
|
||||
const isPasskeyTwoFactor = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN;
|
||||
if (!isPasskeyTwoFactor && !totpCode.trim()) {
|
||||
pushToast('error', pendingTotp.providerType === TWO_FACTOR_PROVIDER_YUBIKEY ? t('txt_please_input_yubikey_otp') : t('txt_please_input_totp_code'));
|
||||
return;
|
||||
}
|
||||
setTotpSubmitting(true);
|
||||
try {
|
||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||
const token = isPasskeyTwoFactor
|
||||
? await assertTwoFactorPasskey(pendingTotp.providerData)
|
||||
: totpCode;
|
||||
const login = await performTotpLogin(pendingTotp, token, rememberDevice);
|
||||
await finalizeLogin(login);
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
||||
pushToast('error', error instanceof Error ? error.message : pendingTotp.providerType === 3 ? t('txt_yubikey_verify_failed') : isPasskeyTwoFactor ? t('txt_passkey_verification_failed') : t('txt_totp_verify_failed'));
|
||||
} finally {
|
||||
setTotpSubmitting(false);
|
||||
}
|
||||
@@ -951,11 +975,14 @@ export default function App() {
|
||||
confirm={null}
|
||||
onCancelConfirm={() => {}}
|
||||
pendingTotpOpen={false}
|
||||
pendingTotpProviderType={0}
|
||||
pendingTotpAvailableProviders={[]}
|
||||
totpCode=""
|
||||
rememberDevice={false}
|
||||
onTotpCodeChange={() => {}}
|
||||
onRememberDeviceChange={() => {}}
|
||||
onConfirmTotp={() => {}}
|
||||
onSelectTotpProvider={() => {}}
|
||||
onCancelTotp={() => {}}
|
||||
onUseRecoveryCode={() => {}}
|
||||
totpSubmitting={false}
|
||||
@@ -1081,9 +1108,9 @@ export default function App() {
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const totpStatusQuery = useQuery({
|
||||
queryKey: ['totp-status', vaultCacheKey || session?.email],
|
||||
queryFn: () => getTotpStatus(authedFetch),
|
||||
const twoFactorStatusQuery = useQuery({
|
||||
queryKey: ['two-factor-status', vaultCacheKey || session?.email],
|
||||
queryFn: () => getTwoFactorProviderStatus(authedFetch),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
@@ -1140,7 +1167,6 @@ export default function App() {
|
||||
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
|
||||
await respondToAuthRequest(authedFetch, authRequest.id, {
|
||||
key,
|
||||
masterPasswordHash: null,
|
||||
deviceIdentifier: getCurrentDeviceIdentifier(),
|
||||
requestApproved: true,
|
||||
});
|
||||
@@ -1816,7 +1842,7 @@ export default function App() {
|
||||
onNotify: pushToast,
|
||||
onProfileUpdated: setProfile,
|
||||
onSetConfirm: setConfirm,
|
||||
refetchTotpStatus: totpStatusQuery.refetch,
|
||||
refetchTwoFactorStatus: twoFactorStatusQuery.refetch,
|
||||
refetchAuthorizedDevices: authorizedDevicesQuery.refetch,
|
||||
});
|
||||
const adminActions = useAdminActions({
|
||||
@@ -1837,7 +1863,6 @@ export default function App() {
|
||||
await pendingAuthRequestsQuery.refetch();
|
||||
};
|
||||
|
||||
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
||||
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
||||
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
|
||||
const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
@@ -1939,6 +1964,7 @@ export default function App() {
|
||||
session,
|
||||
mobileLayout,
|
||||
mobileSidebarToggleKey,
|
||||
themePreference,
|
||||
importRoute: IMPORT_ROUTE,
|
||||
settingsHomeRoute: SETTINGS_HOME_ROUTE,
|
||||
settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE,
|
||||
@@ -1953,7 +1979,9 @@ export default function App() {
|
||||
invites: invitesQuery.data || [],
|
||||
adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data),
|
||||
adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '',
|
||||
totpEnabled: !!totpStatusQuery.data?.enabled,
|
||||
totpEnabled: !!twoFactorStatusQuery.data?.totpEnabled,
|
||||
yubikeyEnabled: !!twoFactorStatusQuery.data?.yubikeyEnabled,
|
||||
passkey2faEnabled: !!twoFactorStatusQuery.data?.passkeyEnabled,
|
||||
lockTimeoutMinutes,
|
||||
sessionTimeoutAction,
|
||||
authorizedDevices: authorizedDevicesQuery.data || [],
|
||||
@@ -1966,6 +1994,7 @@ export default function App() {
|
||||
onNavigate: navigate,
|
||||
onLogout: handleLogout,
|
||||
onNotify: pushToast,
|
||||
onThemePreferenceChange: setThemePreference,
|
||||
onImport: vaultSendActions.importVault,
|
||||
onImportEncryptedRaw: vaultSendActions.importEncryptedRaw,
|
||||
onExport: vaultSendActions.exportVault,
|
||||
@@ -2002,9 +2031,18 @@ export default function App() {
|
||||
onSavePasswordHint: accountSecurityActions.savePasswordHint,
|
||||
onEnableTotp: async (secret: string, token: string, masterPassword: string) => {
|
||||
await accountSecurityActions.enableTotp(secret, token, masterPassword);
|
||||
await totpStatusQuery.refetch();
|
||||
await twoFactorStatusQuery.refetch();
|
||||
},
|
||||
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
||||
onGetYubiKeySettings: accountSecurityActions.getYubiKeySettings,
|
||||
onSaveYubiKeySettings: accountSecurityActions.saveYubiKeySettings,
|
||||
onSaveYubiKeyApiCredentials: accountSecurityActions.saveYubiKeyApiCredentials,
|
||||
onBootstrapYubiKeyApiCredentials: accountSecurityActions.bootstrapYubiKeyApiCredentials,
|
||||
onDisableYubiKey: accountSecurityActions.disableYubiKey,
|
||||
onGetTwoFactorPasskeySettings: accountSecurityActions.getTwoFactorPasskeySettings,
|
||||
onCreateTwoFactorPasskey: accountSecurityActions.createTwoFactorPasskey,
|
||||
onDeleteTwoFactorPasskey: accountSecurityActions.deleteTwoFactorPasskey,
|
||||
onDisableTwoFactorPasskeys: accountSecurityActions.disableTwoFactorPasskeys,
|
||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||
onGetApiKey: accountSecurityActions.getApiKey,
|
||||
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
||||
@@ -2012,6 +2050,9 @@ export default function App() {
|
||||
onCreateAccountPasskey: accountSecurityActions.createAccountPasskey,
|
||||
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
|
||||
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
|
||||
onRefreshTwoFactorStatus: async () => {
|
||||
await twoFactorStatusQuery.refetch();
|
||||
},
|
||||
pendingAuthRequests,
|
||||
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isLoading,
|
||||
pendingAuthRequestsRefreshing: pendingAuthRequestsQuery.isFetching && !pendingAuthRequestsQuery.isLoading,
|
||||
@@ -2079,8 +2120,14 @@ export default function App() {
|
||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
|
||||
},
|
||||
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
|
||||
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
|
||||
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) => {
|
||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||
return backupActions.deleteRemoteBackup(hash, destinationId, path);
|
||||
},
|
||||
onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
|
||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||
return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting);
|
||||
@@ -2206,11 +2253,14 @@ export default function App() {
|
||||
confirm={confirm}
|
||||
onCancelConfirm={() => setConfirm(null)}
|
||||
pendingTotpOpen={!!pendingTotp}
|
||||
pendingTotpProviderType={pendingTotp?.providerType ?? 0}
|
||||
pendingTotpAvailableProviders={pendingTotp?.availableProviders ?? []}
|
||||
totpCode={totpCode}
|
||||
rememberDevice={rememberDevice}
|
||||
onTotpCodeChange={setTotpCode}
|
||||
onRememberDeviceChange={setRememberDevice}
|
||||
onConfirmTotp={() => void handleTotpVerify()}
|
||||
onSelectTotpProvider={handleSelectTotpProvider}
|
||||
onCancelTotp={() => {
|
||||
if (totpSubmitting) return;
|
||||
setPendingTotp(null);
|
||||
@@ -2265,11 +2315,14 @@ export default function App() {
|
||||
confirm={confirm}
|
||||
onCancelConfirm={() => setConfirm(null)}
|
||||
pendingTotpOpen={false}
|
||||
pendingTotpProviderType={0}
|
||||
pendingTotpAvailableProviders={[]}
|
||||
totpCode=""
|
||||
rememberDevice={false}
|
||||
onTotpCodeChange={() => {}}
|
||||
onRememberDeviceChange={() => {}}
|
||||
onConfirmTotp={() => {}}
|
||||
onSelectTotpProvider={() => {}}
|
||||
onCancelTotp={() => {}}
|
||||
onUseRecoveryCode={() => {}}
|
||||
totpSubmitting={false}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact';
|
||||
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 type { ComponentChildren } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Link } from 'wouter';
|
||||
@@ -56,10 +56,11 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
const isLogRoute = props.location === '/logs';
|
||||
const isAdmin = isAdminProfile(props.profile);
|
||||
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
|
||||
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
|
||||
const dataActive = props.location === '/backup' || props.isImportRoute;
|
||||
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
|
||||
const managementActive = props.location === '/admin' || deviceManagementActive || props.location === '/logs';
|
||||
const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive;
|
||||
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);
|
||||
@@ -175,13 +176,12 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
{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(props.settingsAccountRoute, props.location === props.settingsAccountRoute, <SettingsIcon size={16} />, t('nav_account_settings'))}
|
||||
{renderSideLink('/settings/domain-rules', props.location === '/settings/domain-rules', <Globe2 size={16} />, t('nav_domain_rules'))}
|
||||
{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'))}
|
||||
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -206,6 +206,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<>
|
||||
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
|
||||
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
|
||||
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
|
||||
</>
|
||||
)}
|
||||
{renderNavGroup(
|
||||
@@ -226,7 +227,6 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<>
|
||||
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
|
||||
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
|
||||
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import ToastHost from '@/components/ToastHost';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -21,11 +22,14 @@ interface AppGlobalOverlaysProps {
|
||||
confirm: AppConfirmState | null;
|
||||
onCancelConfirm: () => void;
|
||||
pendingTotpOpen: boolean;
|
||||
pendingTotpProviderType?: number;
|
||||
pendingTotpAvailableProviders?: number[];
|
||||
totpCode: string;
|
||||
rememberDevice: boolean;
|
||||
onTotpCodeChange: (value: string) => void;
|
||||
onRememberDeviceChange: (checked: boolean) => void;
|
||||
onConfirmTotp: () => void;
|
||||
onSelectTotpProvider: (providerType: number) => void;
|
||||
onCancelTotp: () => void;
|
||||
onUseRecoveryCode: () => void;
|
||||
totpSubmitting: boolean;
|
||||
@@ -37,7 +41,40 @@ interface AppGlobalOverlaysProps {
|
||||
disableTotpSubmitting: boolean;
|
||||
}
|
||||
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
const TWO_FACTOR_PROVIDER_ORDER = [
|
||||
TWO_FACTOR_PROVIDER_WEBAUTHN,
|
||||
TWO_FACTOR_PROVIDER_YUBIKEY,
|
||||
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
|
||||
] as const;
|
||||
|
||||
function uniqueSupportedProviders(providerTypes: number[] | undefined): number[] {
|
||||
const available = new Set(providerTypes || []);
|
||||
return TWO_FACTOR_PROVIDER_ORDER.filter((provider) => available.has(provider));
|
||||
}
|
||||
|
||||
function twoFactorProviderLabel(providerType: number): string {
|
||||
if (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN) return t('txt_passkey');
|
||||
if (providerType === TWO_FACTOR_PROVIDER_YUBIKEY) return t('txt_otp_from_yubikey');
|
||||
return t('txt_authenticator_app');
|
||||
}
|
||||
|
||||
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
const [methodChooserOpen, setMethodChooserOpen] = useState(false);
|
||||
const availableProviders = useMemo(
|
||||
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
|
||||
[props.pendingTotpAvailableProviders]
|
||||
);
|
||||
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
|
||||
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
|
||||
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
|
||||
|
||||
useEffect(() => {
|
||||
setMethodChooserOpen(false);
|
||||
}, [props.pendingTotpOpen, props.pendingTotpProviderType]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
@@ -55,10 +92,16 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.pendingTotpOpen}
|
||||
title={t('txt_two_step_verification')}
|
||||
message={t('txt_password_is_already_verified')}
|
||||
title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : isWebAuthn ? (
|
||||
<span className="dialog-title-stack">
|
||||
<span>{t('txt_two_step_verification')}</span>
|
||||
<span>{t('txt_passkey')}</span>
|
||||
</span>
|
||||
) : t('txt_two_step_verification')}
|
||||
message={isYubiKeyOtp ? t('txt_press_yubikey_to_authenticate') : isWebAuthn ? t('txt_use_passkey_to_complete_two_step_verification') : t('txt_password_is_already_verified')}
|
||||
confirmText={t('txt_verify')}
|
||||
cancelText={t('txt_cancel')}
|
||||
hideCancel
|
||||
closeButton
|
||||
showIcon={false}
|
||||
confirmDisabled={props.totpSubmitting}
|
||||
cancelDisabled={props.totpSubmitting}
|
||||
@@ -67,16 +110,52 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
afterActions={(
|
||||
<div className="dialog-extra">
|
||||
<div className="dialog-divider" />
|
||||
{alternateProviders.length > 0 && (
|
||||
<div className="two-factor-method-switcher">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary dialog-btn"
|
||||
disabled={props.totpSubmitting}
|
||||
aria-expanded={methodChooserOpen}
|
||||
onClick={() => setMethodChooserOpen((open) => !open)}
|
||||
>
|
||||
{t('txt_select_another_verification_method')}
|
||||
</button>
|
||||
{methodChooserOpen && (
|
||||
<div className="two-factor-method-list" role="list" aria-label={t('txt_select_two_step_login_method')}>
|
||||
<div className="two-factor-method-label">{t('txt_select_two_step_login_method')}</div>
|
||||
{alternateProviders.map((providerType) => (
|
||||
<button
|
||||
key={providerType}
|
||||
type="button"
|
||||
className="btn btn-secondary two-factor-method-option"
|
||||
disabled={props.totpSubmitting}
|
||||
onClick={() => {
|
||||
setMethodChooserOpen(false);
|
||||
props.onSelectTotpProvider(providerType);
|
||||
}}
|
||||
>
|
||||
{twoFactorProviderLabel(providerType)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
|
||||
{t('txt_use_recovery_code')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_totp_code')}</span>
|
||||
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
{isWebAuthn ? (
|
||||
<p className="muted-inline settings-field-note">{t('txt_touch_your_passkey_when_prompted')}</p>
|
||||
) : (
|
||||
<label className="field">
|
||||
<span>{isYubiKeyOtp ? t('txt_otp_from_yubikey') : t('txt_totp_code')}</span>
|
||||
<input className="input" type={isYubiKeyOtp ? 'password' : 'text'} value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
)}
|
||||
<label className="check-line check-line-compact">
|
||||
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
|
||||
<span>{t('txt_trust_this_device_for_30_days')}</span>
|
||||
@@ -88,7 +167,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
title={t('txt_disable_totp')}
|
||||
message={t('txt_enter_master_password_to_disable_two_step_verification')}
|
||||
confirmText={t('txt_disable_totp')}
|
||||
cancelText={t('txt_cancel')}
|
||||
hideCancel
|
||||
closeButton
|
||||
danger
|
||||
showIcon={false}
|
||||
confirmDisabled={props.disableTotpSubmitting}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSett
|
||||
import type { AuditLogFilters } from '@/lib/api/admin';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, TwoFactorPasskeySettings, VaultDraft, YubiKeyOtpSettings } from '@/lib/types';
|
||||
import type { ExportRequest } from '@/lib/export-formats';
|
||||
|
||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||
@@ -39,6 +39,7 @@ export interface AppMainRoutesProps {
|
||||
session: SessionState | null;
|
||||
mobileLayout: boolean;
|
||||
mobileSidebarToggleKey: number;
|
||||
themePreference: 'system' | 'light' | 'dark';
|
||||
importRoute: string;
|
||||
settingsHomeRoute: string;
|
||||
settingsAccountRoute: string;
|
||||
@@ -54,6 +55,8 @@ export interface AppMainRoutesProps {
|
||||
adminLoading: boolean;
|
||||
adminError: string;
|
||||
totpEnabled: boolean;
|
||||
yubikeyEnabled: boolean;
|
||||
passkey2faEnabled: boolean;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
sessionTimeoutAction: 'lock' | 'logout';
|
||||
authorizedDevices: AuthorizedDevice[];
|
||||
@@ -66,6 +69,7 @@ export interface AppMainRoutesProps {
|
||||
onNavigate: (path: string) => void;
|
||||
onLogout: () => void;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
onThemePreferenceChange: (preference: 'system' | 'light' | 'dark') => void;
|
||||
onImport: (
|
||||
payload: CiphersImportPayload,
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||
@@ -110,6 +114,15 @@ export interface AppMainRoutesProps {
|
||||
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||
onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
onGetYubiKeySettings: (masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onSaveYubiKeySettings: (keys: string[], nfc: boolean, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onDisableYubiKey: (masterPassword: string) => Promise<void>;
|
||||
onGetTwoFactorPasskeySettings: (masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onCreateTwoFactorPasskey: (name: string, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onDeleteTwoFactorPasskey: (id: number, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onDisableTwoFactorPasskeys: (masterPassword: string) => Promise<void>;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
@@ -117,6 +130,7 @@ export interface AppMainRoutesProps {
|
||||
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
|
||||
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
||||
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
||||
onRefreshTwoFactorStatus: () => Promise<void>;
|
||||
pendingAuthRequests: AuthRequest[];
|
||||
pendingAuthRequestsLoading: boolean;
|
||||
pendingAuthRequestsRefreshing: boolean;
|
||||
@@ -154,8 +168,8 @@ export interface AppMainRoutesProps {
|
||||
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||
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 } }>;
|
||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||
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>;
|
||||
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
}
|
||||
@@ -266,12 +280,26 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<SettingsPage
|
||||
profile={props.profile}
|
||||
totpEnabled={props.totpEnabled}
|
||||
yubikeyEnabled={props.yubikeyEnabled}
|
||||
passkey2faEnabled={props.passkey2faEnabled}
|
||||
themePreference={props.themePreference}
|
||||
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
||||
sessionTimeoutAction={props.sessionTimeoutAction}
|
||||
onThemePreferenceChange={props.onThemePreferenceChange}
|
||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||
onChangePassword={props.onChangePassword}
|
||||
onSavePasswordHint={props.onSavePasswordHint}
|
||||
onEnableTotp={props.onEnableTotp}
|
||||
onOpenDisableTotp={props.onOpenDisableTotp}
|
||||
onGetYubiKeySettings={props.onGetYubiKeySettings}
|
||||
onSaveYubiKeySettings={props.onSaveYubiKeySettings}
|
||||
onSaveYubiKeyApiCredentials={props.onSaveYubiKeyApiCredentials}
|
||||
onBootstrapYubiKeyApiCredentials={props.onBootstrapYubiKeyApiCredentials}
|
||||
onDisableYubiKey={props.onDisableYubiKey}
|
||||
onGetTwoFactorPasskeySettings={props.onGetTwoFactorPasskeySettings}
|
||||
onCreateTwoFactorPasskey={props.onCreateTwoFactorPasskey}
|
||||
onDeleteTwoFactorPasskey={props.onDeleteTwoFactorPasskey}
|
||||
onDisableTwoFactorPasskeys={props.onDisableTwoFactorPasskeys}
|
||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||
onGetApiKey={props.onGetApiKey}
|
||||
onRotateApiKey={props.onRotateApiKey}
|
||||
@@ -279,6 +307,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onCreateAccountPasskey={props.onCreateAccountPasskey}
|
||||
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
|
||||
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
|
||||
onRefreshTwoFactorStatus={props.onRefreshTwoFactorStatus}
|
||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||
onNotify={props.onNotify}
|
||||
@@ -291,43 +320,55 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
</Route>
|
||||
<Route path="/settings">
|
||||
{props.profile ? (
|
||||
<section className="card mobile-settings-card">
|
||||
<div className="mobile-settings-links">
|
||||
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
||||
<SettingsIcon size={18} />
|
||||
<span>{t('nav_account_settings')}</span>
|
||||
</Link>
|
||||
<Link href="/settings/security/device-management" className="mobile-settings-link">
|
||||
<Shield size={18} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
<Link href="/settings/domain-rules" className="mobile-settings-link">
|
||||
<Globe2 size={18} />
|
||||
<span>{t('nav_domain_rules')}</span>
|
||||
</Link>
|
||||
<Link href={props.importRoute} className="mobile-settings-link">
|
||||
<ArrowUpDown size={18} />
|
||||
<span>{t('nav_import_export')}</span>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link href="/admin" className="mobile-settings-link">
|
||||
<ShieldUser size={18} />
|
||||
<span>{t('nav_admin_panel')}</span>
|
||||
<section className="card mobile-settings-card settings-home-card">
|
||||
<div className="settings-home-section">
|
||||
<h3>{t('txt_settings')}</h3>
|
||||
<div className="mobile-settings-links">
|
||||
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
||||
<SettingsIcon size={18} />
|
||||
<span>{t('nav_account_settings')}</span>
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link href="/logs" className="mobile-settings-link">
|
||||
<FileClock size={18} />
|
||||
<span>{t('nav_log_center')}</span>
|
||||
<Link href="/settings/security/device-management" className="mobile-settings-link">
|
||||
<Shield size={18} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link href="/backup" className="mobile-settings-link">
|
||||
<Cloud size={18} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
<Link href="/settings/domain-rules" className="mobile-settings-link">
|
||||
<Globe2 size={18} />
|
||||
<span>{t('nav_domain_rules')}</span>
|
||||
</Link>
|
||||
)}
|
||||
</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 && (
|
||||
<Link href="/backup" className="mobile-settings-link">
|
||||
<Cloud size={18} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
</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">
|
||||
<ShieldUser size={18} />
|
||||
<span>{t('nav_admin_panel')}</span>
|
||||
</Link>
|
||||
<Link href="/logs" className="mobile-settings-link">
|
||||
<FileClock size={18} />
|
||||
<span>{t('nav_log_center')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-home-spacer" />
|
||||
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={props.onLogout}>
|
||||
<LogOut size={14} className="btn-icon" />
|
||||
{t('txt_sign_out')}
|
||||
|
||||
@@ -42,8 +42,8 @@ interface BackupCenterPageProps {
|
||||
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||
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 }>;
|
||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||
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>;
|
||||
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
@@ -60,6 +60,7 @@ type PendingBackupVerification =
|
||||
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
|
||||
| { action: 'runRemoteBackup' }
|
||||
| { action: 'downloadRemote'; path: string }
|
||||
| { action: 'deleteRemote'; destinationId: string; path: string }
|
||||
| { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult };
|
||||
|
||||
interface BackupProgressPhase {
|
||||
@@ -204,6 +205,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
|
||||
const [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null);
|
||||
const [backupPasswordValue, setBackupPasswordValue] = useState('');
|
||||
const [backupPasswordError, setBackupPasswordError] = useState('');
|
||||
const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false);
|
||||
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
|
||||
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
||||
@@ -245,11 +247,29 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
? t('txt_backup_save_settings')
|
||||
: pendingBackupVerification?.action === 'runRemoteBackup'
|
||||
? t('txt_backup_run_manual')
|
||||
: pendingBackupVerification?.action === 'downloadRemote'
|
||||
? t('txt_backup_remote_download')
|
||||
: pendingBackupVerification?.action === 'restoreRemote'
|
||||
? t('txt_backup_import')
|
||||
: t('txt_backup_import');
|
||||
: pendingBackupVerification?.action === 'downloadRemote'
|
||||
? t('txt_backup_remote_download')
|
||||
: pendingBackupVerification?.action === 'deleteRemote'
|
||||
? t('txt_delete')
|
||||
: pendingBackupVerification?.action === 'restoreRemote'
|
||||
? t('txt_backup_import')
|
||||
: t('txt_backup_import');
|
||||
|
||||
function openBackupPasswordPrompt(request: PendingBackupVerification): void {
|
||||
setPendingBackupVerification(request);
|
||||
setBackupPasswordValue('');
|
||||
setBackupPasswordError('');
|
||||
}
|
||||
|
||||
function showActionError(error: unknown, fallback: string): string {
|
||||
const message = error instanceof Error ? error.message : fallback;
|
||||
setLocalError(message);
|
||||
if (backupPasswordSubmitting || pendingBackupVerification) {
|
||||
setBackupPasswordError(message);
|
||||
}
|
||||
props.onNotify('error', message);
|
||||
return message;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -472,8 +492,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
return verifyBackupFileIntegrity(bytes, file.name || '');
|
||||
}
|
||||
|
||||
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
||||
const payload = await props.onInspectRemoteBackup(destinationId, path);
|
||||
async function inspectRemoteBackupFile(masterPassword: string, destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
||||
const payload = await props.onInspectRemoteBackup(masterPassword, destinationId, path);
|
||||
return {
|
||||
source: 'remote',
|
||||
path,
|
||||
@@ -502,12 +522,11 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
|
||||
};
|
||||
|
||||
setPendingBackupVerification({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
|
||||
setBackupPasswordValue('');
|
||||
openBackupPasswordPrompt({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
|
||||
setConfirmDeleteDestinationOpen(false);
|
||||
}
|
||||
|
||||
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings) {
|
||||
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings): Promise<boolean> {
|
||||
setSavingSettings(true);
|
||||
setLocalError('');
|
||||
try {
|
||||
@@ -527,10 +546,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
setSelectedDestinationId(nextSelected);
|
||||
setConfirmDeleteDestinationOpen(false);
|
||||
props.onNotify('success', t('txt_backup_destination_deleted'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_settings_save_failed'));
|
||||
return false;
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
@@ -538,22 +557,21 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
|
||||
async function handleExport() {
|
||||
if (exporting) return;
|
||||
setPendingBackupVerification({ action: 'export' });
|
||||
setBackupPasswordValue('');
|
||||
openBackupPasswordPrompt({ action: 'export' });
|
||||
}
|
||||
|
||||
async function executeExport(masterPassword: string) {
|
||||
async function executeExport(masterPassword: string): Promise<boolean> {
|
||||
setLocalError('');
|
||||
setExporting(true);
|
||||
try {
|
||||
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
|
||||
await props.onExport(masterPassword, exportIncludeAttachments);
|
||||
props.onNotify('success', t('txt_backup_export_success'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_export_failed'));
|
||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||
return false;
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
@@ -571,13 +589,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
props.onNotify('error', message);
|
||||
return;
|
||||
}
|
||||
setPendingBackupVerification({
|
||||
openBackupPasswordPrompt({
|
||||
action: 'import',
|
||||
replaceExisting,
|
||||
allowChecksumMismatch,
|
||||
knownIntegrity,
|
||||
});
|
||||
setBackupPasswordValue('');
|
||||
}
|
||||
|
||||
async function executeLocalRestore(
|
||||
@@ -585,13 +602,14 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
replaceExisting: boolean,
|
||||
allowChecksumMismatch: boolean = false,
|
||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||
) {
|
||||
if (importing) return;
|
||||
): Promise<boolean> {
|
||||
if (importing) return false;
|
||||
if (!selectedFile) {
|
||||
const message = t('txt_backup_file_required');
|
||||
setLocalError(message);
|
||||
setBackupPasswordError(message);
|
||||
props.onNotify('error', message);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
setLocalError('');
|
||||
setConfirmLocalRestoreOpen(false);
|
||||
@@ -614,17 +632,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
setConfirmLocalRestoreOpen(false);
|
||||
setConfirmReplaceOpen(false);
|
||||
resetPendingIntegrityWarning();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||
clearRestoreProgress();
|
||||
setConfirmLocalRestoreOpen(false);
|
||||
setConfirmReplaceOpen(true);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_restore_failed'));
|
||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||
return false;
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
@@ -632,11 +650,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
|
||||
async function handleSaveSettings() {
|
||||
if (savingSettings) return;
|
||||
setPendingBackupVerification({ action: 'saveSettings' });
|
||||
setBackupPasswordValue('');
|
||||
openBackupPasswordPrompt({ action: 'saveSettings' });
|
||||
}
|
||||
|
||||
async function executeSaveSettings(masterPassword: string) {
|
||||
async function executeSaveSettings(masterPassword: string): Promise<boolean> {
|
||||
const payload = buildSettingsPayloadForSelectedDestination();
|
||||
const destinationIdToInvalidate = selectedDestinationId;
|
||||
setSavingSettings(true);
|
||||
@@ -656,10 +673,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
}
|
||||
setSelectedDestinationId(nextSelected);
|
||||
props.onNotify('success', t('txt_backup_settings_saved'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_settings_save_failed'));
|
||||
return false;
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
@@ -678,12 +695,11 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
|
||||
async function handleRunRemoteBackup() {
|
||||
if (!selectedDestination || runningRemoteBackup) return;
|
||||
setPendingBackupVerification({ action: 'runRemoteBackup' });
|
||||
setBackupPasswordValue('');
|
||||
openBackupPasswordPrompt({ action: 'runRemoteBackup' });
|
||||
}
|
||||
|
||||
async function executeRunRemoteBackup(masterPassword: string) {
|
||||
if (!selectedDestination) return;
|
||||
async function executeRunRemoteBackup(masterPassword: string): Promise<boolean> {
|
||||
if (!selectedDestination) return false;
|
||||
setRunningRemoteBackup(true);
|
||||
setLocalError('');
|
||||
try {
|
||||
@@ -697,32 +713,31 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
setSelectedDestinationId(selectedDestination.id);
|
||||
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
||||
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_remote_run_failed'));
|
||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||
return false;
|
||||
} finally {
|
||||
setRunningRemoteBackup(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadRemote(path: string) {
|
||||
setPendingBackupVerification({ action: 'downloadRemote', path });
|
||||
setBackupPasswordValue('');
|
||||
openBackupPasswordPrompt({ action: 'downloadRemote', path });
|
||||
}
|
||||
|
||||
async function executeDownloadRemote(masterPassword: string, path: string) {
|
||||
if (!savedSelectedDestination) return;
|
||||
async function executeDownloadRemote(masterPassword: string, path: string): Promise<boolean> {
|
||||
if (!savedSelectedDestination) return false;
|
||||
setDownloadingRemotePath(path);
|
||||
setDownloadingRemotePercent(null);
|
||||
setLocalError('');
|
||||
try {
|
||||
await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_remote_download_failed'));
|
||||
return false;
|
||||
} finally {
|
||||
setDownloadingRemotePath('');
|
||||
setDownloadingRemotePercent(null);
|
||||
@@ -732,18 +747,24 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
async function handleDeleteRemote(path: string) {
|
||||
if (deletingRemotePath) return;
|
||||
if (!savedSelectedDestination) return;
|
||||
openBackupPasswordPrompt({ action: 'deleteRemote', destinationId: savedSelectedDestination.id, path });
|
||||
setConfirmRemoteDeleteOpen(false);
|
||||
}
|
||||
|
||||
async function executeDeleteRemote(masterPassword: string, destinationId: string, path: string): Promise<boolean> {
|
||||
if (deletingRemotePath) return false;
|
||||
setDeletingRemotePath(path);
|
||||
setLocalError('');
|
||||
try {
|
||||
await props.onDeleteRemoteBackup(savedSelectedDestination.id, path);
|
||||
await props.onDeleteRemoteBackup(masterPassword, destinationId, path);
|
||||
setConfirmRemoteDeleteOpen(false);
|
||||
setPendingRemoteDeletePath('');
|
||||
await loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true });
|
||||
await loadRemoteBrowser(destinationId, remoteBrowserPathByDestination[destinationId] || '', { force: true });
|
||||
props.onNotify('success', t('txt_backup_remote_delete_success'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_delete_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_remote_delete_failed'));
|
||||
return false;
|
||||
} finally {
|
||||
setDeletingRemotePath('');
|
||||
}
|
||||
@@ -779,19 +800,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
if (!savedSelectedDestination) return;
|
||||
setLocalError('');
|
||||
resetPendingIntegrityWarning();
|
||||
try {
|
||||
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);
|
||||
}
|
||||
await runRemoteRestore(path, false);
|
||||
}
|
||||
|
||||
async function runRemoteRestore(
|
||||
@@ -802,14 +811,13 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
) {
|
||||
if (restoringRemotePath) return;
|
||||
if (!savedSelectedDestination) return;
|
||||
setPendingBackupVerification({
|
||||
openBackupPasswordPrompt({
|
||||
action: 'restoreRemote',
|
||||
path,
|
||||
replaceExisting,
|
||||
allowChecksumMismatch,
|
||||
knownIntegrity,
|
||||
});
|
||||
setBackupPasswordValue('');
|
||||
}
|
||||
|
||||
async function executeRemoteRestore(
|
||||
@@ -818,15 +826,31 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
replaceExisting: boolean,
|
||||
allowChecksumMismatch: boolean = false,
|
||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||
) {
|
||||
if (restoringRemotePath) return;
|
||||
if (!savedSelectedDestination) return;
|
||||
): Promise<boolean> {
|
||||
if (restoringRemotePath) return false;
|
||||
if (!savedSelectedDestination) return false;
|
||||
setConfirmRemoteReplaceOpen(false);
|
||||
setConfirmIntegrityWarningOpen(false);
|
||||
setRestoringRemotePath(path);
|
||||
setLocalError('');
|
||||
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, {
|
||||
source: 'remote',
|
||||
delayMs: replaceExisting ? 480 : 1400,
|
||||
@@ -840,17 +864,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const skippedMessage = buildSkippedImportMessage(result);
|
||||
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||
resetPendingIntegrityWarning();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||
setPendingRemoteRestorePath(path);
|
||||
setConfirmRemoteReplaceOpen(true);
|
||||
clearRestoreProgress();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_remote_restore_failed'));
|
||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||
return false;
|
||||
} finally {
|
||||
setRestoringRemotePath('');
|
||||
}
|
||||
@@ -861,31 +885,38 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const masterPassword = backupPasswordValue;
|
||||
if (!request || backupPasswordSubmitting) return;
|
||||
if (!masterPassword.trim()) {
|
||||
props.onNotify('error', t('txt_master_password_is_required'));
|
||||
setBackupPasswordError(t('txt_master_password_is_required'));
|
||||
return;
|
||||
}
|
||||
setBackupPasswordSubmitting(true);
|
||||
setPendingBackupVerification(null);
|
||||
setBackupPasswordValue('');
|
||||
setBackupPasswordError('');
|
||||
let succeeded = false;
|
||||
try {
|
||||
if (request.action === 'export') {
|
||||
await executeExport(masterPassword);
|
||||
succeeded = await executeExport(masterPassword);
|
||||
} else if (request.action === 'saveSettings') {
|
||||
await executeSaveSettings(masterPassword);
|
||||
succeeded = await executeSaveSettings(masterPassword);
|
||||
} else if (request.action === 'deleteDestination') {
|
||||
await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
|
||||
succeeded = await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
|
||||
} else if (request.action === 'import') {
|
||||
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
|
||||
succeeded = await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
|
||||
} else if (request.action === 'runRemoteBackup') {
|
||||
await executeRunRemoteBackup(masterPassword);
|
||||
succeeded = await executeRunRemoteBackup(masterPassword);
|
||||
} else if (request.action === 'downloadRemote') {
|
||||
await executeDownloadRemote(masterPassword, request.path);
|
||||
succeeded = await executeDownloadRemote(masterPassword, request.path);
|
||||
} else if (request.action === 'deleteRemote') {
|
||||
succeeded = await executeDeleteRemote(masterPassword, request.destinationId, request.path);
|
||||
} else if (request.action === 'restoreRemote') {
|
||||
await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
|
||||
succeeded = await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
|
||||
}
|
||||
} finally {
|
||||
setBackupPasswordSubmitting(false);
|
||||
}
|
||||
if (succeeded) {
|
||||
setPendingBackupVerification(null);
|
||||
setBackupPasswordValue('');
|
||||
setBackupPasswordError('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1031,17 +1062,27 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
if (backupPasswordSubmitting) return;
|
||||
setPendingBackupVerification(null);
|
||||
setBackupPasswordValue('');
|
||||
setBackupPasswordError('');
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input
|
||||
id="backup-master-password"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={backupPasswordValue}
|
||||
onInput={(event) => setBackupPasswordValue((event.currentTarget as HTMLInputElement).value)}
|
||||
aria-invalid={!!backupPasswordError}
|
||||
aria-describedby={backupPasswordError ? 'backup-master-password-error' : undefined}
|
||||
onInput={(event) => {
|
||||
setBackupPasswordValue((event.currentTarget as HTMLInputElement).value);
|
||||
if (backupPasswordError) setBackupPasswordError('');
|
||||
}}
|
||||
/>
|
||||
{backupPasswordError ? (
|
||||
<div id="backup-master-password-error" className="local-error" role="alert">{backupPasswordError}</div>
|
||||
) : null}
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { TriangleAlert } from 'lucide-preact';
|
||||
import { TriangleAlert, X } from 'lucide-preact';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
title: ComponentChildren;
|
||||
message?: string;
|
||||
variant?: 'default' | 'warning';
|
||||
showIcon?: boolean;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
hideCancel?: boolean;
|
||||
hideConfirm?: boolean;
|
||||
closeButton?: boolean;
|
||||
confirmDisabled?: boolean;
|
||||
cancelDisabled?: boolean;
|
||||
onConfirm: () => void;
|
||||
@@ -88,6 +90,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||
const titleId = `${dialogId}-title`;
|
||||
const messageId = `${dialogId}-message`;
|
||||
const hasMessage = !!props.message;
|
||||
const canDismiss = !props.cancelDisabled && !closing;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -191,7 +194,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={messageId}
|
||||
aria-describedby={hasMessage ? messageId : undefined}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
onSubmit={(e) => {
|
||||
@@ -211,17 +214,33 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{props.closeButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="dialog-close-btn"
|
||||
aria-label={t('txt_close')}
|
||||
disabled={props.cancelDisabled}
|
||||
onClick={() => {
|
||||
if (props.cancelDisabled) return;
|
||||
props.onCancel();
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
<h3 id={titleId} className="dialog-title">{props.title}</h3>
|
||||
<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}
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||
disabled={props.confirmDisabled}
|
||||
data-dialog-confirm="true"
|
||||
>
|
||||
{props.confirmText || t('txt_yes')}
|
||||
</button>
|
||||
{!props.hideConfirm && (
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||
disabled={props.confirmDisabled}
|
||||
data-dialog-confirm="true"
|
||||
>
|
||||
{props.confirmText || t('txt_yes')}
|
||||
</button>
|
||||
)}
|
||||
{!props.hideCancel && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { strFromU8, unzipSync } from 'fflate';
|
||||
import { strFromU8, unzipSync, type UnzipFileInfo } from 'fflate';
|
||||
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
||||
import { Download, FileUp } from 'lucide-preact';
|
||||
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||
@@ -96,6 +96,12 @@ const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
|
||||
'keepassx_csv',
|
||||
];
|
||||
|
||||
const MAX_IMPORT_ZIP_BYTES = 256 * 1024 * 1024;
|
||||
const MAX_IMPORT_ZIP_ENTRY_COUNT = 10_000;
|
||||
const MAX_IMPORT_TEXT_ENTRY_BYTES = 32 * 1024 * 1024;
|
||||
const MAX_IMPORT_ATTACHMENT_BYTES = 100 * 1024 * 1024;
|
||||
const MAX_IMPORT_ATTACHMENT_TOTAL_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
@@ -171,8 +177,85 @@ function isZipPayload(bytes: Uint8Array): boolean {
|
||||
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
|
||||
}
|
||||
|
||||
function formatMiB(bytes: number): string {
|
||||
return String(Math.floor(bytes / (1024 * 1024)));
|
||||
}
|
||||
|
||||
function zipEntryName(rawName: unknown): string {
|
||||
return String(rawName || '').trim().replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function assertSafeZipEntryName(name: string): void {
|
||||
if (!name || name.includes('\0') || name.startsWith('/') || name.includes('//')) {
|
||||
throw new Error(t('txt_import_zip_unsafe_file_name'));
|
||||
}
|
||||
const parts = name.split('/');
|
||||
if (parts.some((part) => part === '.' || part === '..')) {
|
||||
throw new Error(t('txt_import_zip_unsafe_file_name'));
|
||||
}
|
||||
}
|
||||
|
||||
function assertImportZipSize(bytes: number): void {
|
||||
if (bytes > MAX_IMPORT_ZIP_BYTES) {
|
||||
throw new Error(t('txt_import_zip_too_large', { size: formatMiB(MAX_IMPORT_ZIP_BYTES) }));
|
||||
}
|
||||
}
|
||||
|
||||
function assertImportTextFileSize(bytes: number): void {
|
||||
if (bytes > MAX_IMPORT_TEXT_ENTRY_BYTES) {
|
||||
throw new Error(t('txt_import_file_too_large', { size: formatMiB(MAX_IMPORT_TEXT_ENTRY_BYTES) }));
|
||||
}
|
||||
}
|
||||
|
||||
function assertImportEntrySize(size: number, maxBytes: number): void {
|
||||
if (size > maxBytes) {
|
||||
throw new Error(t('txt_import_zip_entry_too_large', { size: formatMiB(maxBytes) }));
|
||||
}
|
||||
}
|
||||
|
||||
function isImportTextZipCandidate(source: ImportSourceId, name: string): boolean {
|
||||
const lower = name.toLowerCase();
|
||||
if (source === 'onepassword_1pux') {
|
||||
return lower.endsWith('/export.data') || lower === 'export.data' || lower.endsWith('/export.json') || lower === 'export.json' || lower.endsWith('.json');
|
||||
}
|
||||
return lower.endsWith('/protonpass.json') || lower === 'protonpass.json' || lower.endsWith('/export.json') || lower === 'export.json' || lower.endsWith('.json');
|
||||
}
|
||||
|
||||
function createImportTextZipFilter(source: ImportSourceId): (file: UnzipFileInfo) => boolean {
|
||||
let entryCount = 0;
|
||||
let totalTextBytes = 0;
|
||||
return (entry: UnzipFileInfo): boolean => {
|
||||
entryCount += 1;
|
||||
if (entryCount > MAX_IMPORT_ZIP_ENTRY_COUNT) {
|
||||
throw new Error(t('txt_import_zip_too_many_files'));
|
||||
}
|
||||
const name = zipEntryName(entry.name);
|
||||
assertSafeZipEntryName(name);
|
||||
if (!isImportTextZipCandidate(source, name)) return false;
|
||||
|
||||
const originalSize = Number(entry.originalSize);
|
||||
if (!Number.isFinite(originalSize) || originalSize < 0) {
|
||||
throw new Error(t('txt_import_zip_entry_too_large', { size: formatMiB(MAX_IMPORT_TEXT_ENTRY_BYTES) }));
|
||||
}
|
||||
assertImportEntrySize(originalSize, MAX_IMPORT_TEXT_ENTRY_BYTES);
|
||||
totalTextBytes += originalSize;
|
||||
if (totalTextBytes > MAX_IMPORT_TEXT_ENTRY_BYTES) {
|
||||
throw new Error(t('txt_import_zip_expands_too_large', { size: formatMiB(MAX_IMPORT_TEXT_ENTRY_BYTES) }));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
|
||||
const unzipped = unzipSync(bytes);
|
||||
assertImportZipSize(bytes.byteLength);
|
||||
const unzippedRaw = unzipSync(bytes, { filter: createImportTextZipFilter(source) });
|
||||
const unzipped: Record<string, Uint8Array> = {};
|
||||
for (const [rawName, entryBytes] of Object.entries(unzippedRaw)) {
|
||||
const name = zipEntryName(rawName);
|
||||
assertSafeZipEntryName(name);
|
||||
assertImportEntrySize(entryBytes.byteLength, MAX_IMPORT_TEXT_ENTRY_BYTES);
|
||||
unzipped[name] = entryBytes;
|
||||
}
|
||||
const fileNames = Object.keys(unzipped);
|
||||
if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive'));
|
||||
|
||||
@@ -189,10 +272,13 @@ function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
|
||||
|
||||
async function readImportText(file: File, source: ImportSourceId): Promise<string> {
|
||||
if (source !== 'onepassword_1pux' && source !== 'protonpass_json') {
|
||||
assertImportTextFileSize(file.size);
|
||||
return file.text();
|
||||
}
|
||||
assertImportZipSize(file.size);
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (isZipPayload(bytes)) return readZipText(bytes, source);
|
||||
assertImportTextFileSize(bytes.byteLength);
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
@@ -211,34 +297,77 @@ function looksLikeZipPasswordError(error: unknown): boolean {
|
||||
return message.includes('password') || message.includes('encrypted');
|
||||
}
|
||||
|
||||
function bitwardenZipAttachmentMatch(name: string): RegExpMatchArray | null {
|
||||
return name.match(/^attachments\/([^/]+)\/(.+)$/i);
|
||||
}
|
||||
|
||||
function zipJsEntrySize(entry: unknown): number | null {
|
||||
const size = Number((entry as { uncompressedSize?: unknown })?.uncompressedSize);
|
||||
return Number.isFinite(size) && size >= 0 ? size : null;
|
||||
}
|
||||
|
||||
function validateBitwardenZipEntries(entries: Awaited<ReturnType<ZipReader<unknown>['getEntries']>>): void {
|
||||
if (entries.length > MAX_IMPORT_ZIP_ENTRY_COUNT) {
|
||||
throw new Error(t('txt_import_zip_too_many_files'));
|
||||
}
|
||||
|
||||
let totalAttachmentBytes = 0;
|
||||
for (const entry of entries) {
|
||||
if (entry.directory) continue;
|
||||
const name = zipEntryName(entry.filename);
|
||||
assertSafeZipEntryName(name);
|
||||
const lower = name.toLowerCase();
|
||||
const size = zipJsEntrySize(entry);
|
||||
if (lower === 'data.json' && size != null) {
|
||||
assertImportEntrySize(size, MAX_IMPORT_TEXT_ENTRY_BYTES);
|
||||
} else if (bitwardenZipAttachmentMatch(name) && size != null) {
|
||||
assertImportEntrySize(size, MAX_IMPORT_ATTACHMENT_BYTES);
|
||||
totalAttachmentBytes += size;
|
||||
if (totalAttachmentBytes > MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) {
|
||||
throw new Error(t('txt_import_zip_expands_too_large', { size: formatMiB(MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readBitwardenZipPayload(
|
||||
file: File,
|
||||
passwordRaw: string
|
||||
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
|
||||
const password = String(passwordRaw || '').trim();
|
||||
assertImportZipSize(file.size);
|
||||
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
|
||||
try {
|
||||
const entries = await reader.getEntries();
|
||||
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
|
||||
validateBitwardenZipEntries(entries);
|
||||
|
||||
let jsonText = '';
|
||||
let totalAttachmentBytes = 0;
|
||||
const attachments: ImportAttachmentFile[] = [];
|
||||
const options = password ? { password } : undefined;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.directory) continue;
|
||||
const name = String(entry.filename || '').trim().replace(/\\/g, '/');
|
||||
const name = zipEntryName(entry.filename);
|
||||
if (!name) continue;
|
||||
assertSafeZipEntryName(name);
|
||||
|
||||
const bytes = await entry.getData(new Uint8ArrayWriter(), options);
|
||||
const lower = name.toLowerCase();
|
||||
if (lower === 'data.json') {
|
||||
assertImportEntrySize(bytes.byteLength, MAX_IMPORT_TEXT_ENTRY_BYTES);
|
||||
jsonText = new TextDecoder().decode(bytes);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attachmentMatch = name.match(/^attachments\/([^/]+)\/(.+)$/i);
|
||||
const attachmentMatch = bitwardenZipAttachmentMatch(name);
|
||||
if (!attachmentMatch) continue;
|
||||
assertImportEntrySize(bytes.byteLength, MAX_IMPORT_ATTACHMENT_BYTES);
|
||||
totalAttachmentBytes += bytes.byteLength;
|
||||
if (totalAttachmentBytes > MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) {
|
||||
throw new Error(t('txt_import_zip_expands_too_large', { size: formatMiB(MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) }));
|
||||
}
|
||||
const sourceCipherId = String(attachmentMatch[1] || '').trim() || null;
|
||||
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
|
||||
attachments.push({
|
||||
|
||||
@@ -5,7 +5,7 @@ import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface JwtWarningPageProps {
|
||||
reason: 'missing' | 'default' | 'too_short';
|
||||
reason: 'missing' | 'too_short';
|
||||
minLength: number;
|
||||
}
|
||||
|
||||
@@ -21,9 +21,7 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
|
||||
const title =
|
||||
props.reason === 'missing'
|
||||
? t('txt_jwt_title_missing')
|
||||
: props.reason === 'default'
|
||||
? t('txt_jwt_title_default')
|
||||
: t('txt_jwt_title_too_short');
|
||||
: t('txt_jwt_title_too_short');
|
||||
|
||||
const isMissing = props.reason === 'missing';
|
||||
const fixTitle = isMissing ? t('txt_jwt_how_to_fix_add') : t('txt_jwt_how_to_fix_replace');
|
||||
|
||||
@@ -129,6 +129,10 @@ function formatReason(reason: string): string {
|
||||
return translatedOrHumanized(keyFor('txt_log_reason_', reason), reason);
|
||||
}
|
||||
|
||||
function formatTargetType(type: string): string {
|
||||
return translatedOrHumanized(keyFor('txt_log_target_type_', type), type);
|
||||
}
|
||||
|
||||
function formatTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
@@ -148,11 +152,16 @@ function formatMetaValueForKey(key: string, value: unknown): string {
|
||||
return translatedOrHumanized(keyFor('txt_log_trigger_', value), value);
|
||||
}
|
||||
if (key === 'type' && typeof value === 'string') {
|
||||
return translatedOrHumanized(keyFor('txt_log_target_type_', value), value);
|
||||
return formatTargetType(value);
|
||||
}
|
||||
return formatMetaValue(value);
|
||||
}
|
||||
|
||||
function formatLogTarget(log: AuditLogEntry, metadata: Record<string, unknown>): string {
|
||||
const targetEmail = typeof metadata.targetEmail === 'string' ? metadata.targetEmail : '';
|
||||
return log.targetUserEmail || targetEmail || log.targetId || (log.targetType ? formatTargetType(log.targetType) : t('txt_dash'));
|
||||
}
|
||||
|
||||
function iconForCategory(category: AuditLogCategory) {
|
||||
if (category === 'auth') return <ShieldAlert size={16} />;
|
||||
if (category === 'security') return <UserRound size={16} />;
|
||||
@@ -550,7 +559,7 @@ export default function LogCenterPage(props: LogCenterPageProps) {
|
||||
<div><span>{t('txt_time')}</span><strong>{formatTime(selectedLog.createdAt)}</strong></div>
|
||||
<div><span>{t('txt_log_category')}</span><strong>{t(`txt_log_category_${selectedCategory}`)}</strong></div>
|
||||
<div><span>{t('txt_actor')}</span><strong>{selectedLog.actorEmail || selectedLog.actorUserId || t('txt_dash')}</strong></div>
|
||||
<div><span>{t('txt_target')}</span><strong>{selectedLog.targetUserEmail || String(selectedMetadata.targetEmail || '') || selectedLog.targetId || selectedLog.targetType || t('txt_dash')}</strong></div>
|
||||
<div><span>{t('txt_target')}</span><strong>{formatLogTarget(selectedLog, selectedMetadata)}</strong></div>
|
||||
</div>
|
||||
<div className="log-detail-json">
|
||||
<h4>{t('txt_metadata')}</h4>
|
||||
|
||||
@@ -8,41 +8,13 @@ interface NotFoundPageProps {
|
||||
}
|
||||
|
||||
export default function NotFoundPage(props: NotFoundPageProps) {
|
||||
const starBoxes = [1, 2, 3, 4];
|
||||
const stars = [1, 2, 3, 4, 5, 6, 7];
|
||||
|
||||
return (
|
||||
<main className="not-found-page">
|
||||
<div className="not-found-space" aria-hidden="true">
|
||||
{starBoxes.map((box) => (
|
||||
<div key={box} className={`not-found-star-box not-found-star-box-${box}`}>
|
||||
{stars.map((star) => (
|
||||
<span key={star} className={`not-found-star not-found-star-position-${star}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="not-found-shell" aria-labelledby="not-found-title">
|
||||
<div className="not-found-brand">
|
||||
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="not-found-logo" />
|
||||
<span className="not-found-wordmark" aria-label="NodeWarden" role="img" />
|
||||
</div>
|
||||
|
||||
<div className="not-found-astro-stage" aria-hidden="true">
|
||||
<div className="not-found-astronaut">
|
||||
<div className="not-found-astro-head" />
|
||||
<div className="not-found-astro-arm not-found-astro-arm-left" />
|
||||
<div className="not-found-astro-arm not-found-astro-arm-right" />
|
||||
<div className="not-found-astro-body">
|
||||
<div className="not-found-astro-panel" />
|
||||
</div>
|
||||
<div className="not-found-astro-leg not-found-astro-leg-left" />
|
||||
<div className="not-found-astro-leg not-found-astro-leg-right" />
|
||||
<div className="not-found-astro-pack" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="not-found-copy">
|
||||
<div className="not-found-code">404</div>
|
||||
<h1 id="not-found-title">{props.title || t('txt_page_not_found')}</h1>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 LoadingState from '@/components/LoadingState';
|
||||
import type { Send, SendDraft } from '@/lib/types';
|
||||
@@ -43,6 +43,7 @@ function buildDefaultDraft(): SendDraft {
|
||||
expirationDays: '0',
|
||||
maxAccessCount: '',
|
||||
password: '',
|
||||
hasPassword: false,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
@@ -59,6 +60,7 @@ function draftFromSend(send: Send): SendDraft {
|
||||
expirationDays: daysFromNow(send.expirationDate, 0),
|
||||
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
|
||||
password: '',
|
||||
hasPassword: !!send.password,
|
||||
disabled: !!send.disabled,
|
||||
};
|
||||
}
|
||||
@@ -380,6 +382,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
<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-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 })}
|
||||
</span>
|
||||
</div>
|
||||
@@ -471,12 +474,23 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_password')}</span>
|
||||
<div className="password-wrap">
|
||||
<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)}>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
{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">
|
||||
<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)}>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_notes')}</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { Clipboard, Globe } from 'lucide-preact';
|
||||
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 type { Cipher } from '@/lib/types';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
@@ -14,17 +14,9 @@ interface TotpCodesPageProps {
|
||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||
}
|
||||
|
||||
const TOTP_PERIOD_SECONDS = 30;
|
||||
const TOTP_RING_RADIUS = 14;
|
||||
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||
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 }) {
|
||||
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
||||
@@ -32,13 +24,15 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||
|
||||
interface TotpRowProps {
|
||||
cipher: Cipher;
|
||||
live: { code: string; remain: number } | null;
|
||||
live: TotpCodeResult | null;
|
||||
onCopy: (value: string) => void;
|
||||
}
|
||||
|
||||
function TotpRow(props: TotpRowProps) {
|
||||
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
|
||||
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 (
|
||||
<div className="totp-code-row">
|
||||
@@ -69,8 +63,7 @@ function TotpRow(props: TotpRowProps) {
|
||||
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||
strokeDashoffset: String(
|
||||
TOTP_RING_CIRCUMFERENCE -
|
||||
TOTP_RING_CIRCUMFERENCE *
|
||||
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
||||
TOTP_RING_CIRCUMFERENCE * progress
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -86,8 +79,7 @@ function TotpRow(props: TotpRowProps) {
|
||||
}
|
||||
|
||||
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
|
||||
const [totpCodes, setTotpCodes] = useState<Record<string, TotpCodeResult | null>>({});
|
||||
const [columnCount, setColumnCount] = useState(1);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -120,11 +112,10 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
let stopped = false;
|
||||
let activeRun = 0;
|
||||
let timer = 0;
|
||||
let currentWindowId = -1;
|
||||
|
||||
const refreshCodes = async () => {
|
||||
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) {
|
||||
if (stopped || runId !== activeRun) return;
|
||||
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
|
||||
@@ -132,7 +123,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
batch.map(async (cipher) => {
|
||||
try {
|
||||
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
||||
return [cipher.id, next?.code || null] as const;
|
||||
return [cipher.id, next] as const;
|
||||
} catch {
|
||||
return [cipher.id, null] as const;
|
||||
}
|
||||
@@ -146,15 +137,20 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
if (stopped || runId !== activeRun) return;
|
||||
setTotpCodes((prev) => {
|
||||
let changed = false;
|
||||
const next: Record<string, string | null> = { ...prev };
|
||||
const next: Record<string, TotpCodeResult | null> = { ...prev };
|
||||
for (const id of Object.keys(next)) {
|
||||
if (id in nextCodes) continue;
|
||||
delete next[id];
|
||||
changed = true;
|
||||
}
|
||||
for (const [id, code] of Object.entries(nextCodes)) {
|
||||
if (next[id] === code) continue;
|
||||
next[id] = code;
|
||||
for (const [id, live] of Object.entries(nextCodes)) {
|
||||
const prevLive = next[id];
|
||||
if (
|
||||
prevLive?.code === live?.code &&
|
||||
prevLive?.remain === live?.remain &&
|
||||
prevLive?.period === live?.period
|
||||
) continue;
|
||||
next[id] = live;
|
||||
changed = true;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
@@ -162,10 +158,6 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
const next = getTotpTimeState();
|
||||
setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
|
||||
if (next.windowId === currentWindowId) return;
|
||||
currentWindowId = next.windowId;
|
||||
void refreshCodes();
|
||||
};
|
||||
|
||||
@@ -215,7 +207,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
<TotpRow
|
||||
key={cipher.id}
|
||||
cipher={cipher}
|
||||
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
|
||||
live={totpCodes[cipher.id] || null}
|
||||
onCopy={(value) => void copyToClipboard(value)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -12,23 +12,26 @@ import {
|
||||
cardListSubtitle,
|
||||
FOLDER_SORT_STORAGE_KEY,
|
||||
VAULT_SORT_STORAGE_KEY,
|
||||
bankAccountListSubtitle,
|
||||
cipherTypeKey,
|
||||
cipherTypeLabel,
|
||||
createEmptyDraft,
|
||||
creationTimeValue,
|
||||
draftFromCipher,
|
||||
driversLicenseListSubtitle,
|
||||
buildCipherDuplicateSignatures,
|
||||
firstCipherUri,
|
||||
firstPasskeyCreationTime,
|
||||
isCipherVisibleInArchive,
|
||||
isCipherVisibleInNormalVault,
|
||||
isCipherVisibleInTrash,
|
||||
passportListSubtitle,
|
||||
sortTimeValue,
|
||||
type DuplicateDetectionMode,
|
||||
type SidebarFilter,
|
||||
type VaultSortMode,
|
||||
} 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 { ChevronLeft } from 'lucide-preact';
|
||||
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||
@@ -106,7 +109,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const [renameFolderName, setRenameFolderName] = useState('');
|
||||
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
|
||||
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 [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
|
||||
const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({});
|
||||
@@ -308,10 +311,21 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const name = String(cipher.decName || cipher.name || '');
|
||||
const username = String(cipher.login?.decUsername || '');
|
||||
const uri = firstCipherUri(cipher);
|
||||
const typedText = [
|
||||
cipher.bankAccount?.decBankName,
|
||||
cipher.bankAccount?.decNameOnAccount,
|
||||
cipher.bankAccount?.decAccountNumber,
|
||||
cipher.driversLicense?.decLicenseNumber,
|
||||
cipher.driversLicense?.decFirstName,
|
||||
cipher.driversLicense?.decLastName,
|
||||
cipher.passport?.decPassportNumber,
|
||||
cipher.passport?.decGivenName,
|
||||
cipher.passport?.decSurname,
|
||||
].filter(Boolean).join('\n');
|
||||
const cipherId = String(cipher.id || '').trim();
|
||||
meta.set(cipher.id, {
|
||||
name,
|
||||
searchText: `${cipherId}\n${cipherId.replace(/-/g, '')}\n${name}\n${username}\n${uri}`.toLowerCase(),
|
||||
searchText: `${cipherId}\n${cipherId.replace(/-/g, '')}\n${name}\n${username}\n${uri}\n${typedText}`.toLowerCase(),
|
||||
firstUri: uri,
|
||||
typeKey: cipherTypeKey(Number(cipher.type || 1)),
|
||||
sortTime: sortTimeValue(cipher),
|
||||
@@ -542,6 +556,9 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
if (Number(cipher.type || 1) === 3) {
|
||||
return cardListSubtitle(cipher);
|
||||
}
|
||||
if (Number(cipher.type || 1) === 6) return bankAccountListSubtitle(cipher);
|
||||
if (Number(cipher.type || 1) === 7) return driversLicenseListSubtitle(cipher);
|
||||
if (Number(cipher.type || 1) === 8) return passportListSubtitle(cipher);
|
||||
return cipherTypeLabel(Number(cipher.type || 1));
|
||||
}, [cipherMetaById]);
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ import { createPortal } from 'preact/compat';
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||
import type { TotpCodeResult } from '@/lib/crypto';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
CardBrandIcon,
|
||||
TOTP_PERIOD_SECONDS,
|
||||
TOTP_RING_CIRCUMFERENCE,
|
||||
VaultListIcon,
|
||||
copyToClipboard,
|
||||
@@ -25,7 +25,7 @@ interface VaultDetailViewProps {
|
||||
selectedCipher: Cipher;
|
||||
repromptApprovedCipherId: string | null;
|
||||
showPassword: boolean;
|
||||
totpLive: { code: string; remain: number } | null;
|
||||
totpLive: TotpCodeResult | null;
|
||||
passkeyCreatedAt: string | null;
|
||||
hiddenFieldVisibleMap: Record<number, boolean>;
|
||||
folderName: (id: string | null | undefined) => string;
|
||||
@@ -42,6 +42,11 @@ interface VaultDetailViewProps {
|
||||
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: {
|
||||
open: boolean;
|
||||
entries: Array<{ password: string; lastUsedDate: string | null }>;
|
||||
@@ -191,8 +196,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||
strokeDashoffset: String(
|
||||
TOTP_RING_CIRCUMFERENCE -
|
||||
TOTP_RING_CIRCUMFERENCE *
|
||||
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
||||
TOTP_RING_CIRCUMFERENCE * totpProgress(props.totpLive)
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -327,6 +331,55 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.selectedCipher.bankAccount && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_bank_account_details')}</h4>
|
||||
<div className="kv-line"><span>{t('txt_bank_name')}</span><strong>{props.selectedCipher.bankAccount.decBankName || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_name_on_account')}</span><strong>{props.selectedCipher.bankAccount.decNameOnAccount || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_account_type')}</span><strong>{props.selectedCipher.bankAccount.decAccountType || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_account_number')}</span><strong>{props.selectedCipher.bankAccount.decAccountNumber || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_routing_number')}</span><strong>{props.selectedCipher.bankAccount.decRoutingNumber || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_branch_number')}</span><strong>{props.selectedCipher.bankAccount.decBranchNumber || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_pin')}</span><strong>{props.selectedCipher.bankAccount.decPin || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_swift_code')}</span><strong>{props.selectedCipher.bankAccount.decSwiftCode || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_iban')}</span><strong>{props.selectedCipher.bankAccount.decIban || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_bank_contact_phone')}</span><strong>{props.selectedCipher.bankAccount.decBankContactPhone || ''}</strong></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.selectedCipher.driversLicense && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_drivers_license_details')}</h4>
|
||||
<div className="kv-line"><span>{t('txt_name')}</span><strong>{[props.selectedCipher.driversLicense.decFirstName, props.selectedCipher.driversLicense.decMiddleName, props.selectedCipher.driversLicense.decLastName].filter(Boolean).join(' ')}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_date_of_birth')}</span><strong>{props.selectedCipher.driversLicense.decDateOfBirth || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_license_number')}</span><strong>{props.selectedCipher.driversLicense.decLicenseNumber || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_issuing_country')}</span><strong>{props.selectedCipher.driversLicense.decIssuingCountry || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_issuing_state')}</span><strong>{props.selectedCipher.driversLicense.decIssuingState || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_issue_date')}</span><strong>{props.selectedCipher.driversLicense.decIssueDate || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{props.selectedCipher.driversLicense.decExpirationDate || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_issuing_authority')}</span><strong>{props.selectedCipher.driversLicense.decIssuingAuthority || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_license_class')}</span><strong>{props.selectedCipher.driversLicense.decLicenseClass || ''}</strong></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.selectedCipher.passport && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_passport_details')}</h4>
|
||||
<div className="kv-line"><span>{t('txt_name')}</span><strong>{[props.selectedCipher.passport.decGivenName, props.selectedCipher.passport.decSurname].filter(Boolean).join(' ')}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_date_of_birth')}</span><strong>{props.selectedCipher.passport.decDateOfBirth || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_sex')}</span><strong>{props.selectedCipher.passport.decSex || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_birth_place')}</span><strong>{props.selectedCipher.passport.decBirthPlace || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_nationality')}</span><strong>{props.selectedCipher.passport.decNationality || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_issuing_country')}</span><strong>{props.selectedCipher.passport.decIssuingCountry || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_passport_number')}</span><strong>{props.selectedCipher.passport.decPassportNumber || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_passport_type')}</span><strong>{props.selectedCipher.passport.decPassportType || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_national_id_number')}</span><strong>{props.selectedCipher.passport.decNationalIdentificationNumber || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_issuing_authority')}</span><strong>{props.selectedCipher.passport.decIssuingAuthority || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_issue_date')}</span><strong>{props.selectedCipher.passport.decIssueDate || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{props.selectedCipher.passport.decExpirationDate || ''}</strong></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!(props.selectedCipher.decNotes || '').trim() && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_notes')}</h4>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { RefObject } from 'preact';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||
import jsQR from 'jsqr';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||
import { normalizeTotpInput } from '@/lib/crypto';
|
||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { cardBrand } from '@/lib/import-format-shared';
|
||||
@@ -66,6 +68,8 @@ interface WebsiteRowProps {
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
const TOTP_QR_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
function WebsiteRow(props: WebsiteRowProps) {
|
||||
const websiteMatchOptions = getWebsiteMatchOptions();
|
||||
|
||||
@@ -158,9 +162,9 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
};
|
||||
|
||||
const applyTotpQrValue = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
props.onUpdateDraft({ loginTotp: trimmed });
|
||||
const normalized = normalizeTotpInput(value);
|
||||
if (!normalized) return false;
|
||||
props.onUpdateDraft({ loginTotp: normalized });
|
||||
setTotpQrStatus(t('txt_totp_qr_scanned'));
|
||||
setTotpQrOpen(false);
|
||||
return true;
|
||||
@@ -171,20 +175,50 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
return new window.BarcodeDetector({ formats: ['qr_code'] });
|
||||
};
|
||||
|
||||
const decodeTotpQrImage = async (source: ImageBitmapSource): Promise<boolean> => {
|
||||
const decodeTotpQrCanvas = (source: ImageBitmap | HTMLVideoElement): string => {
|
||||
const width = 'videoWidth' in source ? source.videoWidth : source.width;
|
||||
const height = 'videoHeight' in source ? source.videoHeight : source.height;
|
||||
if (!width || !height) return '';
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return '';
|
||||
// jsQR ignores alpha and reads RGB directly, so transparent pixels would be
|
||||
// treated as black. Composite over white first so transparent-background QR
|
||||
// exports do not become black-on-black and fail to decode.
|
||||
context.fillStyle = '#ffffff';
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.drawImage(source, 0, 0, width, height);
|
||||
const imageData = context.getImageData(0, 0, width, height);
|
||||
return String(jsQR(imageData.data, width, height)?.data || '').trim();
|
||||
};
|
||||
|
||||
const decodeTotpQrImage = async (source: ImageBitmap): Promise<boolean> => {
|
||||
const detector = createTotpQrDetector();
|
||||
if (!detector) {
|
||||
setTotpQrStatus(t('txt_totp_qr_unsupported'));
|
||||
return false;
|
||||
if (detector) {
|
||||
try {
|
||||
const results = await detector.detect(source);
|
||||
const value = String(results[0]?.rawValue || '').trim();
|
||||
if (value && applyTotpQrValue(value)) return true;
|
||||
} catch {
|
||||
// Fall back to jsQR when the native detector is present but not usable.
|
||||
}
|
||||
}
|
||||
const results = await detector.detect(source);
|
||||
const value = String(results[0]?.rawValue || '').trim();
|
||||
if (!value) return false;
|
||||
return applyTotpQrValue(value);
|
||||
const value = decodeTotpQrCanvas(source);
|
||||
return value ? applyTotpQrValue(value) : false;
|
||||
};
|
||||
|
||||
const handleTotpQrFile = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
if (file.type && !file.type.startsWith('image/')) {
|
||||
setTotpQrStatus(t('txt_totp_qr_invalid_image_type'));
|
||||
return;
|
||||
}
|
||||
if (file.size > TOTP_QR_IMAGE_MAX_BYTES) {
|
||||
setTotpQrStatus(t('txt_totp_qr_image_too_large'));
|
||||
return;
|
||||
}
|
||||
setTotpQrBusy(true);
|
||||
setTotpQrStatus(t('txt_totp_qr_scanning'));
|
||||
let bitmap: ImageBitmap | null = null;
|
||||
@@ -206,14 +240,8 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
return;
|
||||
}
|
||||
let stopped = false;
|
||||
let lastCanvasScan = 0;
|
||||
const detector = createTotpQrDetector();
|
||||
if (!detector) {
|
||||
setTotpQrStatus(t('txt_totp_qr_unsupported'));
|
||||
return () => {
|
||||
stopped = true;
|
||||
stopTotpQrScanner();
|
||||
};
|
||||
}
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
|
||||
return () => {
|
||||
@@ -230,8 +258,25 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const results = await detector.detect(video);
|
||||
const value = String(results[0]?.rawValue || '').trim();
|
||||
let value = '';
|
||||
if (detector) {
|
||||
try {
|
||||
const results = await detector.detect(video);
|
||||
value = String(results[0]?.rawValue || '').trim();
|
||||
} catch {
|
||||
// Fall back to jsQR when the native detector is present but not usable.
|
||||
}
|
||||
}
|
||||
// The jsQR fallback runs a synchronous full-frame decode, so throttle
|
||||
// it to a few times per second instead of every animation frame to
|
||||
// avoid pegging the CPU while a code is being aligned.
|
||||
if (!value) {
|
||||
const now = performance.now();
|
||||
if (now - lastCanvasScan >= 250) {
|
||||
lastCanvasScan = now;
|
||||
value = decodeTotpQrCanvas(video);
|
||||
}
|
||||
}
|
||||
if (value && applyTotpQrValue(value)) return;
|
||||
} catch {
|
||||
// Keep the camera active; transient frame decode failures are common.
|
||||
@@ -545,6 +590,64 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.draft.type === 6 && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_bank_account_details')}</h4>
|
||||
<div className="field-grid">
|
||||
<label className="field"><span>{t('txt_bank_name')}</span><input className="input" value={props.draft.bankName} onInput={(e) => props.onUpdateDraft({ bankName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_name_on_account')}</span><input className="input" value={props.draft.bankNameOnAccount} onInput={(e) => props.onUpdateDraft({ bankNameOnAccount: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_account_type')}</span><input className="input" value={props.draft.bankAccountType} onInput={(e) => props.onUpdateDraft({ bankAccountType: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_account_number')}</span><input className="input" value={props.draft.bankAccountNumber} onInput={(e) => props.onUpdateDraft({ bankAccountNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_routing_number')}</span><input className="input" value={props.draft.bankRoutingNumber} onInput={(e) => props.onUpdateDraft({ bankRoutingNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_branch_number')}</span><input className="input" value={props.draft.bankBranchNumber} onInput={(e) => props.onUpdateDraft({ bankBranchNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_pin')}</span><input className="input" value={props.draft.bankPin} onInput={(e) => props.onUpdateDraft({ bankPin: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_swift_code')}</span><input className="input" value={props.draft.bankSwiftCode} onInput={(e) => props.onUpdateDraft({ bankSwiftCode: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_iban')}</span><input className="input" value={props.draft.bankIban} onInput={(e) => props.onUpdateDraft({ bankIban: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_bank_contact_phone')}</span><input className="input" value={props.draft.bankContactPhone} onInput={(e) => props.onUpdateDraft({ bankContactPhone: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.draft.type === 7 && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_drivers_license_details')}</h4>
|
||||
<div className="field-grid">
|
||||
<label className="field"><span>{t('txt_first_name')}</span><input className="input" value={props.draft.licenseFirstName} onInput={(e) => props.onUpdateDraft({ licenseFirstName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_middle_name')}</span><input className="input" value={props.draft.licenseMiddleName} onInput={(e) => props.onUpdateDraft({ licenseMiddleName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_last_name')}</span><input className="input" value={props.draft.licenseLastName} onInput={(e) => props.onUpdateDraft({ licenseLastName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_date_of_birth')}</span><input className="input" value={props.draft.licenseDateOfBirth} onInput={(e) => props.onUpdateDraft({ licenseDateOfBirth: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_license_number')}</span><input className="input" value={props.draft.licenseNumber} onInput={(e) => props.onUpdateDraft({ licenseNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_issuing_country')}</span><input className="input" value={props.draft.licenseIssuingCountry} onInput={(e) => props.onUpdateDraft({ licenseIssuingCountry: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_issuing_state')}</span><input className="input" value={props.draft.licenseIssuingState} onInput={(e) => props.onUpdateDraft({ licenseIssuingState: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_issue_date')}</span><input className="input" value={props.draft.licenseIssueDate} onInput={(e) => props.onUpdateDraft({ licenseIssueDate: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_expiration_date')}</span><input className="input" value={props.draft.licenseExpirationDate} onInput={(e) => props.onUpdateDraft({ licenseExpirationDate: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_issuing_authority')}</span><input className="input" value={props.draft.licenseIssuingAuthority} onInput={(e) => props.onUpdateDraft({ licenseIssuingAuthority: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_license_class')}</span><input className="input" value={props.draft.licenseClass} onInput={(e) => props.onUpdateDraft({ licenseClass: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.draft.type === 8 && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_passport_details')}</h4>
|
||||
<div className="field-grid">
|
||||
<label className="field"><span>{t('txt_surname')}</span><input className="input" value={props.draft.passportSurname} onInput={(e) => props.onUpdateDraft({ passportSurname: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_given_name')}</span><input className="input" value={props.draft.passportGivenName} onInput={(e) => props.onUpdateDraft({ passportGivenName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_date_of_birth')}</span><input className="input" value={props.draft.passportDateOfBirth} onInput={(e) => props.onUpdateDraft({ passportDateOfBirth: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_sex')}</span><input className="input" value={props.draft.passportSex} onInput={(e) => props.onUpdateDraft({ passportSex: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_birth_place')}</span><input className="input" value={props.draft.passportBirthPlace} onInput={(e) => props.onUpdateDraft({ passportBirthPlace: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_nationality')}</span><input className="input" value={props.draft.passportNationality} onInput={(e) => props.onUpdateDraft({ passportNationality: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_issuing_country')}</span><input className="input" value={props.draft.passportIssuingCountry} onInput={(e) => props.onUpdateDraft({ passportIssuingCountry: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_passport_number')}</span><input className="input" value={props.draft.passportNumber} onInput={(e) => props.onUpdateDraft({ passportNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_passport_type')}</span><input className="input" value={props.draft.passportType} onInput={(e) => props.onUpdateDraft({ passportType: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_national_id_number')}</span><input className="input" value={props.draft.passportNationalIdentificationNumber} onInput={(e) => props.onUpdateDraft({ passportNationalIdentificationNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_issuing_authority')}</span><input className="input" value={props.draft.passportIssuingAuthority} onInput={(e) => props.onUpdateDraft({ passportIssuingAuthority: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_issue_date')}</span><input className="input" value={props.draft.passportIssueDate} onInput={(e) => props.onUpdateDraft({ passportIssueDate: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_expiration_date')}</span><input className="input" value={props.draft.passportExpirationDate} onInput={(e) => props.onUpdateDraft({ passportExpirationDate: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="section-head attachment-head">
|
||||
<h4>{t('txt_attachments')}</h4>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RefObject } from 'preact';
|
||||
import {
|
||||
Archive,
|
||||
ArrowUpDown,
|
||||
BookUser,
|
||||
Check,
|
||||
Copy,
|
||||
CreditCard,
|
||||
@@ -10,7 +11,9 @@ import {
|
||||
FolderPlus,
|
||||
FolderX,
|
||||
Globe,
|
||||
IdCard,
|
||||
KeyRound,
|
||||
Landmark,
|
||||
LayoutGrid,
|
||||
Pencil,
|
||||
ShieldUser,
|
||||
@@ -117,9 +120,18 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'card' })}>
|
||||
<CreditCard size={14} className="tree-icon" /> <span className="tree-label">{t('txt_card')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'bank' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'bank' })}>
|
||||
<Landmark size={14} className="tree-icon" /> <span className="tree-label">{t('txt_bank_account')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'identity' })}>
|
||||
<ShieldUser size={14} className="tree-icon" /> <span className="tree-label">{t('txt_identity')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'license' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'license' })}>
|
||||
<IdCard size={14} className="tree-icon" /> <span className="tree-label">{t('txt_drivers_license')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'passport' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'passport' })}>
|
||||
<BookUser size={14} className="tree-icon" /> <span className="tree-label">{t('txt_passport')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'note' })}>
|
||||
<StickyNote size={14} className="tree-icon" /> <span className="tree-label">{t('txt_note')}</span>
|
||||
</button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@/lib/website-icon-cache';
|
||||
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
||||
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
|
||||
import { areWebsiteIconsEnabled } from '@/lib/website-icon-settings';
|
||||
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||
|
||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
||||
@@ -22,7 +23,8 @@ interface WebsiteIconProps {
|
||||
|
||||
export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
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 [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
||||
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
||||
@@ -33,7 +35,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!host) {
|
||||
if (!host || !iconsEnabled) {
|
||||
setShouldLoad(true);
|
||||
setStatus('idle');
|
||||
setImageUrl('');
|
||||
@@ -47,7 +49,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
setStatus(next);
|
||||
setImageUrl(getWebsiteIconImageUrl(host));
|
||||
});
|
||||
}, [host]);
|
||||
}, [host, iconsEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!host || shouldLoad || status === 'loaded' || status === 'error') return;
|
||||
@@ -81,10 +83,11 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
useEffect(() => {
|
||||
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
||||
if (demoIconUrl) return;
|
||||
if (!iconsEnabled) return;
|
||||
if (networkStatus !== 'online') return;
|
||||
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
||||
beginWebsiteIconLoad(host, src);
|
||||
}, [demoIconUrl, host, networkStatus, src, shouldLoad, status]);
|
||||
}, [demoIconUrl, host, iconsEnabled, networkStatus, src, shouldLoad, status]);
|
||||
|
||||
if (demoIconUrl) {
|
||||
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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import {
|
||||
BookUser,
|
||||
CreditCard,
|
||||
FileKey2,
|
||||
Globe,
|
||||
IdCard,
|
||||
KeyRound,
|
||||
Landmark,
|
||||
ShieldUser,
|
||||
StickyNote,
|
||||
} from 'lucide-preact';
|
||||
@@ -14,7 +17,7 @@ import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils
|
||||
import { normalizeEquivalentDomain } from '@shared/domain-normalize';
|
||||
import WebsiteIcon from './WebsiteIcon';
|
||||
|
||||
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh' | 'bank' | 'license' | 'passport';
|
||||
export type VaultSortMode = 'edited' | 'created' | 'name';
|
||||
export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password';
|
||||
export type SidebarFilter =
|
||||
@@ -98,6 +101,32 @@ export function cardListSubtitle(cipher: Cipher): string {
|
||||
return cipherTypeLabel(3);
|
||||
}
|
||||
|
||||
export function bankAccountListSubtitle(cipher: Cipher): string {
|
||||
const bankName = valueOrFallback(cipher.bankAccount?.decBankName ?? cipher.bankAccount?.bankName).trim();
|
||||
const accountType = valueOrFallback(cipher.bankAccount?.decAccountType ?? cipher.bankAccount?.accountType).trim();
|
||||
const accountNumber = valueOrFallback(cipher.bankAccount?.decAccountNumber ?? cipher.bankAccount?.accountNumber).replace(/\D/g, '');
|
||||
const last4 = accountNumber.length >= 4 ? accountNumber.slice(-4) : '';
|
||||
return [bankName, accountType, last4 ? `*${last4}` : ''].filter(Boolean).join(', ') || cipherTypeLabel(6);
|
||||
}
|
||||
|
||||
export function driversLicenseListSubtitle(cipher: Cipher): string {
|
||||
const licenseNumber = valueOrFallback(cipher.driversLicense?.decLicenseNumber ?? cipher.driversLicense?.licenseNumber).trim();
|
||||
const name = [
|
||||
valueOrFallback(cipher.driversLicense?.decFirstName ?? cipher.driversLicense?.firstName).trim(),
|
||||
valueOrFallback(cipher.driversLicense?.decLastName ?? cipher.driversLicense?.lastName).trim(),
|
||||
].filter(Boolean).join(' ');
|
||||
return licenseNumber || name || cipherTypeLabel(7);
|
||||
}
|
||||
|
||||
export function passportListSubtitle(cipher: Cipher): string {
|
||||
const passportNumber = valueOrFallback(cipher.passport?.decPassportNumber ?? cipher.passport?.passportNumber).trim();
|
||||
const name = [
|
||||
valueOrFallback(cipher.passport?.decGivenName ?? cipher.passport?.givenName).trim(),
|
||||
valueOrFallback(cipher.passport?.decSurname ?? cipher.passport?.surname).trim(),
|
||||
].filter(Boolean).join(' ');
|
||||
return passportNumber || name || cipherTypeLabel(8);
|
||||
}
|
||||
|
||||
export function CardBrandIcon({ brand }: { brand?: string | null }) {
|
||||
const display = displayCardBrand(brand);
|
||||
const key = display.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'generic';
|
||||
@@ -118,7 +147,10 @@ export function getCreateTypeOptions(): TypeOption[] {
|
||||
return [
|
||||
{ type: 1, label: t('txt_login') },
|
||||
{ type: 3, label: t('txt_card') },
|
||||
{ type: 6, label: t('txt_bank_account') },
|
||||
{ type: 4, label: t('txt_identity') },
|
||||
{ type: 7, label: t('txt_drivers_license') },
|
||||
{ type: 8, label: t('txt_passport') },
|
||||
{ type: 2, label: t('txt_note') },
|
||||
{ type: 5, label: t('txt_ssh_key') },
|
||||
];
|
||||
@@ -175,8 +207,7 @@ export function getWebsiteMatchOptions(): Array<{ value: number | null; label: s
|
||||
];
|
||||
}
|
||||
|
||||
export const TOTP_PERIOD_SECONDS = 30;
|
||||
export const TOTP_RING_RADIUS = 14;
|
||||
const TOTP_RING_RADIUS = 14;
|
||||
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||
|
||||
export function CreateTypeIcon({ type }: { type: number }) {
|
||||
@@ -185,6 +216,9 @@ export function CreateTypeIcon({ type }: { type: number }) {
|
||||
if (type === 4) return <ShieldUser size={15} />;
|
||||
if (type === 2) return <StickyNote size={15} />;
|
||||
if (type === 5) return <KeyRound size={15} />;
|
||||
if (type === 6) return <Landmark size={15} />;
|
||||
if (type === 7) return <IdCard size={15} />;
|
||||
if (type === 8) return <BookUser size={15} />;
|
||||
return <FileKey2 size={15} />;
|
||||
}
|
||||
|
||||
@@ -193,7 +227,11 @@ export function cipherTypeKey(type: number): TypeFilter {
|
||||
if (type === 3) return 'card';
|
||||
if (type === 4) return 'identity';
|
||||
if (type === 2) return 'note';
|
||||
return 'ssh';
|
||||
if (type === 5) return 'ssh';
|
||||
if (type === 6) return 'bank';
|
||||
if (type === 7) return 'license';
|
||||
if (type === 8) return 'passport';
|
||||
return 'note';
|
||||
}
|
||||
|
||||
function cipherDeletedValue(cipher: Cipher): boolean {
|
||||
@@ -230,6 +268,9 @@ export function cipherTypeLabel(type: number): string {
|
||||
if (type === 4) return t('txt_identity');
|
||||
if (type === 2) return t('txt_secure_note');
|
||||
if (type === 5) return t('txt_ssh_key');
|
||||
if (type === 6) return t('txt_bank_account');
|
||||
if (type === 7) return t('txt_drivers_license');
|
||||
if (type === 8) return t('txt_passport');
|
||||
return t('txt_item');
|
||||
}
|
||||
|
||||
@@ -239,6 +280,9 @@ export function TypeIcon({ type }: { type: number }) {
|
||||
if (type === 4) return <ShieldUser size={18} />;
|
||||
if (type === 2) return <StickyNote size={18} />;
|
||||
if (type === 5) return <KeyRound size={18} />;
|
||||
if (type === 6) return <Landmark size={18} />;
|
||||
if (type === 7) return <IdCard size={18} />;
|
||||
if (type === 8) return <BookUser size={18} />;
|
||||
return <FileKey2 size={18} />;
|
||||
}
|
||||
|
||||
@@ -355,6 +399,52 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
||||
fingerprint: valueOrFallback(cipher.sshKey.decFingerprint ?? cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint),
|
||||
}
|
||||
: null,
|
||||
bankAccount: cipher.bankAccount
|
||||
? {
|
||||
bankName: valueOrFallback(cipher.bankAccount.decBankName ?? cipher.bankAccount.bankName),
|
||||
nameOnAccount: valueOrFallback(cipher.bankAccount.decNameOnAccount ?? cipher.bankAccount.nameOnAccount),
|
||||
accountType: valueOrFallback(cipher.bankAccount.decAccountType ?? cipher.bankAccount.accountType),
|
||||
accountNumber: valueOrFallback(cipher.bankAccount.decAccountNumber ?? cipher.bankAccount.accountNumber),
|
||||
routingNumber: valueOrFallback(cipher.bankAccount.decRoutingNumber ?? cipher.bankAccount.routingNumber),
|
||||
branchNumber: valueOrFallback(cipher.bankAccount.decBranchNumber ?? cipher.bankAccount.branchNumber),
|
||||
pin: valueOrFallback(cipher.bankAccount.decPin ?? cipher.bankAccount.pin),
|
||||
swiftCode: valueOrFallback(cipher.bankAccount.decSwiftCode ?? cipher.bankAccount.swiftCode),
|
||||
iban: valueOrFallback(cipher.bankAccount.decIban ?? cipher.bankAccount.iban),
|
||||
bankContactPhone: valueOrFallback(cipher.bankAccount.decBankContactPhone ?? cipher.bankAccount.bankContactPhone),
|
||||
}
|
||||
: null,
|
||||
driversLicense: cipher.driversLicense
|
||||
? {
|
||||
firstName: valueOrFallback(cipher.driversLicense.decFirstName ?? cipher.driversLicense.firstName),
|
||||
middleName: valueOrFallback(cipher.driversLicense.decMiddleName ?? cipher.driversLicense.middleName),
|
||||
lastName: valueOrFallback(cipher.driversLicense.decLastName ?? cipher.driversLicense.lastName),
|
||||
dateOfBirth: valueOrFallback(cipher.driversLicense.decDateOfBirth ?? cipher.driversLicense.dateOfBirth),
|
||||
licenseNumber: valueOrFallback(cipher.driversLicense.decLicenseNumber ?? cipher.driversLicense.licenseNumber),
|
||||
issuingCountry: valueOrFallback(cipher.driversLicense.decIssuingCountry ?? cipher.driversLicense.issuingCountry),
|
||||
issuingState: valueOrFallback(cipher.driversLicense.decIssuingState ?? cipher.driversLicense.issuingState),
|
||||
issueDate: valueOrFallback(cipher.driversLicense.decIssueDate ?? cipher.driversLicense.issueDate),
|
||||
expirationDate: valueOrFallback(cipher.driversLicense.decExpirationDate ?? cipher.driversLicense.expirationDate),
|
||||
issuingAuthority: valueOrFallback(cipher.driversLicense.decIssuingAuthority ?? cipher.driversLicense.issuingAuthority),
|
||||
licenseClass: valueOrFallback(cipher.driversLicense.decLicenseClass ?? cipher.driversLicense.licenseClass),
|
||||
}
|
||||
: null,
|
||||
passport: cipher.passport
|
||||
? {
|
||||
surname: valueOrFallback(cipher.passport.decSurname ?? cipher.passport.surname),
|
||||
givenName: valueOrFallback(cipher.passport.decGivenName ?? cipher.passport.givenName),
|
||||
dateOfBirth: valueOrFallback(cipher.passport.decDateOfBirth ?? cipher.passport.dateOfBirth),
|
||||
sex: valueOrFallback(cipher.passport.decSex ?? cipher.passport.sex),
|
||||
birthPlace: valueOrFallback(cipher.passport.decBirthPlace ?? cipher.passport.birthPlace),
|
||||
nationality: valueOrFallback(cipher.passport.decNationality ?? cipher.passport.nationality),
|
||||
issuingCountry: valueOrFallback(cipher.passport.decIssuingCountry ?? cipher.passport.issuingCountry),
|
||||
passportNumber: valueOrFallback(cipher.passport.decPassportNumber ?? cipher.passport.passportNumber),
|
||||
passportType: valueOrFallback(cipher.passport.decPassportType ?? cipher.passport.passportType),
|
||||
nationalIdentificationNumber: valueOrFallback(cipher.passport.decNationalIdentificationNumber ?? cipher.passport.nationalIdentificationNumber),
|
||||
issuingAuthority: valueOrFallback(cipher.passport.decIssuingAuthority ?? cipher.passport.issuingAuthority),
|
||||
issueDate: valueOrFallback(cipher.passport.decIssueDate ?? cipher.passport.issueDate),
|
||||
expirationDate: valueOrFallback(cipher.passport.decExpirationDate ?? cipher.passport.expirationDate),
|
||||
}
|
||||
: null,
|
||||
secureNoteType: cipher.secureNote?.type ?? null,
|
||||
fields: (cipher.fields || []).map((field) => ({
|
||||
type: field.type ?? null,
|
||||
@@ -427,6 +517,40 @@ export function createEmptyDraft(type: number): VaultDraft {
|
||||
sshPrivateKey: '',
|
||||
sshPublicKey: '',
|
||||
sshFingerprint: '',
|
||||
bankName: '',
|
||||
bankNameOnAccount: '',
|
||||
bankAccountType: '',
|
||||
bankAccountNumber: '',
|
||||
bankRoutingNumber: '',
|
||||
bankBranchNumber: '',
|
||||
bankPin: '',
|
||||
bankSwiftCode: '',
|
||||
bankIban: '',
|
||||
bankContactPhone: '',
|
||||
licenseFirstName: '',
|
||||
licenseMiddleName: '',
|
||||
licenseLastName: '',
|
||||
licenseDateOfBirth: '',
|
||||
licenseNumber: '',
|
||||
licenseIssuingCountry: '',
|
||||
licenseIssuingState: '',
|
||||
licenseIssueDate: '',
|
||||
licenseExpirationDate: '',
|
||||
licenseIssuingAuthority: '',
|
||||
licenseClass: '',
|
||||
passportSurname: '',
|
||||
passportGivenName: '',
|
||||
passportDateOfBirth: '',
|
||||
passportSex: '',
|
||||
passportBirthPlace: '',
|
||||
passportNationality: '',
|
||||
passportIssuingCountry: '',
|
||||
passportNumber: '',
|
||||
passportType: '',
|
||||
passportNationalIdentificationNumber: '',
|
||||
passportIssuingAuthority: '',
|
||||
passportIssueDate: '',
|
||||
passportExpirationDate: '',
|
||||
customFields: [],
|
||||
};
|
||||
}
|
||||
@@ -490,6 +614,46 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
|
||||
draft.sshPublicKey = cipher.sshKey.decPublicKey || '';
|
||||
draft.sshFingerprint = cipher.sshKey.decFingerprint || '';
|
||||
}
|
||||
if (cipher.bankAccount) {
|
||||
draft.bankName = cipher.bankAccount.decBankName || '';
|
||||
draft.bankNameOnAccount = cipher.bankAccount.decNameOnAccount || '';
|
||||
draft.bankAccountType = cipher.bankAccount.decAccountType || '';
|
||||
draft.bankAccountNumber = cipher.bankAccount.decAccountNumber || '';
|
||||
draft.bankRoutingNumber = cipher.bankAccount.decRoutingNumber || '';
|
||||
draft.bankBranchNumber = cipher.bankAccount.decBranchNumber || '';
|
||||
draft.bankPin = cipher.bankAccount.decPin || '';
|
||||
draft.bankSwiftCode = cipher.bankAccount.decSwiftCode || '';
|
||||
draft.bankIban = cipher.bankAccount.decIban || '';
|
||||
draft.bankContactPhone = cipher.bankAccount.decBankContactPhone || '';
|
||||
}
|
||||
if (cipher.driversLicense) {
|
||||
draft.licenseFirstName = cipher.driversLicense.decFirstName || '';
|
||||
draft.licenseMiddleName = cipher.driversLicense.decMiddleName || '';
|
||||
draft.licenseLastName = cipher.driversLicense.decLastName || '';
|
||||
draft.licenseDateOfBirth = cipher.driversLicense.decDateOfBirth || '';
|
||||
draft.licenseNumber = cipher.driversLicense.decLicenseNumber || '';
|
||||
draft.licenseIssuingCountry = cipher.driversLicense.decIssuingCountry || '';
|
||||
draft.licenseIssuingState = cipher.driversLicense.decIssuingState || '';
|
||||
draft.licenseIssueDate = cipher.driversLicense.decIssueDate || '';
|
||||
draft.licenseExpirationDate = cipher.driversLicense.decExpirationDate || '';
|
||||
draft.licenseIssuingAuthority = cipher.driversLicense.decIssuingAuthority || '';
|
||||
draft.licenseClass = cipher.driversLicense.decLicenseClass || '';
|
||||
}
|
||||
if (cipher.passport) {
|
||||
draft.passportSurname = cipher.passport.decSurname || '';
|
||||
draft.passportGivenName = cipher.passport.decGivenName || '';
|
||||
draft.passportDateOfBirth = cipher.passport.decDateOfBirth || '';
|
||||
draft.passportSex = cipher.passport.decSex || '';
|
||||
draft.passportBirthPlace = cipher.passport.decBirthPlace || '';
|
||||
draft.passportNationality = cipher.passport.decNationality || '';
|
||||
draft.passportIssuingCountry = cipher.passport.decIssuingCountry || '';
|
||||
draft.passportNumber = cipher.passport.decPassportNumber || '';
|
||||
draft.passportType = cipher.passport.decPassportType || '';
|
||||
draft.passportNationalIdentificationNumber = cipher.passport.decNationalIdentificationNumber || '';
|
||||
draft.passportIssuingAuthority = cipher.passport.decIssuingAuthority || '';
|
||||
draft.passportIssueDate = cipher.passport.decIssueDate || '';
|
||||
draft.passportExpirationDate = cipher.passport.decExpirationDate || '';
|
||||
}
|
||||
draft.customFields = (cipher.fields || []).map((field) => ({
|
||||
type: parseFieldType(field.type),
|
||||
label: field.decName || '',
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import {
|
||||
changeMasterPassword,
|
||||
bootstrapYubiKeyOtpApiCredentials,
|
||||
deleteAllAuthorizedDevices,
|
||||
deleteAuthorizedDevice,
|
||||
deleteAuthorizedDevices,
|
||||
deriveLoginHash,
|
||||
deleteAccountPasskey as deleteAccountPasskeyApi,
|
||||
deleteTwoFactorPasskey as deleteTwoFactorPasskeyApi,
|
||||
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
|
||||
disableTwoFactorPasskeys as disableTwoFactorPasskeysApi,
|
||||
disableYubiKeyOtp,
|
||||
getCurrentDeviceIdentifier,
|
||||
getApiKey,
|
||||
getAccountPasskeyAttestationOptions,
|
||||
getAccountPasskeyUpdateAssertionOptions,
|
||||
getTotpRecoveryCode,
|
||||
getTwoFactorPasskeyChallenge,
|
||||
getTwoFactorPasskeySettings as getTwoFactorPasskeySettingsApi,
|
||||
getYubiKeyOtpSettings,
|
||||
listAccountPasskeys,
|
||||
rotateApiKey,
|
||||
revokeAuthorizedDeviceTrust,
|
||||
revokeAllAuthorizedDeviceTrust,
|
||||
saveAccountPasskey,
|
||||
saveTwoFactorPasskey,
|
||||
saveYubiKeyOtpApiCredentials,
|
||||
saveYubiKeyOtpSettings,
|
||||
setTotp,
|
||||
trustAuthorizedDevicePermanently,
|
||||
updateAuthorizedDeviceName,
|
||||
@@ -28,11 +38,12 @@ import {
|
||||
buildAccountPasskeyPrfKeySet,
|
||||
buildAccountPasskeyPrfKeySetFromPrfKey,
|
||||
createAccountPasskeyCredential,
|
||||
createTwoFactorPasskeyCredential,
|
||||
} from '@/lib/account-passkeys';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||
import type { AuthedFetch } from '@/lib/api/shared';
|
||||
import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState, TwoFactorPasskeySettings, YubiKeyOtpSettings } from '@/lib/types';
|
||||
|
||||
type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
|
||||
@@ -47,7 +58,7 @@ interface UseAccountSecurityActionsOptions {
|
||||
onNotify: Notify;
|
||||
onProfileUpdated: (profile: Profile) => void;
|
||||
onSetConfirm: (next: AppConfirmState | null) => void;
|
||||
refetchTotpStatus: () => Promise<unknown>;
|
||||
refetchTwoFactorStatus: () => Promise<unknown>;
|
||||
refetchAuthorizedDevices: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -63,7 +74,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
onNotify,
|
||||
onProfileUpdated,
|
||||
onSetConfirm,
|
||||
refetchTotpStatus,
|
||||
refetchTwoFactorStatus,
|
||||
refetchAuthorizedDevices,
|
||||
} = options;
|
||||
|
||||
@@ -187,13 +198,118 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
|
||||
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
|
||||
clearDisableTotpDialog();
|
||||
await refetchTotpStatus();
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_totp_disabled'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_disable_totp_failed'));
|
||||
}
|
||||
},
|
||||
|
||||
async getYubiKeySettings(masterPassword: string): Promise<YubiKeyOtpSettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
return getYubiKeyOtpSettings(authedFetch, derived.hash);
|
||||
},
|
||||
|
||||
async saveYubiKeySettings(keys: string[], nfc: boolean, masterPassword: string): Promise<YubiKeyOtpSettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const settings = await saveYubiKeyOtpSettings(authedFetch, { keys, nfc, masterPasswordHash: derived.hash });
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_yubikeys_updated'));
|
||||
return settings;
|
||||
},
|
||||
|
||||
async saveYubiKeyApiCredentials(clientId: string, secretKey: string, masterPassword: string): Promise<YubiKeyOtpSettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const settings = await saveYubiKeyOtpApiCredentials(authedFetch, {
|
||||
masterPasswordHash: derived.hash,
|
||||
yubicoClientId: clientId,
|
||||
yubicoSecretKey: secretKey,
|
||||
});
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_yubikey_config_updated'));
|
||||
return settings;
|
||||
},
|
||||
|
||||
async bootstrapYubiKeyApiCredentials(otp: string, masterPassword: string): Promise<YubiKeyOtpSettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const settings = await bootstrapYubiKeyOtpApiCredentials(authedFetch, {
|
||||
masterPasswordHash: derived.hash,
|
||||
otp,
|
||||
});
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_yubikey_config_updated'));
|
||||
return settings;
|
||||
},
|
||||
|
||||
async disableYubiKey(masterPassword: string): Promise<void> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
await disableYubiKeyOtp(authedFetch, derived.hash);
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_yubikey_disabled'));
|
||||
},
|
||||
|
||||
async getTwoFactorPasskeySettings(masterPassword: string): Promise<TwoFactorPasskeySettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
return getTwoFactorPasskeySettingsApi(authedFetch, derived.hash);
|
||||
},
|
||||
|
||||
async createTwoFactorPasskey(name: string, masterPassword: string): Promise<TwoFactorPasskeySettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const normalizedName = String(name || '').trim() || t('txt_passkey');
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const challenge = await getTwoFactorPasskeyChallenge(authedFetch, derived.hash);
|
||||
const deviceResponse = await createTwoFactorPasskeyCredential(challenge);
|
||||
const settings = await saveTwoFactorPasskey(authedFetch, {
|
||||
name: normalizedName,
|
||||
masterPasswordHash: derived.hash,
|
||||
deviceResponse,
|
||||
});
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_two_step_passkey_added'));
|
||||
return settings;
|
||||
},
|
||||
|
||||
async deleteTwoFactorPasskey(id: number, masterPassword: string): Promise<TwoFactorPasskeySettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const settings = await deleteTwoFactorPasskeyApi(authedFetch, { id, masterPasswordHash: derived.hash });
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_two_step_passkey_removed'));
|
||||
return settings;
|
||||
},
|
||||
|
||||
async disableTwoFactorPasskeys(masterPassword: string): Promise<void> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
await disableTwoFactorPasskeysApi(authedFetch, derived.hash);
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_two_step_passkeys_disabled'));
|
||||
},
|
||||
|
||||
async getRecoveryCode(masterPassword: string): Promise<string> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
@@ -476,7 +592,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
session?.symEncKey,
|
||||
session?.symMacKey,
|
||||
refetchAuthorizedDevices,
|
||||
refetchTotpStatus,
|
||||
refetchTwoFactorStatus,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,12 +82,12 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||
},
|
||||
|
||||
async inspectRemoteBackup(destinationId: string, path: string) {
|
||||
return inspectRemoteBackupIntegrity(authedFetch, destinationId, path);
|
||||
async inspectRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
|
||||
return inspectRemoteBackupIntegrity(authedFetch, masterPasswordHash, destinationId, path);
|
||||
},
|
||||
|
||||
async deleteRemoteBackup(destinationId: string, path: string) {
|
||||
await deleteRemoteBackup(authedFetch, destinationId, path);
|
||||
async deleteRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
|
||||
await deleteRemoteBackup(authedFetch, masterPasswordHash, destinationId, path);
|
||||
},
|
||||
|
||||
async restoreRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||
|
||||
@@ -340,6 +340,28 @@ export async function createAccountPasskeyCredential(
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTwoFactorPasskeyCredential(options: unknown): Promise<Record<string, unknown>> {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
throw new Error(t('txt_passkey_browser_not_supported'));
|
||||
}
|
||||
const credential = await navigator.credentials.create({ publicKey: cloneCreationOptions(options) });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error(t('txt_no_passkey_created'));
|
||||
}
|
||||
return attestationRequest(credential);
|
||||
}
|
||||
|
||||
export async function assertTwoFactorPasskey(options: unknown): Promise<string> {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
throw new Error(t('txt_passkey_browser_not_supported'));
|
||||
}
|
||||
const credential = await navigator.credentials.get({ publicKey: cloneRequestOptions(options) });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error(t('txt_invalid_passkey_assertion_response'));
|
||||
}
|
||||
return JSON.stringify(assertionRequest(credential));
|
||||
}
|
||||
|
||||
function parseRsaEncryptedUserKey(value: string): Uint8Array {
|
||||
const text = String(value || '').trim();
|
||||
const [type, payload] = text.split('.');
|
||||
|
||||
@@ -52,7 +52,6 @@ export async function respondToAuthRequest(
|
||||
requestId: string,
|
||||
payload: {
|
||||
key?: string | null;
|
||||
masterPasswordHash?: string | null;
|
||||
deviceIdentifier: string;
|
||||
requestApproved: boolean;
|
||||
}
|
||||
|
||||
+241
-15
@@ -7,6 +7,8 @@ import type {
|
||||
SessionState,
|
||||
TokenError,
|
||||
TokenSuccess,
|
||||
TwoFactorPasskeySettings,
|
||||
YubiKeyOtpSettings,
|
||||
} from '../types';
|
||||
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
|
||||
import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status';
|
||||
@@ -87,11 +89,29 @@ function clearRememberTwoFactorToken(): void {
|
||||
localStorage.removeItem(TOTP_REMEMBER_TOKEN_KEY);
|
||||
}
|
||||
|
||||
function hasTwoFactorChallenge(error: TokenError): boolean {
|
||||
const providers = error.TwoFactorProviders ?? error.CustomResponse?.TwoFactorProviders;
|
||||
const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
|
||||
if (Array.isArray(providers)) return providers.length > 0;
|
||||
if (providers && typeof providers === 'object') return Object.keys(providers as Record<string, unknown>).length > 0;
|
||||
if (Array.isArray(providers2)) return providers2.length > 0;
|
||||
if (providers2 && typeof providers2 === 'object') return Object.keys(providers2 as Record<string, unknown>).length > 0;
|
||||
return providers != null || providers2 != null;
|
||||
}
|
||||
|
||||
export function loadSession(): SessionState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>;
|
||||
if (parsed.email && (parsed.accessToken || parsed.refreshToken)) {
|
||||
const authMode = parsed.authMode === 'web-cookie' ? 'web-cookie' : 'token';
|
||||
saveSession({ email: parsed.email, authMode });
|
||||
return {
|
||||
email: parsed.email,
|
||||
authMode,
|
||||
};
|
||||
}
|
||||
if (parsed.authMode === 'web-cookie' && parsed.email) {
|
||||
return {
|
||||
email: parsed.email,
|
||||
@@ -104,13 +124,7 @@ export function loadSession(): SessionState | null {
|
||||
authMode: 'token',
|
||||
};
|
||||
}
|
||||
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
|
||||
return {
|
||||
accessToken: parsed.accessToken,
|
||||
refreshToken: parsed.refreshToken,
|
||||
email: parsed.email,
|
||||
authMode: 'token',
|
||||
};
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -240,6 +254,7 @@ export async function loginWithPassword(
|
||||
passwordHash: string,
|
||||
options?: {
|
||||
totpCode?: string;
|
||||
twoFactorProvider?: number;
|
||||
rememberDevice?: boolean;
|
||||
useRememberToken?: boolean;
|
||||
signal?: AbortSignal;
|
||||
@@ -259,7 +274,7 @@ export async function loginWithPassword(
|
||||
body.set('twoFactorProvider', '5');
|
||||
body.set('twoFactorToken', rememberedToken);
|
||||
} else if (options?.totpCode) {
|
||||
body.set('twoFactorProvider', '0');
|
||||
body.set('twoFactorProvider', String(options.twoFactorProvider ?? 0));
|
||||
body.set('twoFactorToken', options.totpCode);
|
||||
if (options.rememberDevice) {
|
||||
body.set('twoFactorRemember', '1');
|
||||
@@ -277,7 +292,7 @@ export async function loginWithPassword(
|
||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||
if (resp.ok) {
|
||||
saveRememberTwoFactorToken((json as TokenSuccess).TwoFactorToken);
|
||||
} else if (rememberedToken) {
|
||||
} else if (rememberedToken && hasTwoFactorChallenge(json)) {
|
||||
clearRememberTwoFactorToken();
|
||||
}
|
||||
if (!resp.ok) return json;
|
||||
@@ -387,6 +402,7 @@ export async function revokeCurrentSession(session: SessionState | null): Promis
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}),
|
||||
...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
||||
},
|
||||
body: body.toString(),
|
||||
@@ -591,11 +607,14 @@ export async function changeMasterPassword(
|
||||
const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32);
|
||||
const oldMac = await hkdfExpand(current.masterKey, 'mac', 32);
|
||||
const userSym = await decryptBw(args.profileKey, oldEnc, oldMac);
|
||||
if (userSym.length !== 64) {
|
||||
throw new Error('Invalid profile key');
|
||||
}
|
||||
const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32);
|
||||
const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32);
|
||||
const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32);
|
||||
const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32);
|
||||
const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac);
|
||||
const newKey = await encryptBw(userSym, nextEnc, nextMac);
|
||||
const newMasterPasswordHash = bytesToBase64(nextHash);
|
||||
|
||||
const resp = await authedFetch('/api/accounts/password', {
|
||||
@@ -647,6 +666,203 @@ export async function setTotp(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeYubiKeySettings(raw: any): YubiKeyOtpSettings {
|
||||
return {
|
||||
enabled: !!(raw?.enabled ?? raw?.Enabled),
|
||||
keys: [
|
||||
String(raw?.key1 ?? raw?.Key1 ?? ''),
|
||||
String(raw?.key2 ?? raw?.Key2 ?? ''),
|
||||
String(raw?.key3 ?? raw?.Key3 ?? ''),
|
||||
String(raw?.key4 ?? raw?.Key4 ?? ''),
|
||||
String(raw?.key5 ?? raw?.Key5 ?? ''),
|
||||
],
|
||||
nfc: !!(raw?.nfc ?? raw?.Nfc),
|
||||
yubicoConfigured: !!(raw?.yubicoConfigured ?? raw?.YubicoConfigured),
|
||||
yubicoClientId: String(raw?.yubicoClientId ?? raw?.YubicoClientId ?? ''),
|
||||
yubicoSecretKey: String(raw?.yubicoSecretKey ?? raw?.YubicoSecretKey ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getYubiKeyOtpSettings(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<YubiKeyOtpSettings> {
|
||||
const resp = await authedFetch('/api/two-factor/get-yubikey', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
|
||||
}
|
||||
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function saveYubiKeyOtpSettings(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { keys: string[]; nfc: boolean; masterPasswordHash: string }
|
||||
): Promise<YubiKeyOtpSettings> {
|
||||
const resp = await authedFetch('/api/two-factor/yubikey', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
key1: payload.keys[0] || '',
|
||||
key2: payload.keys[1] || '',
|
||||
key3: payload.keys[2] || '',
|
||||
key4: payload.keys[3] || '',
|
||||
key5: payload.keys[4] || '',
|
||||
nfc: payload.nfc,
|
||||
masterPasswordHash: payload.masterPasswordHash,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_update_failed')));
|
||||
}
|
||||
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function saveYubiKeyOtpApiCredentials(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { masterPasswordHash: string; yubicoClientId: string; yubicoSecretKey: string }
|
||||
): Promise<YubiKeyOtpSettings> {
|
||||
const resp = await authedFetch('/api/two-factor/yubikey/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_config_update_failed')));
|
||||
}
|
||||
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function bootstrapYubiKeyOtpApiCredentials(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { masterPasswordHash: string; otp: string }
|
||||
): Promise<YubiKeyOtpSettings> {
|
||||
const resp = await authedFetch('/api/two-factor/yubikey/bootstrap', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_auto_config_failed')));
|
||||
}
|
||||
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function disableYubiKeyOtp(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/two-factor/disable', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 3, masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_disable_yubikey_failed')));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTwoFactorPasskeySettings(raw: any): TwoFactorPasskeySettings {
|
||||
const keys = Array.isArray(raw?.keys) ? raw.keys : Array.isArray(raw?.Keys) ? raw.Keys : [];
|
||||
return {
|
||||
enabled: !!(raw?.enabled ?? raw?.Enabled),
|
||||
keys: keys
|
||||
.map((item: any) => ({
|
||||
id: Number(item?.id ?? item?.Id),
|
||||
name: String(item?.name || item?.Name || ''),
|
||||
migrated: !!(item?.migrated ?? item?.Migrated),
|
||||
}))
|
||||
.filter((item: { id: number }) => Number.isInteger(item.id) && item.id > 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTwoFactorPasskeySettings(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<TwoFactorPasskeySettings> {
|
||||
const resp = await authedFetch('/api/two-factor/get-webauthn', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
|
||||
}
|
||||
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function getTwoFactorPasskeyChallenge(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<unknown> {
|
||||
const resp = await authedFetch('/api/two-factor/get-webauthn-challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_passkey_setup_failed')));
|
||||
}
|
||||
return parseJson<unknown>(resp);
|
||||
}
|
||||
|
||||
export async function saveTwoFactorPasskey(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { id?: number; name: string; masterPasswordHash: string; deviceResponse: unknown }
|
||||
): Promise<TwoFactorPasskeySettings> {
|
||||
const resp = await authedFetch('/api/two-factor/webauthn', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_passkey_setup_failed')));
|
||||
}
|
||||
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function deleteTwoFactorPasskey(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { id: number; masterPasswordHash: string }
|
||||
): Promise<TwoFactorPasskeySettings> {
|
||||
const resp = await authedFetch('/api/two-factor/webauthn', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_delete_item_failed')));
|
||||
}
|
||||
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function disableTwoFactorPasskeys(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/two-factor/disable', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 7, masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_disable_passkey_two_step_failed')));
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyMasterPassword(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
@@ -804,11 +1020,21 @@ export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<nu
|
||||
return stamp;
|
||||
}
|
||||
|
||||
export async function getTotpStatus(authedFetch: AuthedFetch): Promise<{ enabled: boolean }> {
|
||||
const resp = await authedFetch('/api/accounts/totp');
|
||||
if (!resp.ok) throw new Error('Failed to load TOTP status');
|
||||
const body = (await parseJson<{ enabled?: boolean }>(resp)) || {};
|
||||
return { enabled: !!body.enabled };
|
||||
export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Promise<{ totpEnabled: boolean; yubikeyEnabled: boolean; passkeyEnabled: boolean }> {
|
||||
const resp = await authedFetch('/api/two-factor');
|
||||
if (!resp.ok) throw new Error('Failed to load two-factor status');
|
||||
const body = (await parseJson<{ data?: unknown[]; Data?: unknown[] }>(resp)) || {};
|
||||
const providers = Array.isArray(body.data) ? body.data : Array.isArray(body.Data) ? body.Data : [];
|
||||
const enabledTypes = new Set(
|
||||
providers
|
||||
.map((provider: any) => Number(provider?.type ?? provider?.Type))
|
||||
.filter((type) => Number.isFinite(type))
|
||||
);
|
||||
return {
|
||||
totpEnabled: enabledTypes.has(0),
|
||||
yubikeyEnabled: enabledTypes.has(3),
|
||||
passkeyEnabled: enabledTypes.has(7),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTotpRecoveryCode(
|
||||
|
||||
@@ -196,11 +196,14 @@ export async function exportAdminBackup(
|
||||
|
||||
export async function downloadAdminBackupAttachmentBlob(
|
||||
authedFetch: AuthedFetch,
|
||||
blobName: string
|
||||
blobName: string,
|
||||
masterPasswordHash: string
|
||||
): Promise<Uint8Array> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('blobName', blobName);
|
||||
const resp = await authedFetch(`/api/admin/backup/blob?${params.toString()}`, { method: 'GET' });
|
||||
const resp = await authedFetch('/api/admin/backup/blob', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ blobName, masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
|
||||
return new Uint8Array(await resp.arrayBuffer());
|
||||
}
|
||||
@@ -246,7 +249,7 @@ export async function buildCompleteAdminBackupExport(
|
||||
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -403,25 +406,29 @@ export async function verifyBackupFileIntegrity(bytes: Uint8Array, fileName: str
|
||||
|
||||
export async function deleteRemoteBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string,
|
||||
destinationId: string,
|
||||
path: string
|
||||
): Promise<void> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('destinationId', destinationId);
|
||||
params.set('path', path);
|
||||
const resp = await authedFetch(`/api/admin/backup/remote/file?${params.toString()}`, { method: 'DELETE' });
|
||||
const resp = await authedFetch('/api/admin/backup/remote/file', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ destinationId, path, masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
|
||||
}
|
||||
|
||||
export async function inspectRemoteBackupIntegrity(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string,
|
||||
destinationId: string,
|
||||
path: string
|
||||
): Promise<RemoteBackupIntegrityResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('destinationId', destinationId);
|
||||
params.set('path', path);
|
||||
const resp = await authedFetch(`/api/admin/backup/remote/integrity?${params.toString()}`, { method: 'GET' });
|
||||
const resp = await authedFetch('/api/admin/backup/remote/integrity', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ destinationId, path, masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
|
||||
const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
|
||||
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
|
||||
|
||||
@@ -513,6 +513,30 @@ async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array)
|
||||
return encryptBw(new TextEncoder().encode(s), enc, mac);
|
||||
}
|
||||
|
||||
function stripDecodedObjectFields(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (/^dec[A-Z]/.test(key)) continue;
|
||||
out[key] = item;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function encryptObjectFields(
|
||||
existing: unknown,
|
||||
entries: Array<[string, string]>,
|
||||
draft: VaultDraft,
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array
|
||||
): Promise<Record<string, unknown>> {
|
||||
const out = stripDecodedObjectFields(existing);
|
||||
for (const [fieldName, draftKey] of entries) {
|
||||
out[fieldName] = await encryptTextValue(String((draft as unknown as Record<string, unknown>)[draftKey] || ''), enc, mac);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function encryptPasswordHistory(
|
||||
entries: CipherPasswordHistoryEntry[] | null | undefined,
|
||||
enc: Uint8Array,
|
||||
@@ -587,6 +611,40 @@ function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
|
||||
sshPrivateKey: '',
|
||||
sshPublicKey: '',
|
||||
sshFingerprint: '',
|
||||
bankName: '',
|
||||
bankNameOnAccount: '',
|
||||
bankAccountType: '',
|
||||
bankAccountNumber: '',
|
||||
bankRoutingNumber: '',
|
||||
bankBranchNumber: '',
|
||||
bankPin: '',
|
||||
bankSwiftCode: '',
|
||||
bankIban: '',
|
||||
bankContactPhone: '',
|
||||
licenseFirstName: '',
|
||||
licenseMiddleName: '',
|
||||
licenseLastName: '',
|
||||
licenseDateOfBirth: '',
|
||||
licenseNumber: '',
|
||||
licenseIssuingCountry: '',
|
||||
licenseIssuingState: '',
|
||||
licenseIssueDate: '',
|
||||
licenseExpirationDate: '',
|
||||
licenseIssuingAuthority: '',
|
||||
licenseClass: '',
|
||||
passportSurname: '',
|
||||
passportGivenName: '',
|
||||
passportDateOfBirth: '',
|
||||
passportSex: '',
|
||||
passportBirthPlace: '',
|
||||
passportNationality: '',
|
||||
passportIssuingCountry: '',
|
||||
passportNumber: '',
|
||||
passportType: '',
|
||||
passportNationalIdentificationNumber: '',
|
||||
passportIssuingAuthority: '',
|
||||
passportIssueDate: '',
|
||||
passportExpirationDate: '',
|
||||
customFields: [],
|
||||
};
|
||||
|
||||
@@ -662,6 +720,43 @@ function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
|
||||
cipher.sshKey.decFingerprint,
|
||||
cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint
|
||||
);
|
||||
} else if (type === 6 && cipher.bankAccount) {
|
||||
draft.bankName = plainCipherValue(cipher.bankAccount.decBankName, cipher.bankAccount.bankName);
|
||||
draft.bankNameOnAccount = plainCipherValue(cipher.bankAccount.decNameOnAccount, cipher.bankAccount.nameOnAccount);
|
||||
draft.bankAccountType = plainCipherValue(cipher.bankAccount.decAccountType, cipher.bankAccount.accountType);
|
||||
draft.bankAccountNumber = plainCipherValue(cipher.bankAccount.decAccountNumber, cipher.bankAccount.accountNumber);
|
||||
draft.bankRoutingNumber = plainCipherValue(cipher.bankAccount.decRoutingNumber, cipher.bankAccount.routingNumber);
|
||||
draft.bankBranchNumber = plainCipherValue(cipher.bankAccount.decBranchNumber, cipher.bankAccount.branchNumber);
|
||||
draft.bankPin = plainCipherValue(cipher.bankAccount.decPin, cipher.bankAccount.pin);
|
||||
draft.bankSwiftCode = plainCipherValue(cipher.bankAccount.decSwiftCode, cipher.bankAccount.swiftCode);
|
||||
draft.bankIban = plainCipherValue(cipher.bankAccount.decIban, cipher.bankAccount.iban);
|
||||
draft.bankContactPhone = plainCipherValue(cipher.bankAccount.decBankContactPhone, cipher.bankAccount.bankContactPhone);
|
||||
} else if (type === 7 && cipher.driversLicense) {
|
||||
draft.licenseFirstName = plainCipherValue(cipher.driversLicense.decFirstName, cipher.driversLicense.firstName);
|
||||
draft.licenseMiddleName = plainCipherValue(cipher.driversLicense.decMiddleName, cipher.driversLicense.middleName);
|
||||
draft.licenseLastName = plainCipherValue(cipher.driversLicense.decLastName, cipher.driversLicense.lastName);
|
||||
draft.licenseDateOfBirth = plainCipherValue(cipher.driversLicense.decDateOfBirth, cipher.driversLicense.dateOfBirth);
|
||||
draft.licenseNumber = plainCipherValue(cipher.driversLicense.decLicenseNumber, cipher.driversLicense.licenseNumber);
|
||||
draft.licenseIssuingCountry = plainCipherValue(cipher.driversLicense.decIssuingCountry, cipher.driversLicense.issuingCountry);
|
||||
draft.licenseIssuingState = plainCipherValue(cipher.driversLicense.decIssuingState, cipher.driversLicense.issuingState);
|
||||
draft.licenseIssueDate = plainCipherValue(cipher.driversLicense.decIssueDate, cipher.driversLicense.issueDate);
|
||||
draft.licenseExpirationDate = plainCipherValue(cipher.driversLicense.decExpirationDate, cipher.driversLicense.expirationDate);
|
||||
draft.licenseIssuingAuthority = plainCipherValue(cipher.driversLicense.decIssuingAuthority, cipher.driversLicense.issuingAuthority);
|
||||
draft.licenseClass = plainCipherValue(cipher.driversLicense.decLicenseClass, cipher.driversLicense.licenseClass);
|
||||
} else if (type === 8 && cipher.passport) {
|
||||
draft.passportSurname = plainCipherValue(cipher.passport.decSurname, cipher.passport.surname);
|
||||
draft.passportGivenName = plainCipherValue(cipher.passport.decGivenName, cipher.passport.givenName);
|
||||
draft.passportDateOfBirth = plainCipherValue(cipher.passport.decDateOfBirth, cipher.passport.dateOfBirth);
|
||||
draft.passportSex = plainCipherValue(cipher.passport.decSex, cipher.passport.sex);
|
||||
draft.passportBirthPlace = plainCipherValue(cipher.passport.decBirthPlace, cipher.passport.birthPlace);
|
||||
draft.passportNationality = plainCipherValue(cipher.passport.decNationality, cipher.passport.nationality);
|
||||
draft.passportIssuingCountry = plainCipherValue(cipher.passport.decIssuingCountry, cipher.passport.issuingCountry);
|
||||
draft.passportNumber = plainCipherValue(cipher.passport.decPassportNumber, cipher.passport.passportNumber);
|
||||
draft.passportType = plainCipherValue(cipher.passport.decPassportType, cipher.passport.passportType);
|
||||
draft.passportNationalIdentificationNumber = plainCipherValue(cipher.passport.decNationalIdentificationNumber, cipher.passport.nationalIdentificationNumber);
|
||||
draft.passportIssuingAuthority = plainCipherValue(cipher.passport.decIssuingAuthority, cipher.passport.issuingAuthority);
|
||||
draft.passportIssueDate = plainCipherValue(cipher.passport.decIssueDate, cipher.passport.issueDate);
|
||||
draft.passportExpirationDate = plainCipherValue(cipher.passport.decExpirationDate, cipher.passport.expirationDate);
|
||||
}
|
||||
|
||||
return draft;
|
||||
@@ -983,6 +1078,10 @@ function getCipherKeyMismatchProbes(cipher: Cipher): string[] {
|
||||
cipher.identity?.title,
|
||||
cipher.identity?.firstName,
|
||||
cipher.sshKey?.privateKey,
|
||||
cipher.bankAccount?.bankName,
|
||||
cipher.bankAccount?.accountNumber,
|
||||
cipher.driversLicense?.licenseNumber,
|
||||
cipher.passport?.passportNumber,
|
||||
...(cipher.fields || []).flatMap((field) => [field.name, field.value]),
|
||||
];
|
||||
const probes: string[] = [];
|
||||
@@ -1053,6 +1152,40 @@ function hasUnresolvedEncryptedFields(cipher: Cipher): boolean {
|
||||
[cipher.sshKey?.privateKey, cipher.sshKey?.decPrivateKey],
|
||||
[cipher.sshKey?.publicKey, cipher.sshKey?.decPublicKey],
|
||||
[cipher.sshKey?.keyFingerprint || cipher.sshKey?.fingerprint, cipher.sshKey?.decFingerprint],
|
||||
[cipher.bankAccount?.bankName, cipher.bankAccount?.decBankName],
|
||||
[cipher.bankAccount?.nameOnAccount, cipher.bankAccount?.decNameOnAccount],
|
||||
[cipher.bankAccount?.accountType, cipher.bankAccount?.decAccountType],
|
||||
[cipher.bankAccount?.accountNumber, cipher.bankAccount?.decAccountNumber],
|
||||
[cipher.bankAccount?.routingNumber, cipher.bankAccount?.decRoutingNumber],
|
||||
[cipher.bankAccount?.branchNumber, cipher.bankAccount?.decBranchNumber],
|
||||
[cipher.bankAccount?.pin, cipher.bankAccount?.decPin],
|
||||
[cipher.bankAccount?.swiftCode, cipher.bankAccount?.decSwiftCode],
|
||||
[cipher.bankAccount?.iban, cipher.bankAccount?.decIban],
|
||||
[cipher.bankAccount?.bankContactPhone, cipher.bankAccount?.decBankContactPhone],
|
||||
[cipher.driversLicense?.firstName, cipher.driversLicense?.decFirstName],
|
||||
[cipher.driversLicense?.middleName, cipher.driversLicense?.decMiddleName],
|
||||
[cipher.driversLicense?.lastName, cipher.driversLicense?.decLastName],
|
||||
[cipher.driversLicense?.dateOfBirth, cipher.driversLicense?.decDateOfBirth],
|
||||
[cipher.driversLicense?.licenseNumber, cipher.driversLicense?.decLicenseNumber],
|
||||
[cipher.driversLicense?.issuingCountry, cipher.driversLicense?.decIssuingCountry],
|
||||
[cipher.driversLicense?.issuingState, cipher.driversLicense?.decIssuingState],
|
||||
[cipher.driversLicense?.issueDate, cipher.driversLicense?.decIssueDate],
|
||||
[cipher.driversLicense?.expirationDate, cipher.driversLicense?.decExpirationDate],
|
||||
[cipher.driversLicense?.issuingAuthority, cipher.driversLicense?.decIssuingAuthority],
|
||||
[cipher.driversLicense?.licenseClass, cipher.driversLicense?.decLicenseClass],
|
||||
[cipher.passport?.surname, cipher.passport?.decSurname],
|
||||
[cipher.passport?.givenName, cipher.passport?.decGivenName],
|
||||
[cipher.passport?.dateOfBirth, cipher.passport?.decDateOfBirth],
|
||||
[cipher.passport?.sex, cipher.passport?.decSex],
|
||||
[cipher.passport?.birthPlace, cipher.passport?.decBirthPlace],
|
||||
[cipher.passport?.nationality, cipher.passport?.decNationality],
|
||||
[cipher.passport?.issuingCountry, cipher.passport?.decIssuingCountry],
|
||||
[cipher.passport?.passportNumber, cipher.passport?.decPassportNumber],
|
||||
[cipher.passport?.passportType, cipher.passport?.decPassportType],
|
||||
[cipher.passport?.nationalIdentificationNumber, cipher.passport?.decNationalIdentificationNumber],
|
||||
[cipher.passport?.issuingAuthority, cipher.passport?.decIssuingAuthority],
|
||||
[cipher.passport?.issueDate, cipher.passport?.decIssueDate],
|
||||
[cipher.passport?.expirationDate, cipher.passport?.decExpirationDate],
|
||||
...(cipher.fields || []).flatMap((field) => [
|
||||
[field.name, field.decName] as [unknown, unknown],
|
||||
[field.value, field.decValue] as [unknown, unknown],
|
||||
@@ -1157,6 +1290,9 @@ async function buildCipherPayload(
|
||||
identity: null,
|
||||
secureNote: null,
|
||||
sshKey: null,
|
||||
bankAccount: null,
|
||||
driversLicense: null,
|
||||
passport: null,
|
||||
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
|
||||
passwordHistory: await encryptPasswordHistory(cipher?.passwordHistory, keys.enc, keys.mac),
|
||||
};
|
||||
@@ -1222,11 +1358,73 @@ async function buildCipherPayload(
|
||||
} else if (type === 5) {
|
||||
const encryptedFingerprint = await encryptTextValue(draft.sshFingerprint, keys.enc, keys.mac);
|
||||
payload.sshKey = {
|
||||
...stripDecodedObjectFields(cipher?.sshKey),
|
||||
privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac),
|
||||
publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac),
|
||||
keyFingerprint: encryptedFingerprint,
|
||||
fingerprint: encryptedFingerprint,
|
||||
};
|
||||
} else if (type === 6) {
|
||||
payload.bankAccount = await encryptObjectFields(
|
||||
cipher?.bankAccount,
|
||||
[
|
||||
['bankName', 'bankName'],
|
||||
['nameOnAccount', 'bankNameOnAccount'],
|
||||
['accountType', 'bankAccountType'],
|
||||
['accountNumber', 'bankAccountNumber'],
|
||||
['routingNumber', 'bankRoutingNumber'],
|
||||
['branchNumber', 'bankBranchNumber'],
|
||||
['pin', 'bankPin'],
|
||||
['swiftCode', 'bankSwiftCode'],
|
||||
['iban', 'bankIban'],
|
||||
['bankContactPhone', 'bankContactPhone'],
|
||||
],
|
||||
draft,
|
||||
keys.enc,
|
||||
keys.mac
|
||||
);
|
||||
} else if (type === 7) {
|
||||
payload.driversLicense = await encryptObjectFields(
|
||||
cipher?.driversLicense,
|
||||
[
|
||||
['firstName', 'licenseFirstName'],
|
||||
['middleName', 'licenseMiddleName'],
|
||||
['lastName', 'licenseLastName'],
|
||||
['dateOfBirth', 'licenseDateOfBirth'],
|
||||
['licenseNumber', 'licenseNumber'],
|
||||
['issuingCountry', 'licenseIssuingCountry'],
|
||||
['issuingState', 'licenseIssuingState'],
|
||||
['issueDate', 'licenseIssueDate'],
|
||||
['expirationDate', 'licenseExpirationDate'],
|
||||
['issuingAuthority', 'licenseIssuingAuthority'],
|
||||
['licenseClass', 'licenseClass'],
|
||||
],
|
||||
draft,
|
||||
keys.enc,
|
||||
keys.mac
|
||||
);
|
||||
} else if (type === 8) {
|
||||
payload.passport = await encryptObjectFields(
|
||||
cipher?.passport,
|
||||
[
|
||||
['surname', 'passportSurname'],
|
||||
['givenName', 'passportGivenName'],
|
||||
['dateOfBirth', 'passportDateOfBirth'],
|
||||
['sex', 'passportSex'],
|
||||
['birthPlace', 'passportBirthPlace'],
|
||||
['nationality', 'passportNationality'],
|
||||
['issuingCountry', 'passportIssuingCountry'],
|
||||
['passportNumber', 'passportNumber'],
|
||||
['passportType', 'passportType'],
|
||||
['nationalIdentificationNumber', 'passportNationalIdentificationNumber'],
|
||||
['issuingAuthority', 'passportIssuingAuthority'],
|
||||
['issueDate', 'passportIssueDate'],
|
||||
['expirationDate', 'passportExpirationDate'],
|
||||
],
|
||||
draft,
|
||||
keys.enc,
|
||||
keys.mac
|
||||
);
|
||||
} else if (type === 2) {
|
||||
payload.secureNote = { type: 0 };
|
||||
}
|
||||
|
||||
+134
-9
@@ -27,6 +27,7 @@ import {
|
||||
unlockOfflineVaultWithMasterKey,
|
||||
} from '@/lib/offline-auth';
|
||||
import { probeNodeWardenService } from '@/lib/network-status';
|
||||
import { setWebsiteIconsEnabled } from '@/lib/website-icon-settings';
|
||||
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||
|
||||
export interface PendingTotp {
|
||||
@@ -34,6 +35,10 @@ export interface PendingTotp {
|
||||
passwordHash: string;
|
||||
masterKey: Uint8Array;
|
||||
kdfIterations: number;
|
||||
providerType: number;
|
||||
providerData?: unknown;
|
||||
availableProviders: number[];
|
||||
providerDataByType: Record<number, unknown>;
|
||||
}
|
||||
|
||||
export interface PendingPasskeyPassword {
|
||||
@@ -42,11 +47,12 @@ export interface PendingPasskeyPassword {
|
||||
kdfIterations: number;
|
||||
}
|
||||
|
||||
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
|
||||
export type JwtUnsafeReason = 'missing' | 'too_short';
|
||||
|
||||
export interface BootstrapAppResult {
|
||||
defaultKdfIterations: number;
|
||||
registrationInviteRequired?: boolean;
|
||||
websiteIconsEnabled: boolean;
|
||||
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
||||
session: SessionState | null;
|
||||
profile: Profile | null;
|
||||
@@ -57,6 +63,7 @@ export interface BootstrapAppResult {
|
||||
export interface InitialAppBootstrapState {
|
||||
defaultKdfIterations: number;
|
||||
registrationInviteRequired?: boolean;
|
||||
websiteIconsEnabled: boolean;
|
||||
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
||||
session: SessionState | null;
|
||||
phase: AppPhase;
|
||||
@@ -70,10 +77,98 @@ export interface CompletedLogin {
|
||||
freshUserVerificationToken?: string | null;
|
||||
}
|
||||
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
const SUPPORTED_TWO_FACTOR_PROVIDERS = [
|
||||
TWO_FACTOR_PROVIDER_WEBAUTHN,
|
||||
TWO_FACTOR_PROVIDER_YUBIKEY,
|
||||
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
|
||||
] as const;
|
||||
|
||||
function readTokenUserVerificationToken(token: TokenSuccess): string | null {
|
||||
return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null;
|
||||
}
|
||||
|
||||
type TwoFactorTokenError = {
|
||||
TwoFactorProviders?: unknown;
|
||||
TwoFactorProviders2?: unknown;
|
||||
CustomResponse?: {
|
||||
TwoFactorProviders?: unknown;
|
||||
TwoFactorProviders2?: unknown;
|
||||
};
|
||||
error_description?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function readTwoFactorProviders(error: TwoFactorTokenError): unknown {
|
||||
return error.TwoFactorProviders ?? error.CustomResponse?.TwoFactorProviders ?? error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
|
||||
}
|
||||
|
||||
function readTwoFactorProviderData(error: TwoFactorTokenError, providerType: number): unknown {
|
||||
const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
|
||||
if (!providers2 || typeof providers2 !== 'object') return undefined;
|
||||
const record = providers2 as Record<string, unknown>;
|
||||
return record[String(providerType)] ?? (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN ? record.WebAuthn : undefined);
|
||||
}
|
||||
|
||||
function twoFactorProviderTypeFromValue(value: unknown): number | null {
|
||||
const raw = value && typeof value === 'object'
|
||||
? (value as Record<string, unknown>).Type ?? (value as Record<string, unknown>).type
|
||||
: value;
|
||||
const text = String(raw ?? '').trim();
|
||||
if (!text) return null;
|
||||
const normalized = text.toLowerCase();
|
||||
const numeric = Number(text);
|
||||
const provider = Number.isFinite(numeric)
|
||||
? numeric
|
||||
: normalized === 'webauthn'
|
||||
? TWO_FACTOR_PROVIDER_WEBAUTHN
|
||||
: normalized === 'yubikey' || normalized === 'yubikeyotp'
|
||||
? TWO_FACTOR_PROVIDER_YUBIKEY
|
||||
: normalized === 'authenticator' || normalized === 'totp'
|
||||
? TWO_FACTOR_PROVIDER_AUTHENTICATOR
|
||||
: Number.NaN;
|
||||
return SUPPORTED_TWO_FACTOR_PROVIDERS.includes(provider as any) ? provider : null;
|
||||
}
|
||||
|
||||
function sortTwoFactorProviders(providerTypes: number[]): number[] {
|
||||
const unique = new Set(providerTypes);
|
||||
return SUPPORTED_TWO_FACTOR_PROVIDERS.filter((provider) => unique.has(provider));
|
||||
}
|
||||
|
||||
function readTwoFactorProviderTypes(providers: unknown): number[] {
|
||||
const providerTypes: number[] = [];
|
||||
if (Array.isArray(providers)) {
|
||||
for (const provider of providers) {
|
||||
const providerType = twoFactorProviderTypeFromValue(provider);
|
||||
if (providerType != null) providerTypes.push(providerType);
|
||||
}
|
||||
} else if (providers && typeof providers === 'object') {
|
||||
for (const [key, value] of Object.entries(providers as Record<string, unknown>)) {
|
||||
if (value === false) continue;
|
||||
const providerType = twoFactorProviderTypeFromValue(key);
|
||||
if (providerType != null) providerTypes.push(providerType);
|
||||
}
|
||||
}
|
||||
return sortTwoFactorProviders(providerTypes);
|
||||
}
|
||||
|
||||
function readTwoFactorProviderDataMap(error: TwoFactorTokenError): Record<number, unknown> {
|
||||
const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
|
||||
if (!providers2 || typeof providers2 !== 'object') return {};
|
||||
const out: Record<number, unknown> = {};
|
||||
for (const [key, value] of Object.entries(providers2 as Record<string, unknown>)) {
|
||||
const providerType = twoFactorProviderTypeFromValue(key);
|
||||
if (providerType != null) out[providerType] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolvePendingTwoFactorProvider(providers: unknown): number {
|
||||
return readTwoFactorProviderTypes(providers)[0] ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
|
||||
}
|
||||
|
||||
export type PasswordLoginResult =
|
||||
| { kind: 'success'; login: CompletedLogin }
|
||||
| { kind: 'totp'; pendingTotp: PendingTotp }
|
||||
@@ -137,10 +232,11 @@ function readWindowBootstrap(): WebBootstrapResponse {
|
||||
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 registrationInviteRequired =
|
||||
typeof boot.registrationInviteRequired === 'boolean' ? boot.registrationInviteRequired : undefined;
|
||||
const websiteIconsEnabled = boot.websiteIconsEnabled !== false;
|
||||
const jwtUnsafeReason = boot.jwtUnsafeReason || null;
|
||||
const jwtWarning = jwtUnsafeReason
|
||||
? {
|
||||
@@ -152,6 +248,7 @@ function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialApp
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
registrationInviteRequired,
|
||||
websiteIconsEnabled,
|
||||
jwtWarning,
|
||||
};
|
||||
}
|
||||
@@ -212,7 +309,8 @@ function resolveUnauthenticatedPhase(registrationInviteRequired: boolean | undef
|
||||
}
|
||||
|
||||
export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
||||
const { defaultKdfIterations, registrationInviteRequired, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
|
||||
const { defaultKdfIterations, registrationInviteRequired, websiteIconsEnabled, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
|
||||
setWebsiteIconsEnabled(websiteIconsEnabled);
|
||||
const session = loadSession();
|
||||
const hasInviteCode = !!readInviteCodeFromUrl();
|
||||
const unauthenticatedPhase = hasInviteCode ? 'register' : 'login';
|
||||
@@ -220,6 +318,7 @@ export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
registrationInviteRequired,
|
||||
websiteIconsEnabled,
|
||||
jwtWarning,
|
||||
session,
|
||||
phase: jwtWarning ? 'login' : session ? 'locked' : resolveUnauthenticatedPhase(registrationInviteRequired, unauthenticatedPhase),
|
||||
@@ -231,12 +330,15 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
||||
const normalizedBoot = normalizeBootstrapResponse(remoteBoot);
|
||||
const defaultKdfIterations = normalizedBoot.defaultKdfIterations || initial.defaultKdfIterations;
|
||||
const registrationInviteRequired = normalizedBoot.registrationInviteRequired ?? initial.registrationInviteRequired;
|
||||
const websiteIconsEnabled = normalizedBoot.websiteIconsEnabled !== false;
|
||||
setWebsiteIconsEnabled(websiteIconsEnabled);
|
||||
const jwtWarning = normalizedBoot.jwtWarning ?? initial.jwtWarning;
|
||||
|
||||
if (jwtWarning) {
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
registrationInviteRequired,
|
||||
websiteIconsEnabled,
|
||||
jwtWarning,
|
||||
session: null,
|
||||
profile: null,
|
||||
@@ -249,6 +351,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
registrationInviteRequired,
|
||||
websiteIconsEnabled,
|
||||
jwtWarning: null,
|
||||
session: null,
|
||||
profile: null,
|
||||
@@ -261,6 +364,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
registrationInviteRequired,
|
||||
websiteIconsEnabled,
|
||||
jwtWarning: null,
|
||||
session: loaded,
|
||||
profile: cachedProfile,
|
||||
@@ -272,6 +376,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
registrationInviteRequired,
|
||||
websiteIconsEnabled,
|
||||
jwtWarning: null,
|
||||
session: loaded,
|
||||
profile: null,
|
||||
@@ -416,8 +521,12 @@ export async function performPasswordLogin(
|
||||
};
|
||||
}
|
||||
|
||||
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
if (tokenError.TwoFactorProviders) {
|
||||
const tokenError = token as TwoFactorTokenError;
|
||||
const providers = readTwoFactorProviders(tokenError);
|
||||
if (providers) {
|
||||
const providerType = resolvePendingTwoFactorProvider(providers);
|
||||
const availableProviders = readTwoFactorProviderTypes(providers);
|
||||
const providerDataByType = readTwoFactorProviderDataMap(tokenError);
|
||||
return {
|
||||
kind: 'totp',
|
||||
pendingTotp: {
|
||||
@@ -425,6 +534,10 @@ export async function performPasswordLogin(
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
providerType,
|
||||
providerData: providerDataByType[providerType] ?? readTwoFactorProviderData(tokenError, providerType),
|
||||
availableProviders: availableProviders.length ? availableProviders : [providerType],
|
||||
providerDataByType,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -498,13 +611,17 @@ export async function performTotpLogin(
|
||||
): Promise<CompletedLogin> {
|
||||
const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, {
|
||||
totpCode: totpCode.trim(),
|
||||
twoFactorProvider: pendingTotp.providerType,
|
||||
rememberDevice,
|
||||
});
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations, pendingTotp.passwordHash);
|
||||
}
|
||||
const tokenError = token as { error_description?: string; error?: string };
|
||||
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
|
||||
const fallback = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN
|
||||
? t('txt_passkey_verification_failed')
|
||||
: t('txt_totp_verify_failed');
|
||||
throw new Error(translateServerError(tokenError.error_description || tokenError.error, fallback));
|
||||
}
|
||||
|
||||
export async function performRecoverTwoFactorLogin(
|
||||
@@ -584,7 +701,7 @@ export async function performUnlock(
|
||||
return unlockOffline();
|
||||
}
|
||||
|
||||
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
let token: TokenSuccess | TwoFactorTokenError;
|
||||
try {
|
||||
token = await loginWithPassword(normalizedEmail, derived.hash, {
|
||||
useRememberToken: true,
|
||||
@@ -606,8 +723,12 @@ export async function performUnlock(
|
||||
};
|
||||
}
|
||||
|
||||
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
if (tokenError.TwoFactorProviders) {
|
||||
const tokenError = token as TwoFactorTokenError;
|
||||
const providers = readTwoFactorProviders(tokenError);
|
||||
if (providers) {
|
||||
const providerType = resolvePendingTwoFactorProvider(providers);
|
||||
const availableProviders = readTwoFactorProviderTypes(providers);
|
||||
const providerDataByType = readTwoFactorProviderDataMap(tokenError);
|
||||
return {
|
||||
kind: 'totp',
|
||||
pendingTotp: {
|
||||
@@ -615,6 +736,10 @@ export async function performUnlock(
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
providerType,
|
||||
providerData: providerDataByType[providerType] ?? readTwoFactorProviderData(tokenError, providerType),
|
||||
availableProviders: availableProviders.length ? availableProviders : [providerType],
|
||||
providerDataByType,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+219
-27
@@ -259,17 +259,33 @@ interface TotpConfig {
|
||||
period: number;
|
||||
}
|
||||
|
||||
interface GoogleAuthenticatorMigrationTotp {
|
||||
secret: string;
|
||||
name: string;
|
||||
issuer: string;
|
||||
algorithm: TotpHashAlgorithm;
|
||||
digits: number;
|
||||
period: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TOTP_CONFIG: Omit<TotpConfig, 'secret' | 'steam'> = {
|
||||
algorithm: 'SHA-1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
};
|
||||
|
||||
function parseTotpPositiveInt(value: string | null, fallback: number, min: number, max: number): number {
|
||||
if (!value) return fallback;
|
||||
function parseTotpDigits(value: string | null): number {
|
||||
if (!value) return DEFAULT_TOTP_CONFIG.digits;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) return fallback;
|
||||
return parsed;
|
||||
if (!Number.isInteger(parsed)) return DEFAULT_TOTP_CONFIG.digits;
|
||||
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 {
|
||||
@@ -279,9 +295,190 @@ function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
|
||||
return 'SHA-1';
|
||||
}
|
||||
|
||||
function parseTotpConfig(raw: string): TotpConfig {
|
||||
if (!raw) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||
function base64ToBytesLoose(value: string): Uint8Array {
|
||||
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();
|
||||
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 (/^steam:\/\//i.test(s)) {
|
||||
return {
|
||||
@@ -295,31 +492,20 @@ function parseTotpConfig(raw: string): TotpConfig {
|
||||
if (/^otpauth:\/\//i.test(s)) {
|
||||
try {
|
||||
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 {
|
||||
secret: normalizeTotpSecret(u.searchParams.get('secret') || ''),
|
||||
steam,
|
||||
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
|
||||
digits: steam ? 5 : parseTotpPositiveInt(u.searchParams.get('digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
|
||||
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
||||
steam: false,
|
||||
algorithm: parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
|
||||
digits: parseTotpDigits(u.searchParams.get('digits')),
|
||||
period: parseTotpPeriod(u.searchParams.get('period')),
|
||||
};
|
||||
} catch {
|
||||
const issuer = readOtpAuthParam(s, 'issuer').trim().toLowerCase();
|
||||
const algorithm = readOtpAuthParam(s, 'algorithm').trim().toLowerCase();
|
||||
const steam = issuer === 'steam' || algorithm === 'steam';
|
||||
return {
|
||||
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
|
||||
steam,
|
||||
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(algorithm),
|
||||
digits: steam ? 5 : parseTotpPositiveInt(readOtpAuthParam(s, 'digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
|
||||
period: parseTotpPositiveInt(readOtpAuthParam(s, 'period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
||||
steam: false,
|
||||
algorithm: parseTotpHashAlgorithm(readOtpAuthParam(s, 'algorithm')),
|
||||
digits: parseTotpDigits(readOtpAuthParam(s, 'digits')),
|
||||
period: parseTotpPeriod(readOtpAuthParam(s, 'period')),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -349,7 +535,13 @@ function base32ToBytes(input: string): Uint8Array {
|
||||
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);
|
||||
if (!secret) return null;
|
||||
const keyBytes = base32ToBytes(secret);
|
||||
@@ -378,5 +570,5 @@ export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now())
|
||||
value = Math.floor(value / chars.length);
|
||||
}
|
||||
}
|
||||
return { code, remain };
|
||||
return { code, remain, period };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
|
||||
return {
|
||||
defaultKdfIterations: 600000,
|
||||
registrationInviteRequired: true,
|
||||
websiteIconsEnabled: true,
|
||||
jwtWarning: null,
|
||||
session: null,
|
||||
phase: 'login',
|
||||
|
||||
+13
-1
@@ -790,6 +790,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
|
||||
return {
|
||||
defaultKdfIterations: 600000,
|
||||
registrationInviteRequired: true,
|
||||
websiteIconsEnabled: true,
|
||||
jwtWarning: null,
|
||||
session: null,
|
||||
phase: 'login',
|
||||
@@ -907,6 +908,7 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
||||
adminLoading: false,
|
||||
adminError: '',
|
||||
totpEnabled: true,
|
||||
passkey2faEnabled: false,
|
||||
authorizedDevices: state.authorizedDevices,
|
||||
authorizedDevicesLoading: false,
|
||||
authorizedDevicesError: '',
|
||||
@@ -1060,6 +1062,16 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
||||
onSavePasswordHint: readonly,
|
||||
onEnableTotp: readonly,
|
||||
onOpenDisableTotp: readonlyVoid,
|
||||
onGetTwoFactorPasskeySettings: async () => ({ enabled: false, keys: [] }),
|
||||
onCreateTwoFactorPasskey: async () => {
|
||||
await readonly();
|
||||
return { enabled: false, keys: [] };
|
||||
},
|
||||
onDeleteTwoFactorPasskey: async () => {
|
||||
await readonly();
|
||||
return { enabled: false, keys: [] };
|
||||
},
|
||||
onDisableTwoFactorPasskeys: readonly,
|
||||
onGetRecoveryCode: readonlyString,
|
||||
onGetApiKey: readonlyString,
|
||||
onRotateApiKey: readonlyString,
|
||||
@@ -1189,7 +1201,7 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
||||
onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => {
|
||||
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',
|
||||
destinationId: _destinationId,
|
||||
path,
|
||||
|
||||
@@ -215,13 +215,13 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
|
||||
const login = cipher.login;
|
||||
out.login = login
|
||||
? {
|
||||
...cloneValue(login),
|
||||
...(cloneWithoutDecodedFields(login) || {}),
|
||||
username: login.username ?? null,
|
||||
password: login.password ?? null,
|
||||
totp: login.totp ?? null,
|
||||
uris: Array.isArray(login.uris)
|
||||
? login.uris.map((uri) => ({
|
||||
...cloneValue(uri),
|
||||
...(cloneWithoutDecodedFields(uri) || {}),
|
||||
uri: uri?.uri ?? null,
|
||||
uriChecksum: uri?.uriChecksum ?? null,
|
||||
match: (uri as { match?: unknown })?.match ?? null,
|
||||
@@ -280,6 +280,7 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
|
||||
|
||||
out.sshKey = cipher.sshKey
|
||||
? {
|
||||
...(cloneWithoutDecodedFields(cipher.sshKey) || {}),
|
||||
privateKey: cipher.sshKey.privateKey ?? null,
|
||||
publicKey: cipher.sshKey.publicKey ?? null,
|
||||
keyFingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
|
||||
@@ -287,6 +288,9 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
|
||||
fingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
|
||||
}
|
||||
: null;
|
||||
out.bankAccount = cloneWithoutDecodedFields(cipher.bankAccount) ?? null;
|
||||
out.driversLicense = cloneWithoutDecodedFields(cipher.driversLicense) ?? null;
|
||||
out.passport = cloneWithoutDecodedFields(cipher.passport) ?? null;
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -331,8 +335,8 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
|
||||
out.login = null;
|
||||
}
|
||||
|
||||
out.card = cipher.card ? await deepDecryptUnknown(cipher.card, keyParts.enc, keyParts.mac) : null;
|
||||
out.identity = cipher.identity ? await deepDecryptUnknown(cipher.identity, keyParts.enc, keyParts.mac) : null;
|
||||
out.card = cipher.card ? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.card), keyParts.enc, keyParts.mac) : null;
|
||||
out.identity = cipher.identity ? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.identity), keyParts.enc, keyParts.mac) : null;
|
||||
if (cipher.sshKey) {
|
||||
const fingerprint = await decryptMaybe(
|
||||
cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
|
||||
@@ -340,6 +344,7 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
|
||||
keyParts.mac
|
||||
);
|
||||
out.sshKey = {
|
||||
...((await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.sshKey), keyParts.enc, keyParts.mac)) as Record<string, unknown>),
|
||||
privateKey: await decryptMaybe(cipher.sshKey.privateKey ?? null, keyParts.enc, keyParts.mac),
|
||||
publicKey: await decryptMaybe(cipher.sshKey.publicKey ?? null, keyParts.enc, keyParts.mac),
|
||||
keyFingerprint: fingerprint,
|
||||
@@ -349,6 +354,15 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
|
||||
} else {
|
||||
out.sshKey = null;
|
||||
}
|
||||
out.bankAccount = cipher.bankAccount
|
||||
? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.bankAccount), keyParts.enc, keyParts.mac)
|
||||
: null;
|
||||
out.driversLicense = cipher.driversLicense
|
||||
? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.driversLicense), keyParts.enc, keyParts.mac)
|
||||
: null;
|
||||
out.passport = cipher.passport
|
||||
? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.passport), keyParts.enc, keyParts.mac)
|
||||
: null;
|
||||
out.secureNote = cipher.secureNote
|
||||
? {
|
||||
type: normalizeNumber((cipher.secureNote as { type?: unknown }).type, 0),
|
||||
@@ -431,6 +445,9 @@ function sourceTypeLabel(type: number): string {
|
||||
if (type === 3) return 'card';
|
||||
if (type === 4) return 'identity';
|
||||
if (type === 5) return 'sshKey';
|
||||
if (type === 6) return 'bankAccount';
|
||||
if (type === 7) return 'driversLicense';
|
||||
if (type === 8) return 'passport';
|
||||
if (type === 2) return 'note';
|
||||
return `type ${type}`;
|
||||
}
|
||||
@@ -449,6 +466,16 @@ function appendRecordFieldLines(lines: string[], prefix: string, value: unknown)
|
||||
}
|
||||
}
|
||||
|
||||
function cloneWithoutDecodedFields(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (/^dec[A-Z]/.test(key)) continue;
|
||||
out[key] = cloneValue(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const BITWARDEN_CSV_OBJECT_FIELDS: Record<string, readonly string[]> = {
|
||||
card: ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code'],
|
||||
identity: [
|
||||
@@ -472,6 +499,9 @@ const BITWARDEN_CSV_OBJECT_FIELDS: Record<string, readonly string[]> = {
|
||||
'country',
|
||||
],
|
||||
sshKey: ['privateKey', 'publicKey', 'keyFingerprint', 'fingerprint'],
|
||||
bankAccount: ['bankName', 'nameOnAccount', 'accountType', 'accountNumber', 'routingNumber', 'branchNumber', 'pin', 'swiftCode', 'iban', 'bankContactPhone'],
|
||||
driversLicense: ['firstName', 'middleName', 'lastName', 'dateOfBirth', 'licenseNumber', 'issuingCountry', 'issuingState', 'issueDate', 'expirationDate', 'issuingAuthority', 'licenseClass'],
|
||||
passport: ['surname', 'givenName', 'dateOfBirth', 'sex', 'birthPlace', 'nationality', 'issuingCountry', 'passportNumber', 'passportType', 'nationalIdentificationNumber', 'issuingAuthority', 'issueDate', 'expirationDate'],
|
||||
};
|
||||
|
||||
function appendKnownRecordFieldLines(lines: string[], prefix: string, value: unknown): void {
|
||||
|
||||
@@ -11,6 +11,57 @@ const en: Record<string, string> = {
|
||||
"nav_import_export": "Import & Export",
|
||||
"nav_group_data_backup": "Data & Backup",
|
||||
"nav_group_management": "Management",
|
||||
"txt_settings_appearance": "Appearance",
|
||||
"txt_theme": "Theme",
|
||||
"txt_use_system_theme": "Use system theme",
|
||||
"txt_light_theme": "Light",
|
||||
"txt_dark_theme": "Dark",
|
||||
"txt_theme_saved_locally": "Choose a theme for your web vault.",
|
||||
"txt_display_language_help": "Change the web vault language.",
|
||||
"txt_two_step_login": "Two-step login",
|
||||
"txt_keys": "Keys",
|
||||
"txt_manage": "Manage",
|
||||
"txt_providers": "Providers",
|
||||
"txt_authenticator_app": "Authenticator app",
|
||||
"txt_authenticator_app_help": "Enter a code generated by an authenticator app.",
|
||||
"txt_passkey_provider_help": "Use a FIDO2-compatible security key or biometric authenticator.",
|
||||
"txt_yubico_otp_security_key": "Yubico OTP security key",
|
||||
"txt_yubico_otp_security_key_help": "Use a YubiKey 4, 5, or NEO device.",
|
||||
"txt_yubikey_setup_intro": "Insert your YubiKey into a USB port. Select the first empty YubiKey field below, touch the YubiKey button, then save the form.",
|
||||
"txt_yubikey_plug_in": "Insert your YubiKey into a USB port.",
|
||||
"txt_yubikey_select_empty_field": "Select the first empty YubiKey input field below.",
|
||||
"txt_yubikey_touch_button": "Touch the YubiKey button.",
|
||||
"txt_yubikey_save_form": "Save the form.",
|
||||
"txt_yubikey_x": "YubiKey {index}",
|
||||
"txt_nfc_support": "NFC support",
|
||||
"txt_yubikey_supports_nfc": "One of my keys supports NFC.",
|
||||
"txt_yubikey_supports_nfc_desc": "If one of your YubiKeys supports NFC, mobile apps can prompt you when NFC is available.",
|
||||
"txt_disable_all_keys": "Disable all keys",
|
||||
"txt_yubikeys_updated": "YubiKeys updated",
|
||||
"txt_yubikey_update_failed": "Failed to update YubiKeys",
|
||||
"txt_disable_yubikey_failed": "Failed to disable YubiKeys",
|
||||
"txt_yubikey_disabled": "YubiKeys disabled",
|
||||
"txt_yubikey_enabled": "YubiKey is enabled.",
|
||||
"txt_yubikey_config_required": "Yubico validation is not configured",
|
||||
"txt_yubikey_config_required_help": "Enter one YubiKey OTP first. NodeWarden will automatically request and save the instance Client ID and Secret key, then open the YubiKey setup form.",
|
||||
"txt_otp_from_yubikey": "OTP from YubiKey",
|
||||
"txt_please_input_yubikey_otp": "Please input YubiKey OTP",
|
||||
"txt_yubikey_verify_failed": "YubiKey verification failed",
|
||||
"txt_press_yubikey_to_authenticate": "Press your YubiKey to authenticate.",
|
||||
"txt_yubikey_auto_configure": "Get and save automatically",
|
||||
"txt_yubikey_validation_credentials": "Yubico validation credentials",
|
||||
"txt_view": "View",
|
||||
"txt_yubikey_config_updated": "Yubico validation credentials updated",
|
||||
"txt_yubikey_config_update_failed": "Failed to update Yubico validation credentials",
|
||||
"txt_yubikey_auto_config_failed": "Failed to get Yubico validation credentials",
|
||||
"txt_yubikey_reconfigure_help": "Enter a fresh OTP to request and replace these credentials automatically.",
|
||||
"txt_yubikey_auto_configure_again": "Get again automatically",
|
||||
"txt_setting_coming_soon": "Coming soon.",
|
||||
"txt_totp_manage_intro": "Scan the QR code or enter the key in your authenticator app, then enter the verification code.",
|
||||
"txt_two_step_recovery_code_warning": "If you cannot access your two-step login provider, your one-time recovery code can be used to disable two-step login. Store the recovery code somewhere safe.",
|
||||
"txt_your_two_step_recovery_code": "Your Bitwarden two-step login recovery code:",
|
||||
"txt_name_account_passkey_after_verification": "Passkey created. Name it to help you recognize it.",
|
||||
"txt_account_passkey_name_help": "0 / 50 characters",
|
||||
"txt_page_not_found": "Page Not Found",
|
||||
"txt_page_not_found_hint": "The page may have been removed, expired, or the link is incomplete.",
|
||||
"txt_back_to_home": "Back To Home",
|
||||
@@ -637,6 +688,35 @@ const en: Record<string, string> = {
|
||||
"txt_last_name": "Last Name",
|
||||
"txt_last_seen": "Last Seen",
|
||||
"txt_license_number": "License Number",
|
||||
"txt_bank_account": "Bank Account",
|
||||
"txt_bank_account_details": "Bank Account Details",
|
||||
"txt_bank_name": "Bank Name",
|
||||
"txt_name_on_account": "Name on Account",
|
||||
"txt_account_type": "Account Type",
|
||||
"txt_account_number": "Account Number",
|
||||
"txt_routing_number": "Routing Number",
|
||||
"txt_branch_number": "Branch Number",
|
||||
"txt_pin": "PIN",
|
||||
"txt_swift_code": "SWIFT Code",
|
||||
"txt_iban": "IBAN",
|
||||
"txt_bank_contact_phone": "Bank Contact Phone",
|
||||
"txt_drivers_license": "Driver License",
|
||||
"txt_drivers_license_details": "Driver License Details",
|
||||
"txt_date_of_birth": "Date of Birth",
|
||||
"txt_issuing_country": "Issuing Country",
|
||||
"txt_issuing_state": "Issuing State",
|
||||
"txt_issue_date": "Issue Date",
|
||||
"txt_issuing_authority": "Issuing Authority",
|
||||
"txt_license_class": "License Class",
|
||||
"txt_passport": "Passport",
|
||||
"txt_passport_details": "Passport Details",
|
||||
"txt_surname": "Surname",
|
||||
"txt_given_name": "Given Name",
|
||||
"txt_sex": "Sex",
|
||||
"txt_birth_place": "Place of Birth",
|
||||
"txt_nationality": "Nationality",
|
||||
"txt_passport_type": "Passport Type",
|
||||
"txt_national_id_number": "National ID Number",
|
||||
"txt_link_copied": "Link copied",
|
||||
"txt_linked": "Linked",
|
||||
"txt_linux_desktop": "Linux Desktop",
|
||||
@@ -669,7 +749,7 @@ const en: Record<string, string> = {
|
||||
"txt_jwt_secret_value_label": "Value:",
|
||||
"txt_jwt_secret_value_requirement": "Random string with at least {min} characters",
|
||||
"txt_jwt_what_is": "What is JWT?",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET is the server-side signing key used to issue and verify login tokens. If it is missing, too short, or still using the sample value, the instance is not safe to use normally.",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET is the server-side signing key used to issue and verify login tokens. If it is missing or too short, the instance is not safe to use normally.",
|
||||
"txt_how_to_fix": "How to fix",
|
||||
"txt_jwt_fix_step_1": "Open your deployment environment variables.",
|
||||
"txt_jwt_fix_step_2": "If your current key is not random enough, use the 32-character generator below.",
|
||||
@@ -755,6 +835,23 @@ const en: Record<string, string> = {
|
||||
"txt_password_hint_too_long": "Password hint must be 120 characters or fewer",
|
||||
"txt_passkey": "Passkey",
|
||||
"txt_passkeys": "Passkeys",
|
||||
"txt_register": "Register",
|
||||
"txt_key_list": "Key list",
|
||||
"txt_select_another_verification_method": "Select another verification method",
|
||||
"txt_select_two_step_login_method": "Select two-step login method",
|
||||
"txt_two_step_passkeys": "Passkey two-step login",
|
||||
"txt_two_step_passkeys_help": "Manage passkeys used only for two-step login.",
|
||||
"txt_two_step_passkey_name_placeholder": "Security key",
|
||||
"txt_add_two_step_passkey": "Add passkey",
|
||||
"txt_two_step_passkey_added": "Passkey two-step login updated",
|
||||
"txt_two_step_passkey_removed": "Passkey removed",
|
||||
"txt_two_step_passkeys_disabled": "Passkey two-step login disabled",
|
||||
"txt_disable_passkey_two_step_failed": "Failed to disable passkey two-step login",
|
||||
"txt_use_passkey_to_complete_two_step_verification": "Use your passkey to complete two-step verification.",
|
||||
"txt_touch_your_passkey_when_prompted": "Continue and approve the browser passkey prompt.",
|
||||
"txt_no_two_step_passkeys": "No two-step passkeys",
|
||||
"txt_remove_last_passkey_hint": "Disable passkey two-step login to remove the last key.",
|
||||
"txt_passkey_setup_failed": "Passkey setup failed",
|
||||
"txt_passkey_created_at_value": "Created on {value}",
|
||||
"txt_account_passkey": "Account passkey",
|
||||
"txt_account_passkeys": "Account passkeys",
|
||||
@@ -838,6 +935,8 @@ const en: Record<string, string> = {
|
||||
"txt_scope": "scope",
|
||||
"txt_grant_type": "grant_type",
|
||||
"txt_refresh": "Refresh",
|
||||
"txt_refresh_status": "Refresh status",
|
||||
"txt_load_failed": "Failed to load",
|
||||
"txt_refresh_in_seconds_s": "Refresh in {seconds}s",
|
||||
"txt_regenerate": "Regenerate",
|
||||
"txt_registration_succeeded_please_sign_in": "Registration succeeded. Please sign in.",
|
||||
@@ -944,6 +1043,8 @@ const en: Record<string, string> = {
|
||||
"txt_totp_qr_scanned": "TOTP value added.",
|
||||
"txt_totp_qr_not_found": "No QR code found in that image.",
|
||||
"txt_totp_qr_scan_failed": "Failed to scan QR code.",
|
||||
"txt_totp_qr_invalid_image_type": "Choose an image file.",
|
||||
"txt_totp_qr_image_too_large": "Choose an image smaller than 8 MB.",
|
||||
"txt_totp_qr_unsupported": "This browser does not support QR scanning. Try Chrome or Edge, or paste the TOTP link or secret manually.",
|
||||
"txt_totp_qr_camera_unavailable": "Camera is unavailable. Check browser permission, or choose an image.",
|
||||
"txt_totp_qr_choose_image": "Choose image",
|
||||
@@ -1055,6 +1156,12 @@ const en: Record<string, string> = {
|
||||
"txt_import_invalid_password_protected_file": "Invalid password-protected export file.",
|
||||
"txt_import_decrypt_failed": "Failed to decrypt import file.",
|
||||
"txt_import_empty_zip_archive": "Empty zip archive.",
|
||||
"txt_import_zip_too_large": "ZIP archive is too large. Maximum size is {size} MiB.",
|
||||
"txt_import_file_too_large": "Import file is too large. Maximum size is {size} MiB.",
|
||||
"txt_import_zip_too_many_files": "ZIP archive contains too many files.",
|
||||
"txt_import_zip_entry_too_large": "ZIP archive contains a file larger than {size} MiB.",
|
||||
"txt_import_zip_expands_too_large": "ZIP archive expands beyond the current import limit of {size} MiB.",
|
||||
"txt_import_zip_unsafe_file_name": "ZIP archive contains an unsafe file name.",
|
||||
"txt_import_no_json_found_in_zip": "No importable JSON data found in zip archive.",
|
||||
"txt_import_data_json_not_found": "data.json not found in zip archive.",
|
||||
"txt_import_zip_password_required": "ZIP password is required.",
|
||||
@@ -1149,11 +1256,18 @@ const en: Record<string, string> = {
|
||||
"txt_log_action_account_api_key_create": "Create API key",
|
||||
"txt_log_action_account_api_key_rotate": "Rotate API key",
|
||||
"txt_log_action_account_keys_update": "Update account keys",
|
||||
"txt_log_action_account_passkey_create": "Create login passkey",
|
||||
"txt_log_action_account_passkey_delete": "Delete login passkey",
|
||||
"txt_log_action_account_passkey_encryption_enable": "Enable passkey vault unlock",
|
||||
"txt_log_action_account_profile_update": "Update account profile",
|
||||
"txt_log_action_account_totp_disable": "Disable two-step login",
|
||||
"txt_log_action_account_totp_enable": "Enable two-step login",
|
||||
"txt_log_action_account_totp_recover": "Recover two-step login",
|
||||
"txt_log_action_account_verify_devices_update": "Update device verification",
|
||||
"txt_log_action_account_webauthn_2fa_delete": "Delete passkey two-step login key",
|
||||
"txt_log_action_account_webauthn_2fa_enable": "Enable passkey two-step login",
|
||||
"txt_log_action_account_yubikey_enable": "Update YubiKey OTP settings",
|
||||
"txt_log_action_admin_audit_clear": "Clear audit logs",
|
||||
"txt_log_action_admin_audit_settings_update": "Update log retention settings",
|
||||
"txt_log_action_admin_backup_export": "Export backup",
|
||||
"txt_log_action_admin_backup_import": "Import backup",
|
||||
@@ -1176,6 +1290,8 @@ const en: Record<string, string> = {
|
||||
"txt_log_action_auth_login_failed_bad_password": "Login failed: bad password",
|
||||
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account",
|
||||
"txt_log_action_auth_login_success": "Login succeeded",
|
||||
"txt_log_action_auth_passkey_login_failed": "Passkey login failed",
|
||||
"txt_log_action_auth_passkey_login_success": "Passkey login succeeded",
|
||||
"txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}",
|
||||
"txt_log_action_cipher_delete_permanent": "Permanently delete vault item",
|
||||
"txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items",
|
||||
@@ -1226,6 +1342,7 @@ const en: Record<string, string> = {
|
||||
"txt_log_meta_method": "Request method",
|
||||
"txt_log_meta_path": "Request path",
|
||||
"txt_log_meta_provider": "Provider",
|
||||
"txt_log_meta_prf_status": "PRF status",
|
||||
"txt_log_meta_prune_error": "Cleanup error",
|
||||
"txt_log_meta_pruned_file_count": "Cleaned files",
|
||||
"txt_log_meta_raw": "Raw data",
|
||||
@@ -1261,6 +1378,7 @@ const en: Record<string, string> = {
|
||||
"txt_log_reason_user_inactive": "User inactive",
|
||||
"txt_log_reason_user_missing": "User missing",
|
||||
"txt_log_target_type_attachment": "Attachment",
|
||||
"txt_log_target_type_account_passkey": "Login passkey",
|
||||
"txt_log_target_type_audit_log": "Log",
|
||||
"txt_log_target_type_backup": "Backup",
|
||||
"txt_log_target_type_cipher": "Vault item",
|
||||
|
||||
+241
-123
@@ -11,6 +11,57 @@ const es: Record<string, string> = {
|
||||
"nav_import_export": "Importar y exportar",
|
||||
"nav_group_data_backup": "Datos y copias",
|
||||
"nav_group_management": "Gestión",
|
||||
"txt_settings_appearance": "Apariencia",
|
||||
"txt_theme": "Tema",
|
||||
"txt_use_system_theme": "Usar tema del sistema",
|
||||
"txt_light_theme": "Claro",
|
||||
"txt_dark_theme": "Oscuro",
|
||||
"txt_theme_saved_locally": "Elige un tema para tu bóveda web.",
|
||||
"txt_display_language_help": "Cambia el idioma de la bóveda web.",
|
||||
"txt_two_step_login": "Inicio de sesión en dos pasos",
|
||||
"txt_keys": "Claves",
|
||||
"txt_manage": "Gestionar",
|
||||
"txt_providers": "Proveedores",
|
||||
"txt_authenticator_app": "Aplicación autenticadora",
|
||||
"txt_authenticator_app_help": "Introduce un código generado por una aplicación autenticadora.",
|
||||
"txt_passkey_provider_help": "Usa una llave de seguridad compatible con FIDO2 o autenticación biométrica.",
|
||||
"txt_yubico_otp_security_key": "Llave de seguridad Yubico OTP",
|
||||
"txt_yubico_otp_security_key_help": "Usa un dispositivo YubiKey 4, 5 o NEO.",
|
||||
"txt_yubikey_setup_intro": "Inserta tu YubiKey en un puerto USB. Selecciona el primer campo YubiKey vacío, toca el botón de la YubiKey y guarda el formulario.",
|
||||
"txt_yubikey_plug_in": "Inserta tu YubiKey en un puerto USB.",
|
||||
"txt_yubikey_select_empty_field": "Selecciona el primer campo YubiKey vacío.",
|
||||
"txt_yubikey_touch_button": "Toca el botón de la YubiKey.",
|
||||
"txt_yubikey_save_form": "Guarda el formulario.",
|
||||
"txt_yubikey_x": "YubiKey {index}",
|
||||
"txt_nfc_support": "Compatibilidad NFC",
|
||||
"txt_yubikey_supports_nfc": "Una de mis llaves admite NFC.",
|
||||
"txt_yubikey_supports_nfc_desc": "Si una de tus YubiKeys admite NFC, las apps móviles pueden avisarte cuando NFC esté disponible.",
|
||||
"txt_disable_all_keys": "Desactivar todas las llaves",
|
||||
"txt_yubikeys_updated": "YubiKeys actualizadas",
|
||||
"txt_yubikey_update_failed": "No se pudieron actualizar las YubiKeys",
|
||||
"txt_disable_yubikey_failed": "No se pudieron desactivar las YubiKeys",
|
||||
"txt_yubikey_disabled": "YubiKeys desactivadas",
|
||||
"txt_yubikey_enabled": "YubiKey activada.",
|
||||
"txt_yubikey_config_required": "La validación de Yubico no está configurada",
|
||||
"txt_yubikey_config_required_help": "Introduce primero un OTP de YubiKey. NodeWarden solicitará y guardará automáticamente el Client ID y la Secret key de la instancia, y luego abrirá el formulario de YubiKey.",
|
||||
"txt_otp_from_yubikey": "OTP de YubiKey",
|
||||
"txt_please_input_yubikey_otp": "Introduce el OTP de YubiKey",
|
||||
"txt_yubikey_verify_failed": "No se pudo verificar la YubiKey",
|
||||
"txt_press_yubikey_to_authenticate": "Pulsa tu YubiKey para autenticarte.",
|
||||
"txt_yubikey_auto_configure": "Obtener y guardar automáticamente",
|
||||
"txt_yubikey_validation_credentials": "Credenciales de validación de Yubico",
|
||||
"txt_view": "Ver",
|
||||
"txt_yubikey_config_updated": "Credenciales de validación de Yubico actualizadas",
|
||||
"txt_yubikey_config_update_failed": "No se pudieron actualizar las credenciales de validación de Yubico",
|
||||
"txt_yubikey_auto_config_failed": "No se pudieron obtener las credenciales de validación de Yubico",
|
||||
"txt_yubikey_reconfigure_help": "Introduce un OTP nuevo para solicitar y reemplazar estas credenciales automáticamente.",
|
||||
"txt_yubikey_auto_configure_again": "Obtener de nuevo automáticamente",
|
||||
"txt_setting_coming_soon": "Próximamente.",
|
||||
"txt_totp_manage_intro": "Escanea el código QR o introduce la clave en tu aplicación autenticadora, luego escribe el código de verificación.",
|
||||
"txt_two_step_recovery_code_warning": "Si no puedes acceder a tu proveedor de inicio de sesión en dos pasos, tu código de recuperación de un solo uso puede desactivar el inicio de sesión en dos pasos. Guarda el código en un lugar seguro.",
|
||||
"txt_your_two_step_recovery_code": "Tu código de recuperación de inicio de sesión en dos pasos de Bitwarden:",
|
||||
"txt_name_account_passkey_after_verification": "Passkey creada. Ponle un nombre para reconocerla.",
|
||||
"txt_account_passkey_name_help": "0 / 50 caracteres como máximo",
|
||||
"txt_page_not_found": "Página no encontrada",
|
||||
"txt_page_not_found_hint": "La página pudo haberse eliminado, expirado, o el enlace está incompleto.",
|
||||
"txt_back_to_home": "Volver al inicio",
|
||||
@@ -637,6 +688,35 @@ const es: Record<string, string> = {
|
||||
"txt_last_name": "Apellido",
|
||||
"txt_last_seen": "Visto por última vez",
|
||||
"txt_license_number": "Número de licencia",
|
||||
"txt_bank_account": "Cuenta bancaria",
|
||||
"txt_bank_account_details": "Detalles de cuenta bancaria",
|
||||
"txt_bank_name": "Nombre del banco",
|
||||
"txt_name_on_account": "Nombre en la cuenta",
|
||||
"txt_account_type": "Tipo de cuenta",
|
||||
"txt_account_number": "Número de cuenta",
|
||||
"txt_routing_number": "Número de ruta",
|
||||
"txt_branch_number": "Número de sucursal",
|
||||
"txt_pin": "PIN",
|
||||
"txt_swift_code": "Código SWIFT",
|
||||
"txt_iban": "IBAN",
|
||||
"txt_bank_contact_phone": "Teléfono del banco",
|
||||
"txt_drivers_license": "Licencia de conducir",
|
||||
"txt_drivers_license_details": "Detalles de licencia de conducir",
|
||||
"txt_date_of_birth": "Fecha de nacimiento",
|
||||
"txt_issuing_country": "País emisor",
|
||||
"txt_issuing_state": "Estado emisor",
|
||||
"txt_issue_date": "Fecha de emisión",
|
||||
"txt_issuing_authority": "Autoridad emisora",
|
||||
"txt_license_class": "Clase de licencia",
|
||||
"txt_passport": "Pasaporte",
|
||||
"txt_passport_details": "Detalles del pasaporte",
|
||||
"txt_surname": "Apellido",
|
||||
"txt_given_name": "Nombre",
|
||||
"txt_sex": "Sexo",
|
||||
"txt_birth_place": "Lugar de nacimiento",
|
||||
"txt_nationality": "Nacionalidad",
|
||||
"txt_passport_type": "Tipo de pasaporte",
|
||||
"txt_national_id_number": "Número de ID nacional",
|
||||
"txt_link_copied": "Enlace copiado",
|
||||
"txt_linked": "Vinculado",
|
||||
"txt_linux_desktop": "Escritorio Linux",
|
||||
@@ -669,7 +749,7 @@ const es: Record<string, string> = {
|
||||
"txt_jwt_secret_value_label": "Valor:",
|
||||
"txt_jwt_secret_value_requirement": "Cadena aleatoria de al menos {min} caracteres",
|
||||
"txt_jwt_what_is": "Qué es JWT",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET es la clave de firma del lado del servidor utilizada para emitir y verificar tokens de inicio de sesión. Si no está presente, es demasiado corta o todavía usa el valor de ejemplo, la instancia no es segura para uso normal.",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET es la clave de firma del lado del servidor utilizada para emitir y verificar tokens de inicio de sesión. Si no está presente o es demasiado corta, la instancia no es segura para uso normal.",
|
||||
"txt_how_to_fix": "Cómo corregirlo",
|
||||
"txt_jwt_fix_step_1": "Abra las variables de entorno de su despliegue.",
|
||||
"txt_jwt_fix_step_2": "Si su clave actual no es lo suficientemente aleatoria, use el generador de 32 caracteres a continuación.",
|
||||
@@ -755,6 +835,23 @@ const es: Record<string, string> = {
|
||||
"txt_password_hint_too_long": "La pista de contraseña debe tener 120 caracteres o menos",
|
||||
"txt_passkey": "Clave de acceso",
|
||||
"txt_passkeys": "Claves de acceso",
|
||||
"txt_register": "Registrar",
|
||||
"txt_key_list": "Lista de claves",
|
||||
"txt_select_another_verification_method": "Seleccionar otro método de verificación",
|
||||
"txt_select_two_step_login_method": "Seleccionar método de inicio de sesión en dos pasos",
|
||||
"txt_two_step_passkeys": "Inicio de sesión en dos pasos con clave de acceso",
|
||||
"txt_two_step_passkeys_help": "Administra claves de acceso usadas solo para el inicio de sesión en dos pasos.",
|
||||
"txt_two_step_passkey_name_placeholder": "Llave de seguridad",
|
||||
"txt_add_two_step_passkey": "Agregar clave de acceso",
|
||||
"txt_two_step_passkey_added": "Inicio de sesión en dos pasos con clave de acceso actualizado",
|
||||
"txt_two_step_passkey_removed": "Clave de acceso eliminada",
|
||||
"txt_two_step_passkeys_disabled": "Inicio de sesión en dos pasos con clave de acceso desactivado",
|
||||
"txt_disable_passkey_two_step_failed": "No se pudo desactivar el inicio de sesión en dos pasos con clave de acceso",
|
||||
"txt_use_passkey_to_complete_two_step_verification": "Usa tu clave de acceso para completar la verificación en dos pasos.",
|
||||
"txt_touch_your_passkey_when_prompted": "Continúa y aprueba la solicitud de clave de acceso del navegador.",
|
||||
"txt_no_two_step_passkeys": "No hay claves de acceso en dos pasos",
|
||||
"txt_remove_last_passkey_hint": "Desactiva el inicio de sesión en dos pasos con clave de acceso para eliminar la última clave.",
|
||||
"txt_passkey_setup_failed": "Error al configurar la clave de acceso",
|
||||
"txt_passkey_created_at_value": "Creado el {value}",
|
||||
"txt_account_passkey": "Clave de acceso de cuenta",
|
||||
"txt_account_passkeys": "Claves de acceso de cuenta",
|
||||
@@ -838,6 +935,8 @@ const es: Record<string, string> = {
|
||||
"txt_scope": "Ámbito",
|
||||
"txt_grant_type": "Tipo de concesión",
|
||||
"txt_refresh": "Actualizar",
|
||||
"txt_refresh_status": "Actualizar estado",
|
||||
"txt_load_failed": "No se pudo cargar",
|
||||
"txt_refresh_in_seconds_s": "Actualizar en {seconds}s",
|
||||
"txt_regenerate": "Regenerar",
|
||||
"txt_registration_succeeded_please_sign_in": "Registro completado. Inicie sesión.",
|
||||
@@ -944,6 +1043,8 @@ const es: Record<string, string> = {
|
||||
"txt_totp_qr_scanned": "Valor TOTP agregado.",
|
||||
"txt_totp_qr_not_found": "No se encontró ningún código QR en esa imagen.",
|
||||
"txt_totp_qr_scan_failed": "No se pudo escanear el código QR.",
|
||||
"txt_totp_qr_invalid_image_type": "Elija un archivo de imagen.",
|
||||
"txt_totp_qr_image_too_large": "Elija una imagen de menos de 8 MB.",
|
||||
"txt_totp_qr_unsupported": "Este navegador no admite escaneo QR. Pruebe Chrome o Edge, o pegue manualmente el enlace o secreto TOTP.",
|
||||
"txt_totp_qr_camera_unavailable": "La cámara no está disponible. Revise el permiso del navegador o elija una imagen.",
|
||||
"txt_totp_qr_choose_image": "Elegir imagen",
|
||||
@@ -1055,6 +1156,12 @@ const es: Record<string, string> = {
|
||||
"txt_import_invalid_password_protected_file": "Archivo de exportación protegido con contraseña no válido.",
|
||||
"txt_import_decrypt_failed": "Error al descifrar el archivo de importación.",
|
||||
"txt_import_empty_zip_archive": "El archivo ZIP está vacío.",
|
||||
"txt_import_zip_too_large": "El archivo ZIP es demasiado grande. El tamaño máximo es {size} MiB.",
|
||||
"txt_import_file_too_large": "El archivo de importación es demasiado grande. El tamaño máximo es {size} MiB.",
|
||||
"txt_import_zip_too_many_files": "El archivo ZIP contiene demasiados archivos.",
|
||||
"txt_import_zip_entry_too_large": "El archivo ZIP contiene un archivo mayor que {size} MiB.",
|
||||
"txt_import_zip_expands_too_large": "El archivo ZIP se descomprime por encima del límite actual de importación de {size} MiB.",
|
||||
"txt_import_zip_unsafe_file_name": "El archivo ZIP contiene un nombre de archivo no seguro.",
|
||||
"txt_import_no_json_found_in_zip": "No se encontraron datos JSON importables en el archivo zip.",
|
||||
"txt_import_data_json_not_found": "No se encontró data.json en el archivo ZIP.",
|
||||
"txt_import_zip_password_required": "La contraseña ZIP es obligatoria.",
|
||||
@@ -1146,133 +1253,144 @@ const es: Record<string, string> = {
|
||||
"txt_log_level_info": "Info",
|
||||
"txt_log_level_security": "Seguridad",
|
||||
"txt_log_level_warn": "Aviso",
|
||||
"txt_log_action_account_api_key_create": "Create API key",
|
||||
"txt_log_action_account_api_key_rotate": "Rotate API key",
|
||||
"txt_log_action_account_keys_update": "Update account keys",
|
||||
"txt_log_action_account_profile_update": "Update account profile",
|
||||
"txt_log_action_account_totp_disable": "Disable two-step login",
|
||||
"txt_log_action_account_totp_enable": "Enable two-step login",
|
||||
"txt_log_action_account_totp_recover": "Recover two-step login",
|
||||
"txt_log_action_account_verify_devices_update": "Update device verification",
|
||||
"txt_log_action_admin_audit_settings_update": "Update log retention settings",
|
||||
"txt_log_action_admin_backup_export": "Export backup",
|
||||
"txt_log_action_admin_backup_import": "Import backup",
|
||||
"txt_log_action_admin_backup_remote_delete": "Delete remote backup",
|
||||
"txt_log_action_admin_backup_remote_manual": "Manual remote backup succeeded",
|
||||
"txt_log_action_admin_backup_remote_manual_failed": "Manual remote backup failed",
|
||||
"txt_log_action_admin_backup_remote_scheduled": "Scheduled remote backup succeeded",
|
||||
"txt_log_action_admin_backup_remote_scheduled_failed": "Scheduled remote backup failed",
|
||||
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
|
||||
"txt_log_action_admin_backup_settings_update": "Update backup settings",
|
||||
"txt_log_action_admin_invite_create": "Create invite",
|
||||
"txt_log_action_admin_invite_delete": "Delete invite",
|
||||
"txt_log_action_admin_invite_delete_all": "Clear invites",
|
||||
"txt_log_action_admin_invite_delete_invalid": "Delete invalid invites",
|
||||
"txt_log_action_admin_invite_revoke": "Revoke invite",
|
||||
"txt_log_action_admin_user_delete": "Delete user",
|
||||
"txt_log_action_admin_user_status": "Change user status",
|
||||
"txt_log_action_attachment_delete": "Delete attachment",
|
||||
"txt_log_action_auth_login_failed_bad_api_key": "Login failed: bad API key",
|
||||
"txt_log_action_auth_login_failed_bad_password": "Login failed: bad password",
|
||||
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account",
|
||||
"txt_log_action_auth_login_success": "Login succeeded",
|
||||
"txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}",
|
||||
"txt_log_action_cipher_delete_permanent": "Permanently delete vault item",
|
||||
"txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items",
|
||||
"txt_log_action_cipher_delete_soft": "Move vault item to trash",
|
||||
"txt_log_action_cipher_delete_soft_bulk": "Move vault items to trash",
|
||||
"txt_log_action_device_deactivate": "Deactivate device",
|
||||
"txt_log_action_device_delete": "Delete device",
|
||||
"txt_log_action_device_delete_all": "Delete all devices",
|
||||
"txt_log_action_device_name_update": "Update device name",
|
||||
"txt_log_action_device_trust_permanent": "Trust device permanently",
|
||||
"txt_log_action_device_trust_revoke": "Revoke device trust",
|
||||
"txt_log_action_device_trust_revoke_batch": "Revoke device trust in bulk",
|
||||
"txt_log_action_folder_delete": "Delete folder",
|
||||
"txt_log_action_folder_delete_bulk": "Delete folders",
|
||||
"txt_log_action_send_auth_remove": "Remove Send authentication",
|
||||
"txt_log_action_send_delete": "Delete Send",
|
||||
"txt_log_action_send_delete_bulk": "Delete Sends",
|
||||
"txt_log_action_send_password_remove": "Remove Send password",
|
||||
"txt_log_action_user_password_change": "Change master password",
|
||||
"txt_log_action_user_register_first_admin": "Register first admin",
|
||||
"txt_log_action_user_register_invite": "Register by invite",
|
||||
"txt_log_meta_attachments": "Attachments",
|
||||
"txt_log_action_account_api_key_create": "Crear clave de API",
|
||||
"txt_log_action_account_api_key_rotate": "Rotar clave de API",
|
||||
"txt_log_action_account_keys_update": "Actualizar claves de cuenta",
|
||||
"txt_log_action_account_passkey_create": "Crear passkey de inicio de sesión",
|
||||
"txt_log_action_account_passkey_delete": "Eliminar passkey de inicio de sesión",
|
||||
"txt_log_action_account_passkey_encryption_enable": "Activar desbloqueo de la bóveda con passkey",
|
||||
"txt_log_action_account_profile_update": "Actualizar perfil de cuenta",
|
||||
"txt_log_action_account_totp_disable": "Desactivar verificación en dos pasos",
|
||||
"txt_log_action_account_totp_enable": "Activar verificación en dos pasos",
|
||||
"txt_log_action_account_totp_recover": "Recuperar verificación en dos pasos",
|
||||
"txt_log_action_account_verify_devices_update": "Actualizar verificación de dispositivos",
|
||||
"txt_log_action_account_webauthn_2fa_delete": "Eliminar clave de verificación en dos pasos con passkey",
|
||||
"txt_log_action_account_webauthn_2fa_enable": "Activar verificación en dos pasos con passkey",
|
||||
"txt_log_action_account_yubikey_enable": "Actualizar configuración de YubiKey OTP",
|
||||
"txt_log_action_admin_audit_clear": "Borrar registros de auditoría",
|
||||
"txt_log_action_admin_audit_settings_update": "Actualizar retención de registros",
|
||||
"txt_log_action_admin_backup_export": "Exportar copia de seguridad",
|
||||
"txt_log_action_admin_backup_import": "Importar copia de seguridad",
|
||||
"txt_log_action_admin_backup_remote_delete": "Eliminar copia remota",
|
||||
"txt_log_action_admin_backup_remote_manual": "Copia remota manual completada",
|
||||
"txt_log_action_admin_backup_remote_manual_failed": "Error en copia remota manual",
|
||||
"txt_log_action_admin_backup_remote_scheduled": "Copia remota programada completada",
|
||||
"txt_log_action_admin_backup_remote_scheduled_failed": "Error en copia remota programada",
|
||||
"txt_log_action_admin_backup_settings_repair": "Reparar configuración de copias",
|
||||
"txt_log_action_admin_backup_settings_update": "Actualizar configuración de copias",
|
||||
"txt_log_action_admin_invite_create": "Crear invitación",
|
||||
"txt_log_action_admin_invite_delete": "Eliminar invitación",
|
||||
"txt_log_action_admin_invite_delete_all": "Borrar invitaciones",
|
||||
"txt_log_action_admin_invite_delete_invalid": "Eliminar invitaciones no válidas",
|
||||
"txt_log_action_admin_invite_revoke": "Revocar invitación",
|
||||
"txt_log_action_admin_user_delete": "Eliminar usuario",
|
||||
"txt_log_action_admin_user_status": "Cambiar estado del usuario",
|
||||
"txt_log_action_attachment_delete": "Eliminar adjunto",
|
||||
"txt_log_action_auth_login_failed_bad_api_key": "Inicio de sesión fallido: clave de API incorrecta",
|
||||
"txt_log_action_auth_login_failed_bad_password": "Inicio de sesión fallido: contraseña incorrecta",
|
||||
"txt_log_action_auth_login_failed_user_inactive": "Inicio de sesión fallido: cuenta inactiva",
|
||||
"txt_log_action_auth_login_success": "Inicio de sesión correcto",
|
||||
"txt_log_action_auth_passkey_login_failed": "Error de inicio de sesión con passkey",
|
||||
"txt_log_action_auth_passkey_login_success": "Inicio de sesión con passkey correcto",
|
||||
"txt_log_action_auth_refresh_failed": "Error al renovar inicio de sesión: {reason}",
|
||||
"txt_log_action_cipher_delete_permanent": "Eliminar elemento de bóveda permanentemente",
|
||||
"txt_log_action_cipher_delete_permanent_bulk": "Eliminar elementos de bóveda permanentemente",
|
||||
"txt_log_action_cipher_delete_soft": "Mover elemento de bóveda a la papelera",
|
||||
"txt_log_action_cipher_delete_soft_bulk": "Mover elementos de bóveda a la papelera",
|
||||
"txt_log_action_device_deactivate": "Desactivar dispositivo",
|
||||
"txt_log_action_device_delete": "Eliminar dispositivo",
|
||||
"txt_log_action_device_delete_all": "Eliminar todos los dispositivos",
|
||||
"txt_log_action_device_name_update": "Actualizar nombre del dispositivo",
|
||||
"txt_log_action_device_trust_permanent": "Confiar permanentemente en el dispositivo",
|
||||
"txt_log_action_device_trust_revoke": "Revocar confianza del dispositivo",
|
||||
"txt_log_action_device_trust_revoke_batch": "Revocar confianza de dispositivos en lote",
|
||||
"txt_log_action_folder_delete": "Eliminar carpeta",
|
||||
"txt_log_action_folder_delete_bulk": "Eliminar carpetas",
|
||||
"txt_log_action_send_auth_remove": "Quitar autenticación de Send",
|
||||
"txt_log_action_send_delete": "Eliminar Send",
|
||||
"txt_log_action_send_delete_bulk": "Eliminar Sends",
|
||||
"txt_log_action_send_password_remove": "Quitar contraseña de Send",
|
||||
"txt_log_action_user_password_change": "Cambiar contraseña maestra",
|
||||
"txt_log_action_user_register_first_admin": "Registrar primer administrador",
|
||||
"txt_log_action_user_register_invite": "Registrarse por invitación",
|
||||
"txt_log_meta_attachments": "Adjuntos",
|
||||
"txt_log_meta_bytes": "Bytes",
|
||||
"txt_log_meta_changed": "Changed fields",
|
||||
"txt_log_meta_checksum_mismatch_accepted": "Accepted checksum mismatch",
|
||||
"txt_log_meta_cipher_id": "Vault item ID",
|
||||
"txt_log_meta_ciphers": "Vault items",
|
||||
"txt_log_meta_compat": "Compatibility",
|
||||
"txt_log_meta_compressed_bytes": "Compressed bytes",
|
||||
"txt_log_meta_count": "Count",
|
||||
"txt_log_meta_deleted": "Deleted count",
|
||||
"txt_log_meta_destination_count": "Destination count",
|
||||
"txt_log_meta_destination_id": "Destination ID",
|
||||
"txt_log_meta_destination_name": "Destination name",
|
||||
"txt_log_meta_destination_type": "Destination type",
|
||||
"txt_log_meta_device_identifier": "Device ID",
|
||||
"txt_log_meta_device_type": "Device type",
|
||||
"txt_log_meta_changed": "Campos modificados",
|
||||
"txt_log_meta_checksum_mismatch_accepted": "Desajuste de checksum aceptado",
|
||||
"txt_log_meta_cipher_id": "ID del elemento de bóveda",
|
||||
"txt_log_meta_ciphers": "Elementos de bóveda",
|
||||
"txt_log_meta_compat": "Compatibilidad",
|
||||
"txt_log_meta_compressed_bytes": "Bytes comprimidos",
|
||||
"txt_log_meta_count": "Cantidad",
|
||||
"txt_log_meta_deleted": "Cantidad eliminada",
|
||||
"txt_log_meta_destination_count": "Cantidad de destinos",
|
||||
"txt_log_meta_destination_id": "ID de destino",
|
||||
"txt_log_meta_destination_name": "Nombre de destino",
|
||||
"txt_log_meta_destination_type": "Tipo de destino",
|
||||
"txt_log_meta_device_identifier": "ID del dispositivo",
|
||||
"txt_log_meta_device_type": "Tipo de dispositivo",
|
||||
"txt_log_meta_email": "Email",
|
||||
"txt_log_meta_error": "Error",
|
||||
"txt_log_meta_expires_in_hours": "Expires in hours",
|
||||
"txt_log_meta_file_bytes": "File bytes",
|
||||
"txt_log_meta_file_name": "File name",
|
||||
"txt_log_meta_folder_id": "Folder ID",
|
||||
"txt_log_meta_grant_type": "Login method",
|
||||
"txt_log_meta_includes_attachments": "Includes attachments",
|
||||
"txt_log_meta_ip": "IP address",
|
||||
"txt_log_meta_max_entries": "Entry limit",
|
||||
"txt_log_meta_method": "Request method",
|
||||
"txt_log_meta_path": "Request path",
|
||||
"txt_log_meta_provider": "Provider",
|
||||
"txt_log_meta_prune_error": "Cleanup error",
|
||||
"txt_log_meta_pruned_file_count": "Cleaned files",
|
||||
"txt_log_meta_raw": "Raw data",
|
||||
"txt_log_meta_reason": "Reason",
|
||||
"txt_log_meta_remote_path": "Remote path",
|
||||
"txt_log_meta_removed": "Removed count",
|
||||
"txt_log_meta_removed_devices": "Removed devices",
|
||||
"txt_log_meta_removed_sessions": "Removed sessions",
|
||||
"txt_log_meta_removed_trusted": "Trust removals",
|
||||
"txt_log_meta_replace_existing": "Replace existing data",
|
||||
"txt_log_meta_requested": "Requested count",
|
||||
"txt_log_meta_requested_count": "Requested count",
|
||||
"txt_log_meta_retention_days": "Retention days",
|
||||
"txt_log_meta_scheduled_destination_count": "Scheduled destinations",
|
||||
"txt_log_meta_size": "Size",
|
||||
"txt_log_meta_skipped_attachments": "Skipped attachments",
|
||||
"txt_log_meta_skipped_reason": "Skip reason",
|
||||
"txt_log_meta_status": "Status",
|
||||
"txt_log_meta_target_email": "Target email",
|
||||
"txt_log_meta_trigger": "Trigger",
|
||||
"txt_log_meta_type": "Type",
|
||||
"txt_log_meta_updated": "Updated count",
|
||||
"txt_log_meta_upload_verification_attempts": "Upload verification attempts",
|
||||
"txt_log_meta_user_agent": "Browser/client",
|
||||
"txt_log_meta_users": "Users",
|
||||
"txt_log_meta_verify_devices": "Verify devices",
|
||||
"txt_log_meta_web_session": "Web session",
|
||||
"txt_log_reason_bad_api_key": "Bad API key",
|
||||
"txt_log_reason_bad_password": "Bad password",
|
||||
"txt_log_reason_device_missing": "Device missing",
|
||||
"txt_log_reason_device_session_mismatch": "Device session mismatch",
|
||||
"txt_log_reason_token_not_found_or_expired": "Token missing or expired",
|
||||
"txt_log_reason_user_inactive": "User inactive",
|
||||
"txt_log_reason_user_missing": "User missing",
|
||||
"txt_log_target_type_attachment": "Attachment",
|
||||
"txt_log_target_type_audit_log": "Log",
|
||||
"txt_log_target_type_backup": "Backup",
|
||||
"txt_log_target_type_cipher": "Vault item",
|
||||
"txt_log_target_type_device": "Device",
|
||||
"txt_log_target_type_folder": "Folder",
|
||||
"txt_log_target_type_invite": "Invite",
|
||||
"txt_log_target_type_refresh_token": "Refresh token",
|
||||
"txt_log_meta_expires_in_hours": "Caduca en horas",
|
||||
"txt_log_meta_file_bytes": "Bytes del archivo",
|
||||
"txt_log_meta_file_name": "Nombre del archivo",
|
||||
"txt_log_meta_folder_id": "ID de carpeta",
|
||||
"txt_log_meta_grant_type": "Método de inicio de sesión",
|
||||
"txt_log_meta_includes_attachments": "Incluye adjuntos",
|
||||
"txt_log_meta_ip": "Dirección IP",
|
||||
"txt_log_meta_max_entries": "Límite de entradas",
|
||||
"txt_log_meta_method": "Método de solicitud",
|
||||
"txt_log_meta_path": "Ruta de solicitud",
|
||||
"txt_log_meta_provider": "Proveedor",
|
||||
"txt_log_meta_prf_status": "Estado de PRF",
|
||||
"txt_log_meta_prune_error": "Error de limpieza",
|
||||
"txt_log_meta_pruned_file_count": "Archivos limpiados",
|
||||
"txt_log_meta_raw": "Datos sin procesar",
|
||||
"txt_log_meta_reason": "Motivo",
|
||||
"txt_log_meta_remote_path": "Ruta remota",
|
||||
"txt_log_meta_removed": "Cantidad quitada",
|
||||
"txt_log_meta_removed_devices": "Dispositivos quitados",
|
||||
"txt_log_meta_removed_sessions": "Sesiones quitadas",
|
||||
"txt_log_meta_removed_trusted": "Confianzas revocadas",
|
||||
"txt_log_meta_replace_existing": "Reemplazar datos existentes",
|
||||
"txt_log_meta_requested": "Cantidad solicitada",
|
||||
"txt_log_meta_requested_count": "Cantidad solicitada",
|
||||
"txt_log_meta_retention_days": "Días de retención",
|
||||
"txt_log_meta_scheduled_destination_count": "Destinos programados",
|
||||
"txt_log_meta_size": "Tamaño",
|
||||
"txt_log_meta_skipped_attachments": "Adjuntos omitidos",
|
||||
"txt_log_meta_skipped_reason": "Motivo de omisión",
|
||||
"txt_log_meta_status": "Estado",
|
||||
"txt_log_meta_target_email": "Correo del destino",
|
||||
"txt_log_meta_trigger": "Disparador",
|
||||
"txt_log_meta_type": "Tipo",
|
||||
"txt_log_meta_updated": "Cantidad actualizada",
|
||||
"txt_log_meta_upload_verification_attempts": "Intentos de verificación de subida",
|
||||
"txt_log_meta_user_agent": "Navegador/cliente",
|
||||
"txt_log_meta_users": "Usuarios",
|
||||
"txt_log_meta_verify_devices": "Verificar dispositivos",
|
||||
"txt_log_meta_web_session": "Sesión web",
|
||||
"txt_log_reason_bad_api_key": "Clave de API incorrecta",
|
||||
"txt_log_reason_bad_password": "Contraseña incorrecta",
|
||||
"txt_log_reason_device_missing": "Dispositivo no encontrado",
|
||||
"txt_log_reason_device_session_mismatch": "La sesión no coincide con el dispositivo",
|
||||
"txt_log_reason_token_not_found_or_expired": "Token no encontrado o caducado",
|
||||
"txt_log_reason_user_inactive": "Usuario inactivo",
|
||||
"txt_log_reason_user_missing": "Usuario no encontrado",
|
||||
"txt_log_target_type_attachment": "Adjunto",
|
||||
"txt_log_target_type_account_passkey": "Passkey de inicio de sesión",
|
||||
"txt_log_target_type_audit_log": "Registro",
|
||||
"txt_log_target_type_backup": "Copia de seguridad",
|
||||
"txt_log_target_type_cipher": "Elemento de bóveda",
|
||||
"txt_log_target_type_device": "Dispositivo",
|
||||
"txt_log_target_type_folder": "Carpeta",
|
||||
"txt_log_target_type_invite": "Invitación",
|
||||
"txt_log_target_type_refresh_token": "Token de renovación",
|
||||
"txt_log_target_type_send": "Send",
|
||||
"txt_log_target_type_user": "User",
|
||||
"txt_log_target_type_user": "Usuario",
|
||||
"txt_log_trigger_manual": "Manual",
|
||||
"txt_log_trigger_remote": "Remote",
|
||||
"txt_log_trigger_scheduled": "Scheduled",
|
||||
"txt_log_trigger_remote": "Remoto",
|
||||
"txt_log_trigger_scheduled": "Programado",
|
||||
"txt_log_max_1000": "Hasta 1000 entradas",
|
||||
"txt_log_max_5000": "Hasta 5000 entradas",
|
||||
"txt_log_max_10000": "Hasta 10 000 entradas",
|
||||
|
||||
+243
-125
@@ -12,6 +12,57 @@ const ru: Record<string, string> = {
|
||||
"nav_import_export": "Импорт и экспорт",
|
||||
"nav_group_data_backup": "Данные и резервные копии",
|
||||
"nav_group_management": "Управление",
|
||||
"txt_settings_appearance": "Внешний вид",
|
||||
"txt_theme": "Тема",
|
||||
"txt_use_system_theme": "Использовать системную тему",
|
||||
"txt_light_theme": "Светлая",
|
||||
"txt_dark_theme": "Темная",
|
||||
"txt_theme_saved_locally": "Выберите тему для веб-хранилища.",
|
||||
"txt_display_language_help": "Изменить язык веб-хранилища.",
|
||||
"txt_two_step_login": "Двухэтапный вход",
|
||||
"txt_keys": "Ключи",
|
||||
"txt_manage": "Управлять",
|
||||
"txt_providers": "Поставщики",
|
||||
"txt_authenticator_app": "Приложение-аутентификатор",
|
||||
"txt_authenticator_app_help": "Введите код, созданный приложением-аутентификатором.",
|
||||
"txt_passkey_provider_help": "Используйте FIDO2-совместимый ключ безопасности или биометрический аутентификатор.",
|
||||
"txt_yubico_otp_security_key": "Ключ безопасности Yubico OTP",
|
||||
"txt_yubico_otp_security_key_help": "Используйте устройство YubiKey 4, 5 или NEO.",
|
||||
"txt_yubikey_setup_intro": "Вставьте YubiKey в USB-порт. Выберите первое пустое поле YubiKey ниже, коснитесь кнопки YubiKey и сохраните форму.",
|
||||
"txt_yubikey_plug_in": "Вставьте YubiKey в USB-порт.",
|
||||
"txt_yubikey_select_empty_field": "Выберите первое пустое поле YubiKey ниже.",
|
||||
"txt_yubikey_touch_button": "Коснитесь кнопки YubiKey.",
|
||||
"txt_yubikey_save_form": "Сохраните форму.",
|
||||
"txt_yubikey_x": "YubiKey {index}",
|
||||
"txt_nfc_support": "Поддержка NFC",
|
||||
"txt_yubikey_supports_nfc": "Один из моих ключей поддерживает NFC.",
|
||||
"txt_yubikey_supports_nfc_desc": "Если один из ваших YubiKey поддерживает NFC, мобильные приложения смогут подсказать вам, когда NFC доступен.",
|
||||
"txt_disable_all_keys": "Отключить все ключи",
|
||||
"txt_yubikeys_updated": "YubiKey обновлены",
|
||||
"txt_yubikey_update_failed": "Не удалось обновить YubiKey",
|
||||
"txt_disable_yubikey_failed": "Не удалось отключить YubiKey",
|
||||
"txt_yubikey_disabled": "YubiKey отключены",
|
||||
"txt_yubikey_enabled": "YubiKey включен.",
|
||||
"txt_yubikey_config_required": "Проверка Yubico не настроена",
|
||||
"txt_yubikey_config_required_help": "Сначала введите один OTP с YubiKey. NodeWarden автоматически запросит и сохранит Client ID и Secret key экземпляра, затем откроет форму настройки YubiKey.",
|
||||
"txt_otp_from_yubikey": "OTP с YubiKey",
|
||||
"txt_please_input_yubikey_otp": "Введите OTP с YubiKey",
|
||||
"txt_yubikey_verify_failed": "Не удалось проверить YubiKey",
|
||||
"txt_press_yubikey_to_authenticate": "Нажмите YubiKey для проверки.",
|
||||
"txt_yubikey_auto_configure": "Получить и сохранить автоматически",
|
||||
"txt_yubikey_validation_credentials": "Учетные данные проверки Yubico",
|
||||
"txt_view": "Показать",
|
||||
"txt_yubikey_config_updated": "Учетные данные проверки Yubico обновлены",
|
||||
"txt_yubikey_config_update_failed": "Не удалось обновить учетные данные проверки Yubico",
|
||||
"txt_yubikey_auto_config_failed": "Не удалось получить учетные данные проверки Yubico",
|
||||
"txt_yubikey_reconfigure_help": "Введите новый OTP, чтобы автоматически запросить и заменить эти учетные данные.",
|
||||
"txt_yubikey_auto_configure_again": "Получить снова автоматически",
|
||||
"txt_setting_coming_soon": "Скоро появится.",
|
||||
"txt_totp_manage_intro": "Отсканируйте QR-код или введите ключ в приложении-аутентификаторе, затем введите код проверки.",
|
||||
"txt_two_step_recovery_code_warning": "Если вы не можете получить доступ к поставщику двухэтапного входа, одноразовый код восстановления можно использовать для отключения двухэтапного входа. Сохраните код в надежном месте.",
|
||||
"txt_your_two_step_recovery_code": "Ваш код восстановления двухэтапного входа Bitwarden:",
|
||||
"txt_name_account_passkey_after_verification": "Ключ доступа создан. Назовите его, чтобы легче узнавать.",
|
||||
"txt_account_passkey_name_help": "0 / не более 50 символов",
|
||||
"txt_page_not_found": "Страница не найдена",
|
||||
"txt_page_not_found_hint": "Страница могла быть удалена, срок ее действия истек, или ссылка неполная.",
|
||||
"txt_back_to_home": "На главную",
|
||||
@@ -637,6 +688,35 @@ const ru: Record<string, string> = {
|
||||
"txt_last_name": "Фамилия",
|
||||
"txt_last_seen": "Последний визит",
|
||||
"txt_license_number": "Номер лицензии",
|
||||
"txt_bank_account": "Банковский счет",
|
||||
"txt_bank_account_details": "Данные банковского счета",
|
||||
"txt_bank_name": "Название банка",
|
||||
"txt_name_on_account": "Имя владельца счета",
|
||||
"txt_account_type": "Тип счета",
|
||||
"txt_account_number": "Номер счета",
|
||||
"txt_routing_number": "Маршрутный номер",
|
||||
"txt_branch_number": "Номер отделения",
|
||||
"txt_pin": "PIN",
|
||||
"txt_swift_code": "SWIFT-код",
|
||||
"txt_iban": "IBAN",
|
||||
"txt_bank_contact_phone": "Телефон банка",
|
||||
"txt_drivers_license": "Водительское удостоверение",
|
||||
"txt_drivers_license_details": "Данные водительского удостоверения",
|
||||
"txt_date_of_birth": "Дата рождения",
|
||||
"txt_issuing_country": "Страна выдачи",
|
||||
"txt_issuing_state": "Регион выдачи",
|
||||
"txt_issue_date": "Дата выдачи",
|
||||
"txt_issuing_authority": "Орган выдачи",
|
||||
"txt_license_class": "Категория",
|
||||
"txt_passport": "Паспорт",
|
||||
"txt_passport_details": "Данные паспорта",
|
||||
"txt_surname": "Фамилия",
|
||||
"txt_given_name": "Имя",
|
||||
"txt_sex": "Пол",
|
||||
"txt_birth_place": "Место рождения",
|
||||
"txt_nationality": "Гражданство",
|
||||
"txt_passport_type": "Тип паспорта",
|
||||
"txt_national_id_number": "Национальный ID",
|
||||
"txt_link_copied": "Ссылка скопирована",
|
||||
"txt_linked": "Связано",
|
||||
"txt_linux_desktop": "Рабочий стол Linux",
|
||||
@@ -669,7 +749,7 @@ const ru: Record<string, string> = {
|
||||
"txt_jwt_secret_value_label": "Значение:",
|
||||
"txt_jwt_secret_value_requirement": "Случайная строка, содержащая не менее {min} символов.",
|
||||
"txt_jwt_what_is": "Что такое JWT?",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET — это ключ подписи на стороне сервера, используемый для выдачи и проверки токенов входа. Если он отсутствует, слишком короткий или все еще использует образец значения, обычное использование экземпляра небезопасно.",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET — это ключ подписи на стороне сервера, используемый для выдачи и проверки токенов входа. Если он отсутствует или слишком короткий, обычное использование экземпляра небезопасно.",
|
||||
"txt_how_to_fix": "Как исправить",
|
||||
"txt_jwt_fix_step_1": "Откройте переменные среды развертывания.",
|
||||
"txt_jwt_fix_step_2": "Если ваш текущий ключ недостаточно случайный, используйте 32-значный генератор ниже.",
|
||||
@@ -755,6 +835,23 @@ const ru: Record<string, string> = {
|
||||
"txt_password_hint_too_long": "Подсказка к паролю должна содержать не более 120 символов.",
|
||||
"txt_passkey": "Ключ доступа",
|
||||
"txt_passkeys": "Ключи доступа",
|
||||
"txt_register": "Зарегистрировать",
|
||||
"txt_key_list": "Список ключей",
|
||||
"txt_select_another_verification_method": "Выбрать другой способ проверки",
|
||||
"txt_select_two_step_login_method": "Выберите способ двухэтапного входа",
|
||||
"txt_two_step_passkeys": "Двухэтапный вход с ключом доступа",
|
||||
"txt_two_step_passkeys_help": "Управление ключами доступа, которые используются только для двухэтапного входа.",
|
||||
"txt_two_step_passkey_name_placeholder": "Ключ безопасности",
|
||||
"txt_add_two_step_passkey": "Добавить ключ доступа",
|
||||
"txt_two_step_passkey_added": "Двухэтапный вход с ключом доступа обновлен",
|
||||
"txt_two_step_passkey_removed": "Ключ доступа удален",
|
||||
"txt_two_step_passkeys_disabled": "Двухэтапный вход с ключом доступа отключен",
|
||||
"txt_disable_passkey_two_step_failed": "Не удалось отключить двухэтапный вход с ключом доступа",
|
||||
"txt_use_passkey_to_complete_two_step_verification": "Используйте ключ доступа, чтобы завершить двухэтапную проверку.",
|
||||
"txt_touch_your_passkey_when_prompted": "Продолжите и подтвердите запрос ключа доступа в браузере.",
|
||||
"txt_no_two_step_passkeys": "Нет ключей доступа для двухэтапного входа",
|
||||
"txt_remove_last_passkey_hint": "Отключите двухэтапный вход с ключом доступа, чтобы удалить последний ключ.",
|
||||
"txt_passkey_setup_failed": "Не удалось настроить ключ доступа",
|
||||
"txt_passkey_created_at_value": "Создано {value}",
|
||||
"txt_account_passkey": "Ключ доступа аккаунта",
|
||||
"txt_account_passkeys": "Ключи доступа аккаунта",
|
||||
@@ -838,6 +935,8 @@ const ru: Record<string, string> = {
|
||||
"txt_scope": "Область доступа",
|
||||
"txt_grant_type": "Тип авторизации",
|
||||
"txt_refresh": "Обновить",
|
||||
"txt_refresh_status": "Обновить статус",
|
||||
"txt_load_failed": "Не удалось загрузить",
|
||||
"txt_refresh_in_seconds_s": "Обновить через {seconds} с.",
|
||||
"txt_regenerate": "Регенерировать",
|
||||
"txt_registration_succeeded_please_sign_in": "Регистрация прошла успешно. Пожалуйста, войдите в систему.",
|
||||
@@ -944,6 +1043,8 @@ const ru: Record<string, string> = {
|
||||
"txt_totp_qr_scanned": "Значение TOTP добавлено.",
|
||||
"txt_totp_qr_not_found": "QR-код на этом изображении не найден.",
|
||||
"txt_totp_qr_scan_failed": "Не удалось отсканировать QR-код.",
|
||||
"txt_totp_qr_invalid_image_type": "Выберите файл изображения.",
|
||||
"txt_totp_qr_image_too_large": "Выберите изображение меньше 8 МБ.",
|
||||
"txt_totp_qr_unsupported": "Этот браузер не поддерживает сканирование QR. Попробуйте Chrome или Edge либо вставьте ссылку или секрет TOTP вручную.",
|
||||
"txt_totp_qr_camera_unavailable": "Камера недоступна. Проверьте разрешение браузера или выберите изображение.",
|
||||
"txt_totp_qr_choose_image": "Выбрать изображение",
|
||||
@@ -1055,6 +1156,12 @@ const ru: Record<string, string> = {
|
||||
"txt_import_invalid_password_protected_file": "Неверный файл экспорта, защищенный паролем.",
|
||||
"txt_import_decrypt_failed": "Не удалось расшифровать файл импорта.",
|
||||
"txt_import_empty_zip_archive": "Пустой zip-архив.",
|
||||
"txt_import_zip_too_large": "ZIP-архив слишком большой. Максимальный размер: {size} MiB.",
|
||||
"txt_import_file_too_large": "Файл импорта слишком большой. Максимальный размер: {size} MiB.",
|
||||
"txt_import_zip_too_many_files": "ZIP-архив содержит слишком много файлов.",
|
||||
"txt_import_zip_entry_too_large": "ZIP-архив содержит файл больше {size} MiB.",
|
||||
"txt_import_zip_expands_too_large": "ZIP-архив распаковывается за текущий лимит импорта {size} MiB.",
|
||||
"txt_import_zip_unsafe_file_name": "ZIP-архив содержит небезопасное имя файла.",
|
||||
"txt_import_no_json_found_in_zip": "В zip-архиве не найдены импортируемые данные JSON.",
|
||||
"txt_import_data_json_not_found": "data.json не найден в zip-архиве.",
|
||||
"txt_import_zip_password_required": "Требуется пароль ZIP.",
|
||||
@@ -1146,133 +1253,144 @@ const ru: Record<string, string> = {
|
||||
"txt_log_level_info": "Инфо",
|
||||
"txt_log_level_security": "Безопасность",
|
||||
"txt_log_level_warn": "Предупреждение",
|
||||
"txt_log_action_account_api_key_create": "Create API key",
|
||||
"txt_log_action_account_api_key_rotate": "Rotate API key",
|
||||
"txt_log_action_account_keys_update": "Update account keys",
|
||||
"txt_log_action_account_profile_update": "Update account profile",
|
||||
"txt_log_action_account_totp_disable": "Disable two-step login",
|
||||
"txt_log_action_account_totp_enable": "Enable two-step login",
|
||||
"txt_log_action_account_totp_recover": "Recover two-step login",
|
||||
"txt_log_action_account_verify_devices_update": "Update device verification",
|
||||
"txt_log_action_admin_audit_settings_update": "Update log retention settings",
|
||||
"txt_log_action_admin_backup_export": "Export backup",
|
||||
"txt_log_action_admin_backup_import": "Import backup",
|
||||
"txt_log_action_admin_backup_remote_delete": "Delete remote backup",
|
||||
"txt_log_action_admin_backup_remote_manual": "Manual remote backup succeeded",
|
||||
"txt_log_action_admin_backup_remote_manual_failed": "Manual remote backup failed",
|
||||
"txt_log_action_admin_backup_remote_scheduled": "Scheduled remote backup succeeded",
|
||||
"txt_log_action_admin_backup_remote_scheduled_failed": "Scheduled remote backup failed",
|
||||
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
|
||||
"txt_log_action_admin_backup_settings_update": "Update backup settings",
|
||||
"txt_log_action_admin_invite_create": "Create invite",
|
||||
"txt_log_action_admin_invite_delete": "Delete invite",
|
||||
"txt_log_action_admin_invite_delete_all": "Clear invites",
|
||||
"txt_log_action_admin_invite_delete_invalid": "Delete invalid invites",
|
||||
"txt_log_action_admin_invite_revoke": "Revoke invite",
|
||||
"txt_log_action_admin_user_delete": "Delete user",
|
||||
"txt_log_action_admin_user_status": "Change user status",
|
||||
"txt_log_action_attachment_delete": "Delete attachment",
|
||||
"txt_log_action_auth_login_failed_bad_api_key": "Login failed: bad API key",
|
||||
"txt_log_action_auth_login_failed_bad_password": "Login failed: bad password",
|
||||
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account",
|
||||
"txt_log_action_auth_login_success": "Login succeeded",
|
||||
"txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}",
|
||||
"txt_log_action_cipher_delete_permanent": "Permanently delete vault item",
|
||||
"txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items",
|
||||
"txt_log_action_cipher_delete_soft": "Move vault item to trash",
|
||||
"txt_log_action_cipher_delete_soft_bulk": "Move vault items to trash",
|
||||
"txt_log_action_device_deactivate": "Deactivate device",
|
||||
"txt_log_action_device_delete": "Delete device",
|
||||
"txt_log_action_device_delete_all": "Delete all devices",
|
||||
"txt_log_action_device_name_update": "Update device name",
|
||||
"txt_log_action_device_trust_permanent": "Trust device permanently",
|
||||
"txt_log_action_device_trust_revoke": "Revoke device trust",
|
||||
"txt_log_action_device_trust_revoke_batch": "Revoke device trust in bulk",
|
||||
"txt_log_action_folder_delete": "Delete folder",
|
||||
"txt_log_action_folder_delete_bulk": "Delete folders",
|
||||
"txt_log_action_send_auth_remove": "Remove Send authentication",
|
||||
"txt_log_action_send_delete": "Delete Send",
|
||||
"txt_log_action_send_delete_bulk": "Delete Sends",
|
||||
"txt_log_action_send_password_remove": "Remove Send password",
|
||||
"txt_log_action_user_password_change": "Change master password",
|
||||
"txt_log_action_user_register_first_admin": "Register first admin",
|
||||
"txt_log_action_user_register_invite": "Register by invite",
|
||||
"txt_log_meta_attachments": "Attachments",
|
||||
"txt_log_action_account_api_key_create": "Создание API-ключа",
|
||||
"txt_log_action_account_api_key_rotate": "Ротация API-ключа",
|
||||
"txt_log_action_account_keys_update": "Обновление ключей учетной записи",
|
||||
"txt_log_action_account_passkey_create": "Создание ключа входа",
|
||||
"txt_log_action_account_passkey_delete": "Удаление ключа входа",
|
||||
"txt_log_action_account_passkey_encryption_enable": "Включение разблокировки хранилища ключом доступа",
|
||||
"txt_log_action_account_profile_update": "Обновление профиля учетной записи",
|
||||
"txt_log_action_account_totp_disable": "Отключение двухфакторной проверки",
|
||||
"txt_log_action_account_totp_enable": "Включение двухфакторной проверки",
|
||||
"txt_log_action_account_totp_recover": "Восстановление двухфакторной проверки",
|
||||
"txt_log_action_account_verify_devices_update": "Обновление проверки устройств",
|
||||
"txt_log_action_account_webauthn_2fa_delete": "Удаление ключа двухфакторной проверки",
|
||||
"txt_log_action_account_webauthn_2fa_enable": "Включение двухфакторной проверки ключом доступа",
|
||||
"txt_log_action_account_yubikey_enable": "Обновление настроек YubiKey OTP",
|
||||
"txt_log_action_admin_audit_clear": "Очистка журнала аудита",
|
||||
"txt_log_action_admin_audit_settings_update": "Обновление хранения журналов",
|
||||
"txt_log_action_admin_backup_export": "Экспорт резервной копии",
|
||||
"txt_log_action_admin_backup_import": "Импорт резервной копии",
|
||||
"txt_log_action_admin_backup_remote_delete": "Удаление удаленной резервной копии",
|
||||
"txt_log_action_admin_backup_remote_manual": "Ручное удаленное резервное копирование выполнено",
|
||||
"txt_log_action_admin_backup_remote_manual_failed": "Ошибка ручного удаленного резервного копирования",
|
||||
"txt_log_action_admin_backup_remote_scheduled": "Запланированное удаленное резервное копирование выполнено",
|
||||
"txt_log_action_admin_backup_remote_scheduled_failed": "Ошибка запланированного удаленного резервного копирования",
|
||||
"txt_log_action_admin_backup_settings_repair": "Восстановление настроек резервного копирования",
|
||||
"txt_log_action_admin_backup_settings_update": "Обновление настроек резервного копирования",
|
||||
"txt_log_action_admin_invite_create": "Создание приглашения",
|
||||
"txt_log_action_admin_invite_delete": "Удаление приглашения",
|
||||
"txt_log_action_admin_invite_delete_all": "Очистка приглашений",
|
||||
"txt_log_action_admin_invite_delete_invalid": "Удаление недействительных приглашений",
|
||||
"txt_log_action_admin_invite_revoke": "Отзыв приглашения",
|
||||
"txt_log_action_admin_user_delete": "Удаление пользователя",
|
||||
"txt_log_action_admin_user_status": "Изменение статуса пользователя",
|
||||
"txt_log_action_attachment_delete": "Удаление вложения",
|
||||
"txt_log_action_auth_login_failed_bad_api_key": "Ошибка входа: неверный API-ключ",
|
||||
"txt_log_action_auth_login_failed_bad_password": "Ошибка входа: неверный пароль",
|
||||
"txt_log_action_auth_login_failed_user_inactive": "Ошибка входа: учетная запись неактивна",
|
||||
"txt_log_action_auth_login_success": "Вход выполнен",
|
||||
"txt_log_action_auth_passkey_login_failed": "Ошибка входа по ключу доступа",
|
||||
"txt_log_action_auth_passkey_login_success": "Вход по ключу доступа выполнен",
|
||||
"txt_log_action_auth_refresh_failed": "Не удалось обновить вход: {reason}",
|
||||
"txt_log_action_cipher_delete_permanent": "Окончательное удаление элемента хранилища",
|
||||
"txt_log_action_cipher_delete_permanent_bulk": "Окончательное удаление элементов хранилища",
|
||||
"txt_log_action_cipher_delete_soft": "Перемещение элемента хранилища в корзину",
|
||||
"txt_log_action_cipher_delete_soft_bulk": "Перемещение элементов хранилища в корзину",
|
||||
"txt_log_action_device_deactivate": "Деактивация устройства",
|
||||
"txt_log_action_device_delete": "Удаление устройства",
|
||||
"txt_log_action_device_delete_all": "Удаление всех устройств",
|
||||
"txt_log_action_device_name_update": "Обновление имени устройства",
|
||||
"txt_log_action_device_trust_permanent": "Постоянное доверие устройству",
|
||||
"txt_log_action_device_trust_revoke": "Отзыв доверия устройству",
|
||||
"txt_log_action_device_trust_revoke_batch": "Массовый отзыв доверия устройствам",
|
||||
"txt_log_action_folder_delete": "Удаление папки",
|
||||
"txt_log_action_folder_delete_bulk": "Удаление папок",
|
||||
"txt_log_action_send_auth_remove": "Удаление проверки Send",
|
||||
"txt_log_action_send_delete": "Удаление Send",
|
||||
"txt_log_action_send_delete_bulk": "Удаление Send",
|
||||
"txt_log_action_send_password_remove": "Удаление пароля Send",
|
||||
"txt_log_action_user_password_change": "Изменение мастер-пароля",
|
||||
"txt_log_action_user_register_first_admin": "Регистрация первого администратора",
|
||||
"txt_log_action_user_register_invite": "Регистрация по приглашению",
|
||||
"txt_log_meta_attachments": "Вложения",
|
||||
"txt_log_meta_bytes": "Bytes",
|
||||
"txt_log_meta_changed": "Changed fields",
|
||||
"txt_log_meta_checksum_mismatch_accepted": "Accepted checksum mismatch",
|
||||
"txt_log_meta_cipher_id": "Vault item ID",
|
||||
"txt_log_meta_ciphers": "Vault items",
|
||||
"txt_log_meta_compat": "Compatibility",
|
||||
"txt_log_meta_compressed_bytes": "Compressed bytes",
|
||||
"txt_log_meta_count": "Count",
|
||||
"txt_log_meta_deleted": "Deleted count",
|
||||
"txt_log_meta_destination_count": "Destination count",
|
||||
"txt_log_meta_destination_id": "Destination ID",
|
||||
"txt_log_meta_destination_name": "Destination name",
|
||||
"txt_log_meta_destination_type": "Destination type",
|
||||
"txt_log_meta_device_identifier": "Device ID",
|
||||
"txt_log_meta_device_type": "Device type",
|
||||
"txt_log_meta_changed": "Измененные поля",
|
||||
"txt_log_meta_checksum_mismatch_accepted": "Принято несовпадение контрольной суммы",
|
||||
"txt_log_meta_cipher_id": "ID элемента хранилища",
|
||||
"txt_log_meta_ciphers": "Элементы хранилища",
|
||||
"txt_log_meta_compat": "Совместимость",
|
||||
"txt_log_meta_compressed_bytes": "Байт после сжатия",
|
||||
"txt_log_meta_count": "Количество",
|
||||
"txt_log_meta_deleted": "Удалено",
|
||||
"txt_log_meta_destination_count": "Количество назначений",
|
||||
"txt_log_meta_destination_id": "ID назначения",
|
||||
"txt_log_meta_destination_name": "Имя назначения",
|
||||
"txt_log_meta_destination_type": "Тип назначения",
|
||||
"txt_log_meta_device_identifier": "ID устройства",
|
||||
"txt_log_meta_device_type": "Тип устройства",
|
||||
"txt_log_meta_email": "Email",
|
||||
"txt_log_meta_error": "Error",
|
||||
"txt_log_meta_expires_in_hours": "Expires in hours",
|
||||
"txt_log_meta_file_bytes": "File bytes",
|
||||
"txt_log_meta_file_name": "File name",
|
||||
"txt_log_meta_folder_id": "Folder ID",
|
||||
"txt_log_meta_grant_type": "Login method",
|
||||
"txt_log_meta_includes_attachments": "Includes attachments",
|
||||
"txt_log_meta_ip": "IP address",
|
||||
"txt_log_meta_max_entries": "Entry limit",
|
||||
"txt_log_meta_method": "Request method",
|
||||
"txt_log_meta_path": "Request path",
|
||||
"txt_log_meta_provider": "Provider",
|
||||
"txt_log_meta_prune_error": "Cleanup error",
|
||||
"txt_log_meta_pruned_file_count": "Cleaned files",
|
||||
"txt_log_meta_raw": "Raw data",
|
||||
"txt_log_meta_reason": "Reason",
|
||||
"txt_log_meta_remote_path": "Remote path",
|
||||
"txt_log_meta_removed": "Removed count",
|
||||
"txt_log_meta_removed_devices": "Removed devices",
|
||||
"txt_log_meta_removed_sessions": "Removed sessions",
|
||||
"txt_log_meta_removed_trusted": "Trust removals",
|
||||
"txt_log_meta_replace_existing": "Replace existing data",
|
||||
"txt_log_meta_requested": "Requested count",
|
||||
"txt_log_meta_requested_count": "Requested count",
|
||||
"txt_log_meta_retention_days": "Retention days",
|
||||
"txt_log_meta_scheduled_destination_count": "Scheduled destinations",
|
||||
"txt_log_meta_size": "Size",
|
||||
"txt_log_meta_skipped_attachments": "Skipped attachments",
|
||||
"txt_log_meta_skipped_reason": "Skip reason",
|
||||
"txt_log_meta_status": "Status",
|
||||
"txt_log_meta_target_email": "Target email",
|
||||
"txt_log_meta_trigger": "Trigger",
|
||||
"txt_log_meta_type": "Type",
|
||||
"txt_log_meta_updated": "Updated count",
|
||||
"txt_log_meta_upload_verification_attempts": "Upload verification attempts",
|
||||
"txt_log_meta_user_agent": "Browser/client",
|
||||
"txt_log_meta_users": "Users",
|
||||
"txt_log_meta_verify_devices": "Verify devices",
|
||||
"txt_log_meta_web_session": "Web session",
|
||||
"txt_log_reason_bad_api_key": "Bad API key",
|
||||
"txt_log_reason_bad_password": "Bad password",
|
||||
"txt_log_reason_device_missing": "Device missing",
|
||||
"txt_log_reason_device_session_mismatch": "Device session mismatch",
|
||||
"txt_log_reason_token_not_found_or_expired": "Token missing or expired",
|
||||
"txt_log_reason_user_inactive": "User inactive",
|
||||
"txt_log_reason_user_missing": "User missing",
|
||||
"txt_log_target_type_attachment": "Attachment",
|
||||
"txt_log_target_type_audit_log": "Log",
|
||||
"txt_log_target_type_backup": "Backup",
|
||||
"txt_log_target_type_cipher": "Vault item",
|
||||
"txt_log_target_type_device": "Device",
|
||||
"txt_log_target_type_folder": "Folder",
|
||||
"txt_log_target_type_invite": "Invite",
|
||||
"txt_log_target_type_refresh_token": "Refresh token",
|
||||
"txt_log_meta_error": "Ошибка",
|
||||
"txt_log_meta_expires_in_hours": "Истекает через часов",
|
||||
"txt_log_meta_file_bytes": "Байт файла",
|
||||
"txt_log_meta_file_name": "Имя файла",
|
||||
"txt_log_meta_folder_id": "ID папки",
|
||||
"txt_log_meta_grant_type": "Способ входа",
|
||||
"txt_log_meta_includes_attachments": "Включает вложения",
|
||||
"txt_log_meta_ip": "IP-адрес",
|
||||
"txt_log_meta_max_entries": "Лимит записей",
|
||||
"txt_log_meta_method": "Метод запроса",
|
||||
"txt_log_meta_path": "Путь запроса",
|
||||
"txt_log_meta_provider": "Поставщик",
|
||||
"txt_log_meta_prf_status": "Статус PRF",
|
||||
"txt_log_meta_prune_error": "Ошибка очистки",
|
||||
"txt_log_meta_pruned_file_count": "Очищено файлов",
|
||||
"txt_log_meta_raw": "Исходные данные",
|
||||
"txt_log_meta_reason": "Причина",
|
||||
"txt_log_meta_remote_path": "Удаленный путь",
|
||||
"txt_log_meta_removed": "Удалено",
|
||||
"txt_log_meta_removed_devices": "Удалено устройств",
|
||||
"txt_log_meta_removed_sessions": "Удалено сессий",
|
||||
"txt_log_meta_removed_trusted": "Отозвано доверий",
|
||||
"txt_log_meta_replace_existing": "Заменить существующие данные",
|
||||
"txt_log_meta_requested": "Запрошено",
|
||||
"txt_log_meta_requested_count": "Запрошено",
|
||||
"txt_log_meta_retention_days": "Дней хранения",
|
||||
"txt_log_meta_scheduled_destination_count": "Запланированные назначения",
|
||||
"txt_log_meta_size": "Размер",
|
||||
"txt_log_meta_skipped_attachments": "Пропущенные вложения",
|
||||
"txt_log_meta_skipped_reason": "Причина пропуска",
|
||||
"txt_log_meta_status": "Статус",
|
||||
"txt_log_meta_target_email": "Email цели",
|
||||
"txt_log_meta_trigger": "Триггер",
|
||||
"txt_log_meta_type": "Тип",
|
||||
"txt_log_meta_updated": "Обновлено",
|
||||
"txt_log_meta_upload_verification_attempts": "Попытки проверки загрузки",
|
||||
"txt_log_meta_user_agent": "Браузер/клиент",
|
||||
"txt_log_meta_users": "Пользователи",
|
||||
"txt_log_meta_verify_devices": "Проверка устройств",
|
||||
"txt_log_meta_web_session": "Веб-сессия",
|
||||
"txt_log_reason_bad_api_key": "Неверный API-ключ",
|
||||
"txt_log_reason_bad_password": "Неверный пароль",
|
||||
"txt_log_reason_device_missing": "Устройство не найдено",
|
||||
"txt_log_reason_device_session_mismatch": "Сессия не соответствует устройству",
|
||||
"txt_log_reason_token_not_found_or_expired": "Токен отсутствует или истек",
|
||||
"txt_log_reason_user_inactive": "Пользователь неактивен",
|
||||
"txt_log_reason_user_missing": "Пользователь не найден",
|
||||
"txt_log_target_type_attachment": "Вложение",
|
||||
"txt_log_target_type_account_passkey": "Ключ входа",
|
||||
"txt_log_target_type_audit_log": "Журнал",
|
||||
"txt_log_target_type_backup": "Резервная копия",
|
||||
"txt_log_target_type_cipher": "Элемент хранилища",
|
||||
"txt_log_target_type_device": "Устройство",
|
||||
"txt_log_target_type_folder": "Папка",
|
||||
"txt_log_target_type_invite": "Приглашение",
|
||||
"txt_log_target_type_refresh_token": "Токен обновления",
|
||||
"txt_log_target_type_send": "Send",
|
||||
"txt_log_target_type_user": "User",
|
||||
"txt_log_trigger_manual": "Manual",
|
||||
"txt_log_trigger_remote": "Remote",
|
||||
"txt_log_trigger_scheduled": "Scheduled",
|
||||
"txt_log_target_type_user": "Пользователь",
|
||||
"txt_log_trigger_manual": "Вручную",
|
||||
"txt_log_trigger_remote": "Удаленно",
|
||||
"txt_log_trigger_scheduled": "По расписанию",
|
||||
"txt_log_max_1000": "До 1 000 записей",
|
||||
"txt_log_max_5000": "До 5 000 записей",
|
||||
"txt_log_max_10000": "До 10 000 записей",
|
||||
|
||||
@@ -11,6 +11,57 @@ const zhCN: Record<string, string> = {
|
||||
"nav_import_export": "导入导出",
|
||||
"nav_group_data_backup": "数据与备份",
|
||||
"nav_group_management": "管理",
|
||||
"txt_settings_appearance": "外观",
|
||||
"txt_theme": "主题",
|
||||
"txt_use_system_theme": "使用系统主题",
|
||||
"txt_light_theme": "浅色",
|
||||
"txt_dark_theme": "深色",
|
||||
"txt_theme_saved_locally": "为您的网页密码库选择一个主题。",
|
||||
"txt_display_language_help": "更改网页密码库的语言。",
|
||||
"txt_two_step_login": "两步登录",
|
||||
"txt_keys": "密钥",
|
||||
"txt_manage": "管理",
|
||||
"txt_providers": "提供程序",
|
||||
"txt_authenticator_app": "验证器 App",
|
||||
"txt_authenticator_app_help": "输入验证器 App 生成的代码。",
|
||||
"txt_passkey_provider_help": "使用兼容 FIDO2 的安全密钥或生物识别验证器。",
|
||||
"txt_yubico_otp_security_key": "Yubico OTP 安全密钥",
|
||||
"txt_yubico_otp_security_key_help": "使用 YubiKey 4、5 或 NEO 设备。",
|
||||
"txt_yubikey_setup_intro": "将 YubiKey 插入计算机的 USB 端口。在下面选择第一个空的 YubiKey 输入字段。触摸 YubiKey 的按钮、保存。",
|
||||
"txt_yubikey_plug_in": "将 YubiKey 插入计算机的 USB 端口",
|
||||
"txt_yubikey_select_empty_field": "在下面选择第一个空的 YubiKey 输入字段",
|
||||
"txt_yubikey_touch_button": "触摸 YubiKey 的按钮、保存",
|
||||
"txt_yubikey_save_form": "保存",
|
||||
"txt_yubikey_x": "YubiKey {index}",
|
||||
"txt_nfc_support": "NFC 支持",
|
||||
"txt_yubikey_supports_nfc": "我的某个密钥支持 NFC",
|
||||
"txt_yubikey_supports_nfc_desc": "",
|
||||
"txt_disable_all_keys": "停用全部密钥",
|
||||
"txt_yubikeys_updated": "YubiKey 已更新",
|
||||
"txt_yubikey_update_failed": "更新 YubiKey 失败",
|
||||
"txt_disable_yubikey_failed": "停用 YubiKey 失败",
|
||||
"txt_yubikey_disabled": "YubiKey 已停用",
|
||||
"txt_yubikey_enabled": "YubiKey 已启用。",
|
||||
"txt_yubikey_config_required": "尚未配置 Yubico 验证",
|
||||
"txt_yubikey_config_required_help": "请先输入一次 YubiKey OTP。NodeWarden 会自动获取并保存实例级 Client ID 和 Secret key,成功后再进入 YubiKey 设置表单。",
|
||||
"txt_otp_from_yubikey": "来自 YubiKey 的 OTP",
|
||||
"txt_please_input_yubikey_otp": "请输入 YubiKey OTP",
|
||||
"txt_yubikey_verify_failed": "YubiKey 验证失败",
|
||||
"txt_press_yubikey_to_authenticate": "按下 YubiKey 进行验证。",
|
||||
"txt_yubikey_auto_configure": "自动获取并保存",
|
||||
"txt_yubikey_validation_credentials": "Yubico 验证凭据",
|
||||
"txt_view": "查看",
|
||||
"txt_yubikey_config_updated": "Yubico 验证凭据已更新",
|
||||
"txt_yubikey_config_update_failed": "更新 Yubico 验证凭据失败",
|
||||
"txt_yubikey_auto_config_failed": "获取 Yubico 验证凭据失败",
|
||||
"txt_yubikey_reconfigure_help": "输入一个新的 OTP,可以重新自动获取并替换当前凭据。",
|
||||
"txt_yubikey_auto_configure_again": "重新自动获取",
|
||||
"txt_setting_coming_soon": "即将推出。",
|
||||
"txt_totp_manage_intro": "扫描二维码或在验证器 App 中输入密钥,然后输入验证码。",
|
||||
"txt_two_step_recovery_code_warning": "当您无法访问两步登录提供程序时,您的一次性恢复代码可用于停用两步登录。请将其妥善保管。",
|
||||
"txt_your_two_step_recovery_code": "您的 Bitwarden 两步登录恢复代码:",
|
||||
"txt_name_account_passkey_after_verification": "通行密钥创建成功!为您的通行密钥命名以帮助您识别它。",
|
||||
"txt_account_passkey_name_help": "0 / 最多 50 个字符",
|
||||
"txt_page_not_found": "页面不存在",
|
||||
"txt_page_not_found_hint": "这个页面可能已经删除、过期,或者链接不完整。",
|
||||
"txt_back_to_home": "回到首页",
|
||||
@@ -637,6 +688,35 @@ const zhCN: Record<string, string> = {
|
||||
"txt_last_name": "姓",
|
||||
"txt_last_seen": "最后在线",
|
||||
"txt_license_number": "证件号",
|
||||
"txt_bank_account": "银行账户",
|
||||
"txt_bank_account_details": "银行账户详情",
|
||||
"txt_bank_name": "银行名称",
|
||||
"txt_name_on_account": "账户姓名",
|
||||
"txt_account_type": "账户类型",
|
||||
"txt_account_number": "账户号码",
|
||||
"txt_routing_number": "路由号码",
|
||||
"txt_branch_number": "分行号码",
|
||||
"txt_pin": "PIN",
|
||||
"txt_swift_code": "SWIFT 代码",
|
||||
"txt_iban": "IBAN",
|
||||
"txt_bank_contact_phone": "银行联系电话",
|
||||
"txt_drivers_license": "驾照",
|
||||
"txt_drivers_license_details": "驾照详情",
|
||||
"txt_date_of_birth": "出生日期",
|
||||
"txt_issuing_country": "签发国家/地区",
|
||||
"txt_issuing_state": "签发州/省",
|
||||
"txt_issue_date": "签发日期",
|
||||
"txt_issuing_authority": "签发机构",
|
||||
"txt_license_class": "驾照等级",
|
||||
"txt_passport": "护照",
|
||||
"txt_passport_details": "护照详情",
|
||||
"txt_surname": "姓",
|
||||
"txt_given_name": "名",
|
||||
"txt_sex": "性别",
|
||||
"txt_birth_place": "出生地",
|
||||
"txt_nationality": "国籍",
|
||||
"txt_passport_type": "护照类型",
|
||||
"txt_national_id_number": "国家身份证号",
|
||||
"txt_link_copied": "链接已复制",
|
||||
"txt_linked": "已关联",
|
||||
"txt_linux_desktop": "Linux 桌面端",
|
||||
@@ -669,7 +749,7 @@ const zhCN: Record<string, string> = {
|
||||
"txt_jwt_secret_value_label": "值:",
|
||||
"txt_jwt_secret_value_requirement": "最低 {min} 位随机字符",
|
||||
"txt_jwt_what_is": "JWT 是什么",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET 是服务端用来签发和校验登录令牌的密钥。如果它缺失、过短,或者仍然使用示例值,实例就不能安全地正常使用。",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET 是服务端用来签发和校验登录令牌的密钥。如果它缺失或过短,实例就不能安全地正常使用。",
|
||||
"txt_how_to_fix": "处理步骤(添加 / 更换)",
|
||||
"txt_jwt_fix_step_1": "你可以继续下一步,不影响使用。",
|
||||
"txt_jwt_fix_step_2": "如果当前密钥不是强随机值,建议使用下方 32 位生成器。",
|
||||
@@ -755,6 +835,23 @@ const zhCN: Record<string, string> = {
|
||||
"txt_password_hint_too_long": "密码提示最多只能输入 120 个字符",
|
||||
"txt_passkey": "通行密钥",
|
||||
"txt_passkeys": "通行密钥",
|
||||
"txt_register": "注册",
|
||||
"txt_key_list": "密钥列表",
|
||||
"txt_select_another_verification_method": "选择其他验证方式",
|
||||
"txt_select_two_step_login_method": "选择验证方式",
|
||||
"txt_two_step_passkeys": "通行密钥二步登录",
|
||||
"txt_two_step_passkeys_help": "管理仅用于二步登录的通行密钥。",
|
||||
"txt_two_step_passkey_name_placeholder": "安全密钥",
|
||||
"txt_add_two_step_passkey": "添加通行密钥",
|
||||
"txt_two_step_passkey_added": "通行密钥二步登录已更新",
|
||||
"txt_two_step_passkey_removed": "通行密钥已移除",
|
||||
"txt_two_step_passkeys_disabled": "通行密钥二步登录已禁用",
|
||||
"txt_disable_passkey_two_step_failed": "禁用通行密钥二步登录失败",
|
||||
"txt_use_passkey_to_complete_two_step_verification": "使用通行密钥完成二步验证。",
|
||||
"txt_touch_your_passkey_when_prompted": "继续并在浏览器提示中批准通行密钥验证。",
|
||||
"txt_no_two_step_passkeys": "暂无二步登录通行密钥",
|
||||
"txt_remove_last_passkey_hint": "请禁用通行密钥二步登录来移除最后一把密钥。",
|
||||
"txt_passkey_setup_failed": "通行密钥设置失败",
|
||||
"txt_passkey_created_at_value": "创建于 {value}",
|
||||
"txt_account_passkey": "账号通行密钥",
|
||||
"txt_account_passkeys": "账号通行密钥",
|
||||
@@ -838,6 +935,8 @@ const zhCN: Record<string, string> = {
|
||||
"txt_scope": "权限范围",
|
||||
"txt_grant_type": "授权类型",
|
||||
"txt_refresh": "刷新",
|
||||
"txt_refresh_status": "刷新状态",
|
||||
"txt_load_failed": "加载失败",
|
||||
"txt_refresh_in_seconds_s": "{seconds} 秒后刷新",
|
||||
"txt_regenerate": "重新生成",
|
||||
"txt_registration_succeeded_please_sign_in": "注册成功,请登录",
|
||||
@@ -944,6 +1043,8 @@ const zhCN: Record<string, string> = {
|
||||
"txt_totp_qr_scanned": "TOTP 内容已填入。",
|
||||
"txt_totp_qr_not_found": "这张图片里没有识别到二维码。",
|
||||
"txt_totp_qr_scan_failed": "二维码扫描失败。",
|
||||
"txt_totp_qr_invalid_image_type": "请选择图片文件。",
|
||||
"txt_totp_qr_image_too_large": "请选择小于 8 MB 的图片。",
|
||||
"txt_totp_qr_unsupported": "当前浏览器不支持二维码扫描。可尝试 Chrome 或 Edge,或手动粘贴 TOTP 链接/密钥。",
|
||||
"txt_totp_qr_camera_unavailable": "无法使用摄像头。请检查浏览器权限,或选择图片。",
|
||||
"txt_totp_qr_choose_image": "选择图片",
|
||||
@@ -1055,6 +1156,12 @@ const zhCN: Record<string, string> = {
|
||||
"txt_import_invalid_password_protected_file": "密码保护导出文件格式无效。",
|
||||
"txt_import_decrypt_failed": "导入文件解密失败。",
|
||||
"txt_import_empty_zip_archive": "ZIP 压缩包为空。",
|
||||
"txt_import_zip_too_large": "ZIP 压缩包过大,最大允许 {size} MiB。",
|
||||
"txt_import_file_too_large": "导入文件过大,最大允许 {size} MiB。",
|
||||
"txt_import_zip_too_many_files": "ZIP 压缩包内文件过多。",
|
||||
"txt_import_zip_entry_too_large": "ZIP 压缩包内存在超过 {size} MiB 的文件。",
|
||||
"txt_import_zip_expands_too_large": "ZIP 解压后超过当前导入限制 {size} MiB。",
|
||||
"txt_import_zip_unsafe_file_name": "ZIP 压缩包包含不安全的文件名。",
|
||||
"txt_import_no_json_found_in_zip": "ZIP 内未找到可导入的 JSON 数据。",
|
||||
"txt_import_data_json_not_found": "ZIP 内未找到 data.json。",
|
||||
"txt_import_zip_password_required": "该 ZIP 需要密码。",
|
||||
@@ -1149,11 +1256,18 @@ const zhCN: Record<string, string> = {
|
||||
"txt_log_action_account_api_key_create": "创建 API 密钥",
|
||||
"txt_log_action_account_api_key_rotate": "轮换 API 密钥",
|
||||
"txt_log_action_account_keys_update": "更新账户密钥",
|
||||
"txt_log_action_account_passkey_create": "创建登录通行密钥",
|
||||
"txt_log_action_account_passkey_delete": "删除登录通行密钥",
|
||||
"txt_log_action_account_passkey_encryption_enable": "开启通行密钥解锁密码库",
|
||||
"txt_log_action_account_profile_update": "更新账户资料",
|
||||
"txt_log_action_account_totp_disable": "关闭两步验证",
|
||||
"txt_log_action_account_totp_enable": "开启两步验证",
|
||||
"txt_log_action_account_totp_recover": "恢复两步验证",
|
||||
"txt_log_action_account_verify_devices_update": "更新设备验证设置",
|
||||
"txt_log_action_account_webauthn_2fa_delete": "删除通行密钥两步验证密钥",
|
||||
"txt_log_action_account_webauthn_2fa_enable": "开启通行密钥两步验证",
|
||||
"txt_log_action_account_yubikey_enable": "更新 YubiKey OTP 设置",
|
||||
"txt_log_action_admin_audit_clear": "清空审计日志",
|
||||
"txt_log_action_admin_audit_settings_update": "更新日志保留设置",
|
||||
"txt_log_action_admin_backup_export": "导出备份",
|
||||
"txt_log_action_admin_backup_import": "导入备份",
|
||||
@@ -1176,6 +1290,8 @@ const zhCN: Record<string, string> = {
|
||||
"txt_log_action_auth_login_failed_bad_password": "密码错误登录失败",
|
||||
"txt_log_action_auth_login_failed_user_inactive": "账号停用登录失败",
|
||||
"txt_log_action_auth_login_success": "登录成功",
|
||||
"txt_log_action_auth_passkey_login_failed": "通行密钥登录失败",
|
||||
"txt_log_action_auth_passkey_login_success": "通行密钥登录成功",
|
||||
"txt_log_action_auth_refresh_failed": "刷新登录失败:{reason}",
|
||||
"txt_log_action_cipher_delete_permanent": "永久删除密码项",
|
||||
"txt_log_action_cipher_delete_permanent_bulk": "批量永久删除密码项",
|
||||
@@ -1226,6 +1342,7 @@ const zhCN: Record<string, string> = {
|
||||
"txt_log_meta_method": "请求方法",
|
||||
"txt_log_meta_path": "请求路径",
|
||||
"txt_log_meta_provider": "服务提供方",
|
||||
"txt_log_meta_prf_status": "PRF 状态",
|
||||
"txt_log_meta_prune_error": "清理错误",
|
||||
"txt_log_meta_pruned_file_count": "已清理文件数",
|
||||
"txt_log_meta_raw": "原始数据",
|
||||
@@ -1261,6 +1378,7 @@ const zhCN: Record<string, string> = {
|
||||
"txt_log_reason_user_inactive": "用户未启用",
|
||||
"txt_log_reason_user_missing": "用户不存在",
|
||||
"txt_log_target_type_attachment": "附件",
|
||||
"txt_log_target_type_account_passkey": "登录通行密钥",
|
||||
"txt_log_target_type_audit_log": "日志",
|
||||
"txt_log_target_type_backup": "备份",
|
||||
"txt_log_target_type_cipher": "密码项",
|
||||
|
||||
@@ -11,6 +11,57 @@ const zhTW: Record<string, string> = {
|
||||
"nav_import_export": "導入導出",
|
||||
"nav_group_data_backup": "資料與備份",
|
||||
"nav_group_management": "管理",
|
||||
"txt_settings_appearance": "外觀",
|
||||
"txt_theme": "主題",
|
||||
"txt_use_system_theme": "使用系統主題",
|
||||
"txt_light_theme": "淺色",
|
||||
"txt_dark_theme": "深色",
|
||||
"txt_theme_saved_locally": "為您的網頁密碼庫選擇一個主題。",
|
||||
"txt_display_language_help": "更改網頁密碼庫的語言。",
|
||||
"txt_two_step_login": "兩步登入",
|
||||
"txt_keys": "密鑰",
|
||||
"txt_manage": "管理",
|
||||
"txt_providers": "提供程序",
|
||||
"txt_authenticator_app": "驗證器 App",
|
||||
"txt_authenticator_app_help": "輸入驗證器 App 生成的代碼。",
|
||||
"txt_passkey_provider_help": "使用兼容 FIDO2 的安全密鑰或生物識別驗證器。",
|
||||
"txt_yubico_otp_security_key": "Yubico OTP 安全密鑰",
|
||||
"txt_yubico_otp_security_key_help": "使用 YubiKey 4、5 或 NEO 裝置。",
|
||||
"txt_yubikey_setup_intro": "將 YubiKey 插入電腦的 USB 連接埠。在下方選擇第一個空的 YubiKey 輸入欄位,觸摸 YubiKey 按鈕,然後保存表單。",
|
||||
"txt_yubikey_plug_in": "將 YubiKey 插入電腦的 USB 連接埠。",
|
||||
"txt_yubikey_select_empty_field": "在下方選擇第一個空的 YubiKey 輸入欄位。",
|
||||
"txt_yubikey_touch_button": "觸摸 YubiKey 按鈕。",
|
||||
"txt_yubikey_save_form": "保存表單。",
|
||||
"txt_yubikey_x": "YubiKey {index}",
|
||||
"txt_nfc_support": "NFC 支援",
|
||||
"txt_yubikey_supports_nfc": "我的某個密鑰支援 NFC。",
|
||||
"txt_yubikey_supports_nfc_desc": "如果您的某個 YubiKey 支援 NFC,行動裝置偵測到 NFC 可用時會提示您。",
|
||||
"txt_disable_all_keys": "停用全部密鑰",
|
||||
"txt_yubikeys_updated": "YubiKey 已更新",
|
||||
"txt_yubikey_update_failed": "更新 YubiKey 失敗",
|
||||
"txt_disable_yubikey_failed": "停用 YubiKey 失敗",
|
||||
"txt_yubikey_disabled": "YubiKey 已停用",
|
||||
"txt_yubikey_enabled": "YubiKey 已啟用。",
|
||||
"txt_yubikey_config_required": "尚未配置 Yubico 驗證",
|
||||
"txt_yubikey_config_required_help": "請先輸入一次 YubiKey OTP。NodeWarden 會自動取得並保存實例級 Client ID 和 Secret key,成功後再進入 YubiKey 設定表單。",
|
||||
"txt_otp_from_yubikey": "來自 YubiKey 的 OTP",
|
||||
"txt_please_input_yubikey_otp": "請輸入 YubiKey OTP",
|
||||
"txt_yubikey_verify_failed": "YubiKey 驗證失敗",
|
||||
"txt_press_yubikey_to_authenticate": "按下 YubiKey 進行驗證。",
|
||||
"txt_yubikey_auto_configure": "自動取得並保存",
|
||||
"txt_yubikey_validation_credentials": "Yubico 驗證憑據",
|
||||
"txt_view": "查看",
|
||||
"txt_yubikey_config_updated": "Yubico 驗證憑據已更新",
|
||||
"txt_yubikey_config_update_failed": "更新 Yubico 驗證憑據失敗",
|
||||
"txt_yubikey_auto_config_failed": "取得 Yubico 驗證憑據失敗",
|
||||
"txt_yubikey_reconfigure_help": "輸入一個新的 OTP,可以重新自動取得並替換目前憑據。",
|
||||
"txt_yubikey_auto_configure_again": "重新自動取得",
|
||||
"txt_setting_coming_soon": "即將推出。",
|
||||
"txt_totp_manage_intro": "掃描二維碼或在驗證器 App 中輸入密鑰,然後輸入驗證碼。",
|
||||
"txt_two_step_recovery_code_warning": "當您無法訪問兩步登入提供程序時,您的一次性恢復代碼可用於停用兩步登入。請將其妥善保管。",
|
||||
"txt_your_two_step_recovery_code": "您的 Bitwarden 兩步登入恢復代碼:",
|
||||
"txt_name_account_passkey_after_verification": "通行密鑰創建成功!為您的通行密鑰命名以幫助您識別它。",
|
||||
"txt_account_passkey_name_help": "0 / 最多 50 個字符",
|
||||
"txt_page_not_found": "頁面不存在",
|
||||
"txt_page_not_found_hint": "這個頁面可能已經刪除、過期,或者連結不完整。",
|
||||
"txt_back_to_home": "回到首頁",
|
||||
@@ -637,6 +688,35 @@ const zhTW: Record<string, string> = {
|
||||
"txt_last_name": "姓",
|
||||
"txt_last_seen": "最後在線",
|
||||
"txt_license_number": "證件號",
|
||||
"txt_bank_account": "銀行帳戶",
|
||||
"txt_bank_account_details": "銀行帳戶詳情",
|
||||
"txt_bank_name": "銀行名稱",
|
||||
"txt_name_on_account": "帳戶姓名",
|
||||
"txt_account_type": "帳戶類型",
|
||||
"txt_account_number": "帳戶號碼",
|
||||
"txt_routing_number": "路由號碼",
|
||||
"txt_branch_number": "分行號碼",
|
||||
"txt_pin": "PIN",
|
||||
"txt_swift_code": "SWIFT 代碼",
|
||||
"txt_iban": "IBAN",
|
||||
"txt_bank_contact_phone": "銀行聯絡電話",
|
||||
"txt_drivers_license": "駕照",
|
||||
"txt_drivers_license_details": "駕照詳情",
|
||||
"txt_date_of_birth": "出生日期",
|
||||
"txt_issuing_country": "簽發國家/地區",
|
||||
"txt_issuing_state": "簽發州/省",
|
||||
"txt_issue_date": "簽發日期",
|
||||
"txt_issuing_authority": "簽發機構",
|
||||
"txt_license_class": "駕照等級",
|
||||
"txt_passport": "護照",
|
||||
"txt_passport_details": "護照詳情",
|
||||
"txt_surname": "姓",
|
||||
"txt_given_name": "名",
|
||||
"txt_sex": "性別",
|
||||
"txt_birth_place": "出生地",
|
||||
"txt_nationality": "國籍",
|
||||
"txt_passport_type": "護照類型",
|
||||
"txt_national_id_number": "國家身分證號",
|
||||
"txt_link_copied": "鏈接已複製",
|
||||
"txt_linked": "已關聯",
|
||||
"txt_linux_desktop": "Linux 桌面端",
|
||||
@@ -669,7 +749,7 @@ const zhTW: Record<string, string> = {
|
||||
"txt_jwt_secret_value_label": "值:",
|
||||
"txt_jwt_secret_value_requirement": "最低 {min} 位隨機字符",
|
||||
"txt_jwt_what_is": "JWT 是什麼",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET 是服務端用來簽發和校驗登錄令牌的密鑰。如果它缺失、過短,或者仍然使用示例值,實例就不能安全地正常使用。",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET 是服務端用來簽發和校驗登錄令牌的密鑰。如果它缺失或過短,實例就不能安全地正常使用。",
|
||||
"txt_how_to_fix": "處理步驟(添加 / 更換)",
|
||||
"txt_jwt_fix_step_1": "你可以繼續下一步,不影響使用。",
|
||||
"txt_jwt_fix_step_2": "如果當前密鑰不是強隨機值,建議使用下方 32 位生成器。",
|
||||
@@ -755,6 +835,23 @@ const zhTW: Record<string, string> = {
|
||||
"txt_password_hint_too_long": "密碼提示最多隻能輸入 120 個字符",
|
||||
"txt_passkey": "通行密鑰",
|
||||
"txt_passkeys": "通行密鑰",
|
||||
"txt_register": "註冊",
|
||||
"txt_key_list": "密鑰列表",
|
||||
"txt_select_another_verification_method": "選擇其他驗證方式",
|
||||
"txt_select_two_step_login_method": "選擇驗證方式",
|
||||
"txt_two_step_passkeys": "通行密鑰兩步登入",
|
||||
"txt_two_step_passkeys_help": "管理僅用於兩步登入的通行密鑰。",
|
||||
"txt_two_step_passkey_name_placeholder": "安全密鑰",
|
||||
"txt_add_two_step_passkey": "新增通行密鑰",
|
||||
"txt_two_step_passkey_added": "通行密鑰兩步登入已更新",
|
||||
"txt_two_step_passkey_removed": "通行密鑰已移除",
|
||||
"txt_two_step_passkeys_disabled": "通行密鑰兩步登入已停用",
|
||||
"txt_disable_passkey_two_step_failed": "停用通行密鑰兩步登入失敗",
|
||||
"txt_use_passkey_to_complete_two_step_verification": "使用通行密鑰完成兩步驗證。",
|
||||
"txt_touch_your_passkey_when_prompted": "繼續並在瀏覽器提示中批准通行密鑰驗證。",
|
||||
"txt_no_two_step_passkeys": "暫無兩步登入通行密鑰",
|
||||
"txt_remove_last_passkey_hint": "請停用通行密鑰兩步登入來移除最後一把密鑰。",
|
||||
"txt_passkey_setup_failed": "通行密鑰設置失敗",
|
||||
"txt_passkey_created_at_value": "創建於 {value}",
|
||||
"txt_account_passkey": "賬號通行密鑰",
|
||||
"txt_account_passkeys": "賬號通行密鑰",
|
||||
@@ -838,6 +935,8 @@ const zhTW: Record<string, string> = {
|
||||
"txt_scope": "權限範圍",
|
||||
"txt_grant_type": "授權類型",
|
||||
"txt_refresh": "刷新",
|
||||
"txt_refresh_status": "刷新狀態",
|
||||
"txt_load_failed": "載入失敗",
|
||||
"txt_refresh_in_seconds_s": "{seconds} 秒後刷新",
|
||||
"txt_regenerate": "重新生成",
|
||||
"txt_registration_succeeded_please_sign_in": "註冊成功,請登錄",
|
||||
@@ -944,6 +1043,8 @@ const zhTW: Record<string, string> = {
|
||||
"txt_totp_qr_scanned": "TOTP 內容已填入。",
|
||||
"txt_totp_qr_not_found": "這張圖片裡沒有識別到二維碼。",
|
||||
"txt_totp_qr_scan_failed": "二維碼掃描失敗。",
|
||||
"txt_totp_qr_invalid_image_type": "請選擇圖片檔案。",
|
||||
"txt_totp_qr_image_too_large": "請選擇小於 8 MB 的圖片。",
|
||||
"txt_totp_qr_unsupported": "目前瀏覽器不支援二維碼掃描。可嘗試 Chrome 或 Edge,或手動貼上 TOTP 連結/密鑰。",
|
||||
"txt_totp_qr_camera_unavailable": "無法使用攝影機。請檢查瀏覽器權限,或選擇圖片。",
|
||||
"txt_totp_qr_choose_image": "選擇圖片",
|
||||
@@ -1055,6 +1156,12 @@ const zhTW: Record<string, string> = {
|
||||
"txt_import_invalid_password_protected_file": "密碼保護導出文件格式無效。",
|
||||
"txt_import_decrypt_failed": "導入文件解密失敗。",
|
||||
"txt_import_empty_zip_archive": "ZIP 壓縮包為空。",
|
||||
"txt_import_zip_too_large": "ZIP 壓縮包過大,最大允許 {size} MiB。",
|
||||
"txt_import_file_too_large": "導入文件過大,最大允許 {size} MiB。",
|
||||
"txt_import_zip_too_many_files": "ZIP 壓縮包內文件過多。",
|
||||
"txt_import_zip_entry_too_large": "ZIP 壓縮包內存在超過 {size} MiB 的文件。",
|
||||
"txt_import_zip_expands_too_large": "ZIP 解壓後超過目前導入限制 {size} MiB。",
|
||||
"txt_import_zip_unsafe_file_name": "ZIP 壓縮包包含不安全的文件名。",
|
||||
"txt_import_no_json_found_in_zip": "ZIP 內未找到可導入的 JSON 數據。",
|
||||
"txt_import_data_json_not_found": "ZIP 內未找到 data.json。",
|
||||
"txt_import_zip_password_required": "該 ZIP 需要密碼。",
|
||||
@@ -1149,11 +1256,18 @@ const zhTW: Record<string, string> = {
|
||||
"txt_log_action_account_api_key_create": "建立 API 金鑰",
|
||||
"txt_log_action_account_api_key_rotate": "輪換 API 金鑰",
|
||||
"txt_log_action_account_keys_update": "更新帳戶金鑰",
|
||||
"txt_log_action_account_passkey_create": "建立登入通行密鑰",
|
||||
"txt_log_action_account_passkey_delete": "刪除登入通行密鑰",
|
||||
"txt_log_action_account_passkey_encryption_enable": "開啟通行密鑰解鎖密碼庫",
|
||||
"txt_log_action_account_profile_update": "更新帳戶資料",
|
||||
"txt_log_action_account_totp_disable": "關閉兩步驟登入",
|
||||
"txt_log_action_account_totp_enable": "開啟兩步驟登入",
|
||||
"txt_log_action_account_totp_recover": "復原兩步驟登入",
|
||||
"txt_log_action_account_verify_devices_update": "更新裝置驗證設定",
|
||||
"txt_log_action_account_webauthn_2fa_delete": "刪除通行密鑰兩步驟驗證密鑰",
|
||||
"txt_log_action_account_webauthn_2fa_enable": "開啟通行密鑰兩步驟驗證",
|
||||
"txt_log_action_account_yubikey_enable": "更新 YubiKey OTP 設定",
|
||||
"txt_log_action_admin_audit_clear": "清空稽核日誌",
|
||||
"txt_log_action_admin_audit_settings_update": "更新日誌保留設定",
|
||||
"txt_log_action_admin_backup_export": "匯出備份",
|
||||
"txt_log_action_admin_backup_import": "匯入備份",
|
||||
@@ -1176,6 +1290,8 @@ const zhTW: Record<string, string> = {
|
||||
"txt_log_action_auth_login_failed_bad_password": "密碼錯誤登入失敗",
|
||||
"txt_log_action_auth_login_failed_user_inactive": "帳號停用登入失敗",
|
||||
"txt_log_action_auth_login_success": "登入成功",
|
||||
"txt_log_action_auth_passkey_login_failed": "通行密鑰登入失敗",
|
||||
"txt_log_action_auth_passkey_login_success": "通行密鑰登入成功",
|
||||
"txt_log_action_auth_refresh_failed": "刷新登入失敗:{reason}",
|
||||
"txt_log_action_cipher_delete_permanent": "永久刪除密碼項",
|
||||
"txt_log_action_cipher_delete_permanent_bulk": "批次永久刪除密碼項",
|
||||
@@ -1226,6 +1342,7 @@ const zhTW: Record<string, string> = {
|
||||
"txt_log_meta_method": "請求方法",
|
||||
"txt_log_meta_path": "請求路徑",
|
||||
"txt_log_meta_provider": "服務提供方",
|
||||
"txt_log_meta_prf_status": "PRF 狀態",
|
||||
"txt_log_meta_prune_error": "清理錯誤",
|
||||
"txt_log_meta_pruned_file_count": "已清理檔案數",
|
||||
"txt_log_meta_raw": "原始資料",
|
||||
@@ -1261,6 +1378,7 @@ const zhTW: Record<string, string> = {
|
||||
"txt_log_reason_user_inactive": "使用者未啟用",
|
||||
"txt_log_reason_user_missing": "使用者不存在",
|
||||
"txt_log_target_type_attachment": "附件",
|
||||
"txt_log_target_type_account_passkey": "登入通行密鑰",
|
||||
"txt_log_target_type_audit_log": "日誌",
|
||||
"txt_log_target_type_backup": "備份",
|
||||
"txt_log_target_type_cipher": "密碼項",
|
||||
|
||||
@@ -40,6 +40,10 @@ export interface BitwardenCipherInput {
|
||||
fields?: BitwardenFieldInput[] | null;
|
||||
passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null;
|
||||
sshKey?: Record<string, unknown> | null;
|
||||
bankAccount?: Record<string, unknown> | null;
|
||||
driversLicense?: Record<string, unknown> | null;
|
||||
passport?: Record<string, unknown> | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BitwardenJsonInput {
|
||||
@@ -79,6 +83,7 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload {
|
||||
let hasAnyExplicitFolderLink = false;
|
||||
for (const item of itemsRaw) {
|
||||
ciphers.push({
|
||||
...(item && typeof item === 'object' ? item as Record<string, unknown> : {}),
|
||||
id: item?.id ?? null,
|
||||
type: Number(item?.type || 1) || 1,
|
||||
name: item?.name ?? 'Untitled',
|
||||
@@ -93,7 +98,7 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload {
|
||||
totp: item.login.totp ?? null,
|
||||
fido2Credentials: Array.isArray(item.login.fido2Credentials) ? item.login.fido2Credentials : null,
|
||||
uris: Array.isArray(item.login.uris)
|
||||
? item.login.uris.map((u) => ({ uri: u?.uri ?? null, match: u?.match ?? null }))
|
||||
? item.login.uris.map((u) => ({ ...u, uri: u?.uri ?? null, uriChecksum: u?.uriChecksum ?? null, match: u?.match ?? null }))
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
@@ -114,6 +119,9 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload {
|
||||
.filter((x) => !!x.password)
|
||||
: null,
|
||||
sshKey: item?.sshKey ?? null,
|
||||
bankAccount: item?.bankAccount ?? null,
|
||||
driversLicense: item?.driversLicense ?? null,
|
||||
passport: item?.passport ?? null,
|
||||
});
|
||||
const folderId = txt(item?.folderId);
|
||||
if (!folderId) continue;
|
||||
|
||||
+149
-1
@@ -15,6 +15,7 @@ export interface Profile {
|
||||
name: string;
|
||||
key: string;
|
||||
masterPasswordHint?: string | null;
|
||||
yubikeyEnabled?: boolean;
|
||||
privateKey?: string | null;
|
||||
publicKey?: string | null;
|
||||
role: 'admin' | 'user';
|
||||
@@ -141,6 +142,86 @@ export interface CipherSshKey {
|
||||
decFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface CipherBankAccount {
|
||||
bankName?: string | null;
|
||||
nameOnAccount?: string | null;
|
||||
accountType?: string | null;
|
||||
accountNumber?: string | null;
|
||||
routingNumber?: string | null;
|
||||
branchNumber?: string | null;
|
||||
pin?: string | null;
|
||||
swiftCode?: string | null;
|
||||
iban?: string | null;
|
||||
bankContactPhone?: string | null;
|
||||
decBankName?: string;
|
||||
decNameOnAccount?: string;
|
||||
decAccountType?: string;
|
||||
decAccountNumber?: string;
|
||||
decRoutingNumber?: string;
|
||||
decBranchNumber?: string;
|
||||
decPin?: string;
|
||||
decSwiftCode?: string;
|
||||
decIban?: string;
|
||||
decBankContactPhone?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CipherDriversLicense {
|
||||
firstName?: string | null;
|
||||
middleName?: string | null;
|
||||
lastName?: string | null;
|
||||
dateOfBirth?: string | null;
|
||||
licenseNumber?: string | null;
|
||||
issuingCountry?: string | null;
|
||||
issuingState?: string | null;
|
||||
issueDate?: string | null;
|
||||
expirationDate?: string | null;
|
||||
issuingAuthority?: string | null;
|
||||
licenseClass?: string | null;
|
||||
decFirstName?: string;
|
||||
decMiddleName?: string;
|
||||
decLastName?: string;
|
||||
decDateOfBirth?: string;
|
||||
decLicenseNumber?: string;
|
||||
decIssuingCountry?: string;
|
||||
decIssuingState?: string;
|
||||
decIssueDate?: string;
|
||||
decExpirationDate?: string;
|
||||
decIssuingAuthority?: string;
|
||||
decLicenseClass?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CipherPassport {
|
||||
surname?: string | null;
|
||||
givenName?: string | null;
|
||||
dateOfBirth?: string | null;
|
||||
sex?: string | null;
|
||||
birthPlace?: string | null;
|
||||
nationality?: string | null;
|
||||
issuingCountry?: string | null;
|
||||
passportNumber?: string | null;
|
||||
passportType?: string | null;
|
||||
nationalIdentificationNumber?: string | null;
|
||||
issuingAuthority?: string | null;
|
||||
issueDate?: string | null;
|
||||
expirationDate?: string | null;
|
||||
decSurname?: string;
|
||||
decGivenName?: string;
|
||||
decDateOfBirth?: string;
|
||||
decSex?: string;
|
||||
decBirthPlace?: string;
|
||||
decNationality?: string;
|
||||
decIssuingCountry?: string;
|
||||
decPassportNumber?: string;
|
||||
decPassportType?: string;
|
||||
decNationalIdentificationNumber?: string;
|
||||
decIssuingAuthority?: string;
|
||||
decIssueDate?: string;
|
||||
decExpirationDate?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CipherField {
|
||||
type?: number | string | null;
|
||||
name?: string | null;
|
||||
@@ -174,6 +255,9 @@ export interface Cipher {
|
||||
card?: CipherCard | null;
|
||||
identity?: CipherIdentity | null;
|
||||
sshKey?: CipherSshKey | null;
|
||||
bankAccount?: CipherBankAccount | null;
|
||||
driversLicense?: CipherDriversLicense | null;
|
||||
passport?: CipherPassport | null;
|
||||
secureNote?: { type?: number | null } | null;
|
||||
passwordHistory?: CipherPasswordHistoryEntry[] | null;
|
||||
fields?: CipherField[] | null;
|
||||
@@ -196,6 +280,8 @@ export interface Send {
|
||||
key?: string | null;
|
||||
maxAccessCount?: number | null;
|
||||
accessCount?: number;
|
||||
password?: string | null;
|
||||
authType?: number | null;
|
||||
disabled?: boolean;
|
||||
revisionDate?: string;
|
||||
expirationDate?: string | null;
|
||||
@@ -224,6 +310,7 @@ export interface SendDraft {
|
||||
expirationDays: string;
|
||||
maxAccessCount: string;
|
||||
password: string;
|
||||
hasPassword?: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
@@ -275,6 +362,40 @@ export interface VaultDraft {
|
||||
sshPrivateKey: string;
|
||||
sshPublicKey: string;
|
||||
sshFingerprint: string;
|
||||
bankName: string;
|
||||
bankNameOnAccount: string;
|
||||
bankAccountType: string;
|
||||
bankAccountNumber: string;
|
||||
bankRoutingNumber: string;
|
||||
bankBranchNumber: string;
|
||||
bankPin: string;
|
||||
bankSwiftCode: string;
|
||||
bankIban: string;
|
||||
bankContactPhone: string;
|
||||
licenseFirstName: string;
|
||||
licenseMiddleName: string;
|
||||
licenseLastName: string;
|
||||
licenseDateOfBirth: string;
|
||||
licenseNumber: string;
|
||||
licenseIssuingCountry: string;
|
||||
licenseIssuingState: string;
|
||||
licenseIssueDate: string;
|
||||
licenseExpirationDate: string;
|
||||
licenseIssuingAuthority: string;
|
||||
licenseClass: string;
|
||||
passportSurname: string;
|
||||
passportGivenName: string;
|
||||
passportDateOfBirth: string;
|
||||
passportSex: string;
|
||||
passportBirthPlace: string;
|
||||
passportNationality: string;
|
||||
passportIssuingCountry: string;
|
||||
passportNumber: string;
|
||||
passportType: string;
|
||||
passportNationalIdentificationNumber: string;
|
||||
passportIssuingAuthority: string;
|
||||
passportIssueDate: string;
|
||||
passportExpirationDate: string;
|
||||
customFields: VaultDraftField[];
|
||||
}
|
||||
|
||||
@@ -290,9 +411,20 @@ export interface ListResponse<T> {
|
||||
|
||||
export interface WebBootstrapResponse {
|
||||
defaultKdfIterations?: number;
|
||||
jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null;
|
||||
jwtUnsafeReason?: 'missing' | 'too_short' | null;
|
||||
jwtSecretMinLength?: number;
|
||||
registrationInviteRequired?: boolean;
|
||||
webAuthnAllowedOrigins?: string[];
|
||||
websiteIconsEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface YubiKeyOtpSettings {
|
||||
enabled: boolean;
|
||||
keys: [string, string, string, string, string];
|
||||
nfc: boolean;
|
||||
yubicoConfigured: boolean;
|
||||
yubicoClientId: string;
|
||||
yubicoSecretKey: string;
|
||||
}
|
||||
|
||||
export interface TokenSuccess {
|
||||
@@ -328,6 +460,11 @@ export interface TokenError {
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
TwoFactorProviders?: unknown;
|
||||
TwoFactorProviders2?: unknown;
|
||||
CustomResponse?: {
|
||||
TwoFactorProviders?: unknown;
|
||||
TwoFactorProviders2?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AccountPasskeyCredential {
|
||||
@@ -340,6 +477,17 @@ export interface AccountPasskeyCredential {
|
||||
revisionDate?: string;
|
||||
}
|
||||
|
||||
export interface TwoFactorPasskeyCredential {
|
||||
id: number;
|
||||
name: string;
|
||||
migrated?: boolean;
|
||||
}
|
||||
|
||||
export interface TwoFactorPasskeySettings {
|
||||
enabled: boolean;
|
||||
keys: TwoFactorPasskeyCredential[];
|
||||
}
|
||||
|
||||
export interface AuthRequest {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user