mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
Compare commits
95
Commits
49c872a8ec
...
v1.7.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b093c01fd7 | ||
|
|
fa611dc843 | ||
|
|
3c581d1fb1 | ||
|
|
fb376797d2 | ||
|
|
99b50275a6 | ||
|
|
dfc98008cb | ||
|
|
b472121f43 | ||
|
|
9caa064488 | ||
|
|
aae614a079 | ||
|
|
0e46cd371f | ||
|
|
db31792cef | ||
|
|
8c65cb2e80 | ||
|
|
14dff8ee6a | ||
|
|
8d399f431b | ||
|
|
bb3f866220 | ||
|
|
39d9df78ea | ||
|
|
a1b12fc447 | ||
|
|
099217062a | ||
|
|
dd90d7b8b8 | ||
|
|
525b773cf4 | ||
|
|
04cb475935 | ||
|
|
e063f45cd9 | ||
|
|
e10920d142 | ||
|
|
b07edb0850 | ||
|
|
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 | ||
|
|
ce3674669e | ||
|
|
aa7b87e041 | ||
|
|
e4215b4025 | ||
|
|
8d292ca7b8 | ||
|
|
5dd9dff045 | ||
|
|
709a8c1768 | ||
|
|
e2c3516ce9 | ||
|
|
55b5c57f9e | ||
|
|
b6fb62603b | ||
|
|
35071c2719 | ||
|
|
5bd7dab277 | ||
|
|
99f2d7f444 | ||
|
|
fb9a2aeda1 | ||
|
|
c87e6ac984 | ||
|
|
a6f1c6dea2 | ||
|
|
68c42a0330 |
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
custom:
|
||||
- https://nodewarden.app/sponsor
|
||||
@@ -14,6 +14,10 @@ updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
ignore:
|
||||
- dependency-name: "tailwindcss"
|
||||
update-types:
|
||||
- "version-update:semver-major"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
@@ -22,7 +26,7 @@ updates:
|
||||
day: "monday"
|
||||
time: "05:10"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 5
|
||||
open-pull-requests-limit: 0
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
|
||||
@@ -27,18 +27,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@411bbbe57033eedfc1a82d68c01345aa96c737d7
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@411bbbe57033eedfc1a82d68c01345aa96c737d7
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -18,13 +18,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout full history
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Gitleaks
|
||||
uses: gitleaks/gitleaks-action@v3
|
||||
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_ENABLE_SUMMARY: "true"
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
osv:
|
||||
name: OSV Dependency Scan
|
||||
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@v2.3.8
|
||||
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -57,12 +57,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload Semgrep SARIF
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@411bbbe57033eedfc1a82d68c01345aa96c737d7
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
category: semgrep
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -160,12 +160,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@v0.5.7
|
||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa
|
||||
with:
|
||||
persona: auditor
|
||||
min-severity: medium
|
||||
@@ -182,19 +182,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run OpenSSF Scorecard
|
||||
uses: ossf/scorecard-action@v2.4.3
|
||||
uses: ossf/scorecard-action@99c09fe975337306107572b4fdf4db224cf8e2f2
|
||||
with:
|
||||
results_file: scorecard.sarif
|
||||
results_format: sarif
|
||||
publish_results: false
|
||||
|
||||
- name: Upload Scorecard SARIF
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@411bbbe57033eedfc1a82d68c01345aa96c737d7
|
||||
with:
|
||||
sarif_file: scorecard.sarif
|
||||
category: openssf-scorecard
|
||||
|
||||
@@ -19,20 +19,29 @@ jobs:
|
||||
sync-global-domains:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
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
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1
|
||||
with:
|
||||
branch: chore/sync-bitwarden-global-domains
|
||||
delete-branch: true
|
||||
|
||||
@@ -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@v4
|
||||
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
|
||||
+2
-1
@@ -43,7 +43,7 @@ tmp/
|
||||
.tmp/
|
||||
.tmp-bitwarden-clients/
|
||||
|
||||
nodewarden.wiki/
|
||||
nodewarden-wiki/
|
||||
wiki/
|
||||
AGENTS.md
|
||||
settings.json
|
||||
@@ -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,85 +105,46 @@ 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
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
|
||||
</a>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
</picture>
|
||||
</a>
|
||||
-172
@@ -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)
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
<p align="center">
|
||||
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">English</a> |
|
||||
<a href="./CONTRIBUTING.md">贡献指南</a> |
|
||||
<a href="https://nodewarden.app">官方wiki</a>
|
||||
</p>
|
||||
|
||||
> **免责声明**
|
||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
||||
|
||||
---
|
||||
|
||||
## 与 Bitwarden 官方服务端能力对比
|
||||
|
||||
| 能力 | Bitwarden免费版 | NodeWarden | 说明 |
|
||||
|---|---|---|---|
|
||||
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||
| TOTP | ❌ | ✅ | 包括 `steam://` 支持 |
|
||||
| **PWA / 离线使用** | ❌ | ✅ | **可安装、离线使用、App快捷方式** |
|
||||
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
||||
| API 密钥 | ✅ | ✅ | 供bitwarden cli使用,支持获取和轮换 |
|
||||
| 登录 2FA | ✅ | ✅ | 支持 TOTP、YubiKey、Passkey |
|
||||
| 2FA 恢复码 | ✅ | ✅ | 一次性恢复码用于禁用 2FA |
|
||||
| 实时推送同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
|
||||
| 附件 / Send| ✅ | ✅ | Cloudflare R2 或 KV |
|
||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时增量备份** |
|
||||
| 设备管理 | ✅ | ✅ | **删除设备、撤销信任、永久信任** |
|
||||
| 登录请求 | ✅ | ✅ | **多端免密登录审批、跨设备解锁请求** |
|
||||
| **多用户使用** | ✅ | ✅ | 支持邀请码注册 |
|
||||
| 域名规则 | ✅ | ✅ | 自定义等效域名、全局域名排除 |
|
||||
| Fill-assist | ✅ | ✅ | `POST /fill-assist` 辅助客户端自动填充;不能绕过保险库解锁 |
|
||||
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
||||
|
||||
---
|
||||
|
||||
## 已测试客户端
|
||||
|
||||
- ✅ Windows 桌面端
|
||||
- ✅ 手机 App
|
||||
- ✅ 浏览器扩展
|
||||
- ✅ Linux 桌面端
|
||||
- ⚠️ macOS 桌面端尚未完整验证
|
||||
|
||||
---
|
||||
|
||||
## 可视化快速部署
|
||||
|
||||
1. Fork NodeWarden 仓库到自己的 GitHub 账号
|
||||
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||
3. 选择 Continue with GitHub 并选择你的仓库
|
||||
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
|
||||
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||
5. 等部署完成后,打开生成的 Workers 域名
|
||||
|
||||
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
|
||||
|
||||
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
|
||||
|
||||
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` 或 `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
|
||||
|
||||
|
||||
> [!TIP]
|
||||
> 默认R2与可选KV的区别:
|
||||
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||
> |---|---|---|---|
|
||||
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||
> | KV | 不需要 | 25 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/) - 无服务器平台
|
||||
|
||||
---
|
||||
|
||||
## 贡献者
|
||||
|
||||
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
|
||||
</a>
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
security_stamp TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
verify_devices INTEGER NOT NULL DEFAULT 1,
|
||||
verify_devices INTEGER NOT NULL DEFAULT 0,
|
||||
totp_secret TEXT,
|
||||
totp_recovery_code TEXT,
|
||||
api_key TEXT,
|
||||
@@ -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,
|
||||
|
||||
Generated
+872
-593
File diff suppressed because it is too large
Load Diff
+21
-20
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.4",
|
||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||
"author": "shuaiplus",
|
||||
"license": "LGPL-3.0",
|
||||
@@ -49,27 +49,28 @@
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260131.0",
|
||||
"@preact/preset-vite": "^2.10.3",
|
||||
"@types/node": "^25.2.3",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"opencc-js": "^1.0.5",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"wrangler": "^4.71.0"
|
||||
"@cloudflare/workers-types": "^4.20260630.1",
|
||||
"@preact/preset-vite": "^2.10.5",
|
||||
"@types/node": "^26.0.1",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"opencc-js": "^1.3.2",
|
||||
"postcss": "^8.5.16",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.3",
|
||||
"wrangler": "^4.105.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@simplewebauthn/server": "^13.3.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@zip.js/zip.js": "^2.8.22",
|
||||
"fflate": "^0.8.2",
|
||||
"lucide-preact": "^0.575.0",
|
||||
"preact": "^10.28.4",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"@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",
|
||||
"wouter": "^3.9.0"
|
||||
"wouter": "^3.10.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ const localeFiles = [
|
||||
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
|
||||
['ru', 'ru.ts', 'ru', 'Russian'],
|
||||
['es', 'es.ts', 'es', 'Spanish'],
|
||||
['fi', 'fi.ts', 'fi', 'Finnish'],
|
||||
['de', 'de.ts', 'de', 'German'],
|
||||
['fr', 'fr.ts', 'fr', 'French'],
|
||||
['it', 'it.ts', 'it', 'Italian'],
|
||||
['sv', 'sv.ts', 'sv', 'Swedish'],
|
||||
];
|
||||
|
||||
function readLocale(fileName, variableName) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { normalizeBackupEndpointUrl } from '../src/services/backup-config.ts';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const scratch = process.env.SCRATCH || '.';
|
||||
const cases = [
|
||||
'http://127.0.0.1',
|
||||
'http://169.254.169.254',
|
||||
'http://[::1]',
|
||||
'http://[0:0:0:0:0:0:0:1]',
|
||||
'http://[::2]',
|
||||
'http://[::]',
|
||||
'http://[fe80::1]',
|
||||
'http://[fc00::1]',
|
||||
'https://example.com',
|
||||
];
|
||||
|
||||
const out = [];
|
||||
for (const url of cases) {
|
||||
try {
|
||||
const normalized = normalizeBackupEndpointUrl(url, 'WebDAV server URL');
|
||||
out.push({ url, allowed: true, normalized });
|
||||
} catch (e) {
|
||||
out.push({ url, allowed: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
const path = `${scratch}/poc-normalizeBackupEndpointUrl.json`;
|
||||
fs.writeFileSync(path, JSON.stringify(out, null, 2));
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
|
||||
// Security expectation: IPv6 loopback must NOT be allowed.
|
||||
const loopback = out.find((row) => row.url === 'http://[::1]');
|
||||
if (loopback?.allowed) {
|
||||
console.error('FINDING_CONFIRMED: normalizeBackupEndpointUrl accepts http://[::1]');
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
console.log('IPv6 loopback rejected as expected');
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '1.7.1';
|
||||
export const APP_VERSION = '1.7.4';
|
||||
|
||||
@@ -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,
|
||||
|
||||
+353
-63
@@ -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,8 +274,6 @@ 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';
|
||||
return errorResponse(message, 400);
|
||||
}
|
||||
@@ -321,9 +352,15 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
securityStamp: generateUUID(),
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
verifyDevices: true,
|
||||
verifyDevices: false, // new-device verification requires email delivery (not available)
|
||||
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
|
||||
@@ -516,51 +553,31 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
|
||||
}
|
||||
|
||||
// PUT/POST /api/accounts/verify-devices
|
||||
// New-device verification requires an email delivery channel which NodeWarden
|
||||
// does not provide. This endpoint always rejects the request so clients receive
|
||||
// clear feedback that the feature is unavailable rather than silently ignoring
|
||||
// the user's preference.
|
||||
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
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: {
|
||||
secret?: string;
|
||||
masterPasswordHash?: string;
|
||||
verifyDevices?: boolean;
|
||||
VerifyDevices?: boolean;
|
||||
};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const verifyDevices = typeof body.verifyDevices === 'boolean' ? body.verifyDevices : body.VerifyDevices;
|
||||
if (typeof verifyDevices !== 'boolean') {
|
||||
return errorResponse('verifyDevices must be true or false', 400);
|
||||
}
|
||||
|
||||
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
|
||||
if (!verified) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
user.verifyDevices = verifyDevices;
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
// Log the attempt for audit purposes, but do not change state.
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'account.verify_devices.update',
|
||||
action: 'account.verify_devices.update.rejected',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
level: 'info',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
verifyDevices: user.verifyDevices,
|
||||
reason: 'new-device verification is not supported (no email delivery channel)',
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
|
||||
}
|
||||
|
||||
// GET /api/accounts/keys
|
||||
@@ -700,6 +717,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 +731,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 +786,44 @@ 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',
|
||||
};
|
||||
}
|
||||
|
||||
// New-device verification requires an email delivery channel to send OTP
|
||||
// challenges to unknown devices. NodeWarden does not integrate with an email
|
||||
// provider, so this feature is intentionally unavailable. The settings
|
||||
// response always reports disabled regardless of any legacy DB value.
|
||||
function deviceVerificationSettingsResponse(_user: User): Record<string, unknown> {
|
||||
return {
|
||||
Enabled: false,
|
||||
enabled: false,
|
||||
VerifyDevices: false,
|
||||
verifyDevices: false,
|
||||
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 +831,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 +867,77 @@ 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
|
||||
// New-device verification is not supported (no email delivery channel).
|
||||
// Reject any attempt to enable it; always return disabled state.
|
||||
export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
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;
|
||||
|
||||
// Log the attempt for audit purposes — never change state.
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'account.verify_devices.update.rejected',
|
||||
category: 'security',
|
||||
level: 'info',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
requested: rawEnabled,
|
||||
reason: 'new-device verification is not supported (no email delivery channel)',
|
||||
source: 'two-factor.device-verification-settings',
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
|
||||
if (rawEnabled === true) {
|
||||
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
|
||||
}
|
||||
|
||||
// Setting to false is the only supported state — return it.
|
||||
return jsonResponse(deviceVerificationSettingsResponse(user));
|
||||
}
|
||||
|
||||
// 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 +961,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 +987,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 +1138,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);
|
||||
|
||||
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 +1179,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 +1224,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 +1373,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,9 +1485,9 @@ 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);
|
||||
// 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);
|
||||
@@ -1213,10 +1504,9 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
|
||||
targetId: user.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
apiKey: user.apiKey,
|
||||
apiKey: plainApiKey,
|
||||
revisionDate: user.updatedAt,
|
||||
object: 'apiKey',
|
||||
});
|
||||
|
||||
+58
-18
@@ -9,6 +9,34 @@ function isAdmin(user: User): boolean {
|
||||
return user.role === 'admin' && user.status === 'active';
|
||||
}
|
||||
|
||||
async function requireMasterPasswordHash(
|
||||
env: Env,
|
||||
actorUser: User,
|
||||
masterPasswordHash: unknown
|
||||
): Promise<Response | null> {
|
||||
const normalized = String(masterPasswordHash || '').trim();
|
||||
if (!normalized) {
|
||||
return errorResponse('masterPasswordHash is required', 400);
|
||||
}
|
||||
const auth = new AuthService(env);
|
||||
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
|
||||
if (!valid) {
|
||||
return errorResponse('Invalid password', 400);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const body = await request.json();
|
||||
return body && typeof body === 'object' && !Array.isArray(body)
|
||||
? body as Record<string, unknown>
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function randomHex(bytes: number): string {
|
||||
const data = crypto.getRandomValues(new Uint8Array(bytes));
|
||||
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
|
||||
@@ -69,18 +97,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 +215,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 });
|
||||
}
|
||||
|
||||
@@ -197,14 +232,11 @@ export async function handleAdminCreateInvite(
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
let body: { expiresInHours?: number } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
const body = await readJsonBody(request);
|
||||
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||
if (passwordError) return passwordError;
|
||||
|
||||
const expiresInHours = Number.isFinite(body.expiresInHours)
|
||||
const expiresInHours = Number.isFinite(Number(body.expiresInHours))
|
||||
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
|
||||
: 24 * 7;
|
||||
const now = new Date();
|
||||
@@ -259,6 +291,10 @@ export async function handleAdminDeleteInvite(
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||
if (passwordError) return passwordError;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const deleted = await storage.deleteInvite(code);
|
||||
if (!deleted) {
|
||||
@@ -281,6 +317,10 @@ export async function handleAdminDeleteAllInvites(
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||
if (passwordError) return passwordError;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.get('scope') === 'invalid') {
|
||||
@@ -311,12 +351,9 @@ export async function handleAdminSetUserStatus(
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
let body: { status?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
const body = await readJsonBody(request);
|
||||
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||
if (passwordError) return passwordError;
|
||||
|
||||
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
|
||||
if (!nextStatus) {
|
||||
@@ -359,7 +396,6 @@ export async function handleAdminDeleteUser(
|
||||
actorUser: User,
|
||||
targetUserId: string
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
@@ -367,6 +403,10 @@ export async function handleAdminDeleteUser(
|
||||
return errorResponse('You cannot delete yourself', 400);
|
||||
}
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||
if (passwordError) return passwordError;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const target = await storage.getUserById(targetUserId);
|
||||
if (!target) {
|
||||
|
||||
+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);
|
||||
|
||||
+98
-2
@@ -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);
|
||||
@@ -48,6 +48,8 @@ function buildDeviceResponse(device: Device): DeviceResponse {
|
||||
creationDate: device.createdAt,
|
||||
RevisionDate: device.updatedAt,
|
||||
revisionDate: device.updatedAt,
|
||||
LastActivityDate: device.lastSeenAt,
|
||||
lastActivityDate: device.lastSeenAt,
|
||||
LastSeenAt: device.lastSeenAt,
|
||||
lastSeenAt: device.lastSeenAt,
|
||||
HasStoredDevice: true,
|
||||
@@ -123,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
|
||||
@@ -383,11 +464,26 @@ export async function handleUpdateDeviceName(
|
||||
|
||||
// DELETE /api/devices
|
||||
export async function handleDeleteAllDevices(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);
|
||||
|
||||
let masterPasswordHash = '';
|
||||
try {
|
||||
const body = await request.json() as { masterPasswordHash?: string };
|
||||
masterPasswordHash = String(body?.masterPasswordHash || '').trim();
|
||||
} catch {
|
||||
masterPasswordHash = '';
|
||||
}
|
||||
if (!masterPasswordHash) {
|
||||
return errorResponse('masterPasswordHash is required', 400);
|
||||
}
|
||||
const auth = new AuthService(env);
|
||||
const passwordValid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
|
||||
if (!passwordValid) {
|
||||
return errorResponse('Invalid password', 400);
|
||||
}
|
||||
|
||||
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
|
||||
storage.deleteTrustedTwoFactorTokensByUserId(userId),
|
||||
storage.deleteRefreshTokensByUserId(userId),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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: 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,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
const normalized = normalizeFilename(filename);
|
||||
if (normalized === EMPTY_FORMS_FILENAME) {
|
||||
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,
|
||||
@@ -155,7 +155,15 @@ export function formatSize(bytes: number): string {
|
||||
|
||||
export function parseDate(raw: unknown): Date | null {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||
const date = new Date(raw);
|
||||
let value = raw.trim();
|
||||
if (!/[zZ]$/.test(value) && !/[+\-]\d{2}:?\d{2}$/.test(value)) {
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) {
|
||||
value += 'Z';
|
||||
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value)) {
|
||||
value = value.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date;
|
||||
}
|
||||
@@ -371,7 +379,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 +442,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 +472,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 };
|
||||
|
||||
+6
-3
@@ -24,8 +24,11 @@ function isWorkerHandledPath(path: string): boolean {
|
||||
path.startsWith('/api/') ||
|
||||
path.startsWith('/identity/') ||
|
||||
path.startsWith('/icons/') ||
|
||||
path.startsWith('/fill-assist/') ||
|
||||
path.startsWith('/notifications/') ||
|
||||
path.startsWith('/.well-known/') ||
|
||||
path === '/v1/assetlinks:check' ||
|
||||
path === '/web-bootstrap' ||
|
||||
path === '/config' ||
|
||||
path === '/api/config' ||
|
||||
path === '/api/version'
|
||||
@@ -89,7 +92,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 +110,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);
|
||||
|
||||
+74
-10
@@ -1,5 +1,4 @@
|
||||
import { LIMITS } from './config/limits';
|
||||
import { DEFAULT_DEV_SECRET } from './types';
|
||||
import {
|
||||
handleAccessSend,
|
||||
handleAccessSendFile,
|
||||
@@ -8,6 +7,11 @@ import {
|
||||
handleDownloadSendFile,
|
||||
} from './handlers/sends';
|
||||
import { handleKnownDevice } from './handlers/devices';
|
||||
import {
|
||||
handleDigitalAssetLinkCheck,
|
||||
handleFillAssistForms,
|
||||
handleFillAssistManifest,
|
||||
} from './handlers/fill-assist';
|
||||
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
|
||||
import {
|
||||
@@ -28,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 {
|
||||
@@ -97,6 +108,7 @@ function buildIconServiceCsp(origin: string): string {
|
||||
}
|
||||
|
||||
function buildConfigResponse(origin: string) {
|
||||
const fillAssistBase = `${origin}/fill-assist/`;
|
||||
return {
|
||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||
gitHash: 'nodewarden',
|
||||
@@ -109,7 +121,7 @@ function buildConfigResponse(origin: string) {
|
||||
notifications: origin + '/notifications',
|
||||
icons: origin,
|
||||
sso: '',
|
||||
fillAssistRules: null,
|
||||
fillAssistRules: fillAssistBase,
|
||||
},
|
||||
push: {
|
||||
pushTechnology: 0,
|
||||
@@ -125,8 +137,10 @@ function buildConfigResponse(origin: string) {
|
||||
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
|
||||
'duo-redirect': true,
|
||||
'email-verification': true,
|
||||
'fill-assist-targeting-rules': true,
|
||||
'pm-19051-send-email-verification': false,
|
||||
'pm-19148-innovation-archive': true,
|
||||
'pm-4516-devices-add-last-activity-date': true,
|
||||
'pm-30529-webauthn-related-origins': true,
|
||||
'unauth-ui-refresh': true,
|
||||
'web-push': false,
|
||||
@@ -248,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();
|
||||
|
||||
@@ -304,8 +322,6 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
|
||||
const jwtUnsafeReason =
|
||||
!secret
|
||||
? 'missing'
|
||||
: secret === DEFAULT_DEV_SECRET
|
||||
? 'default'
|
||||
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||
? 'too_short'
|
||||
: null;
|
||||
@@ -317,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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -343,12 +361,31 @@ export async function handlePublicRoute(
|
||||
return jsonResponse(await buildWebBootstrapResponse(env));
|
||||
}
|
||||
|
||||
if (path === '/fill-assist/manifest.json' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
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);
|
||||
if (blocked) return blocked;
|
||||
return handleFillAssistForms(fillAssistFormsMatch[1]);
|
||||
}
|
||||
|
||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||
if (iconMatch && method === 'GET') {
|
||||
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);
|
||||
@@ -398,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;
|
||||
@@ -451,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;
|
||||
@@ -500,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;
|
||||
|
||||
+91
-18
@@ -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,163 @@ 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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a hostname-form IPv6 literal to eight 4-digit hextets.
|
||||
* Needed so compressed forms like "::1" are not misclassified by a naive
|
||||
* "first non-empty hextet" check (which would read "1" and miss loopback).
|
||||
*/
|
||||
function expandIpv6Address(hostname: string): string[] | null {
|
||||
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (!normalized.includes(':')) return null;
|
||||
if (normalized.includes('.')) {
|
||||
// IPv4-embedded forms are handled separately by the caller.
|
||||
return null;
|
||||
}
|
||||
if ((normalized.match(/::/g) || []).length > 1) return null;
|
||||
|
||||
const sides = normalized.split('::');
|
||||
const left = sides[0] ? sides[0].split(':').filter((part) => part.length > 0) : [];
|
||||
const right = sides.length > 1 && sides[1] ? sides[1].split(':').filter((part) => part.length > 0) : [];
|
||||
if (left.length + right.length > 8) return null;
|
||||
if (sides.length === 1 && left.length !== 8) return null;
|
||||
|
||||
const missing = 8 - left.length - right.length;
|
||||
if (sides.length > 1 && missing < 0) return null;
|
||||
const middle = sides.length > 1 ? Array.from({ length: missing }, () => '0') : [];
|
||||
const parts = [...left, ...middle, ...right];
|
||||
if (parts.length !== 8) return null;
|
||||
|
||||
const hextets: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (!/^[0-9a-f]{1,4}$/i.test(part)) return null;
|
||||
hextets.push(part.padStart(4, '0'));
|
||||
}
|
||||
return hextets;
|
||||
}
|
||||
|
||||
function isBlockedIpv6Address(hostname: string): boolean {
|
||||
if (!hostname.includes(':')) return false;
|
||||
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
|
||||
// IPv4-mapped dotted form: ::ffff:127.0.0.1
|
||||
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
|
||||
if (mappedIpv4) {
|
||||
const octets = parseIpv4Address(mappedIpv4[1]);
|
||||
return !octets || isBlockedIpv4Address(octets);
|
||||
}
|
||||
|
||||
// IPv4-mapped hex form produced by some URL parsers: ::ffff:7f00:1
|
||||
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
|
||||
if (mappedHex) {
|
||||
const hi = Number.parseInt(mappedHex[1], 16);
|
||||
const lo = Number.parseInt(mappedHex[2], 16);
|
||||
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return true;
|
||||
const octets = [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff];
|
||||
return isBlockedIpv4Address(octets);
|
||||
}
|
||||
|
||||
const hextets = expandIpv6Address(normalized);
|
||||
if (!hextets) return true;
|
||||
const firstHextet = Number.parseInt(hextets[0], 16);
|
||||
if (!Number.isFinite(firstHextet)) return true;
|
||||
// After expansion, loopback (::1) and unspecified (::) have first hextet 0.
|
||||
return (
|
||||
firstHextet === 0 ||
|
||||
(firstHextet & 0xfe00) === 0xfc00 ||
|
||||
(firstHextet & 0xffc0) === 0xfe80 ||
|
||||
(firstHextet & 0xff00) === 0xff00 ||
|
||||
hextets.join(':').startsWith('2001:0db8:')
|
||||
);
|
||||
}
|
||||
|
||||
function assertBackupEndpointHostAllowed(hostname: string, label: string): void {
|
||||
const normalized = normalizeHostnameForPolicy(hostname);
|
||||
if (!normalized) throw new Error(`${label} host is required`);
|
||||
if (
|
||||
normalized === 'localhost' ||
|
||||
normalized === 'localhost.localdomain' ||
|
||||
normalized.endsWith('.localhost.localdomain') ||
|
||||
normalized.endsWith('.localhost') ||
|
||||
normalized.endsWith('.local') ||
|
||||
normalized.endsWith('.home.arpa') ||
|
||||
normalized.endsWith('.internal') ||
|
||||
normalized.endsWith('.lan') ||
|
||||
normalized === 'metadata.google.internal' ||
|
||||
normalized === 'localtest.me' ||
|
||||
normalized.endsWith('.localtest.me') ||
|
||||
normalized === 'lvh.me' ||
|
||||
normalized.endsWith('.lvh.me') ||
|
||||
normalized === 'vcap.me' ||
|
||||
normalized.endsWith('.vcap.me') ||
|
||||
normalized === 'nip.io' ||
|
||||
normalized.endsWith('.nip.io') ||
|
||||
normalized === 'sslip.io' ||
|
||||
normalized.endsWith('.sslip.io') ||
|
||||
normalized === 'xip.io' ||
|
||||
normalized.endsWith('.xip.io')
|
||||
) {
|
||||
throw new Error(`${label} host is not allowed`);
|
||||
}
|
||||
const ipv4 = parseIpv4Address(normalized);
|
||||
if (ipv4 && isBlockedIpv4Address(ipv4)) {
|
||||
throw new Error(`${label} host is not allowed`);
|
||||
}
|
||||
if (isBlockedIpv6Address(normalized)) {
|
||||
throw new Error(`${label} host is not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeBackupEndpointUrl(value: string, label: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${label} must be a valid URL`);
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`${label} must start with http:// or https://`);
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error(`${label} must not include credentials`);
|
||||
}
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new Error(`${label} must not include query or fragment`);
|
||||
}
|
||||
assertBackupEndpointHostAllowed(parsed.hostname, label);
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function assertValidTimeZone(timezone: string): string {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
|
||||
@@ -122,7 +280,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 +293,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
||||
endpoint: endpoint ? normalizeBackupEndpointUrl(endpoint, 'S3 endpoint') : '',
|
||||
bucket,
|
||||
addressingStyle,
|
||||
region,
|
||||
@@ -154,7 +312,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 +322,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 +338,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 +434,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 +620,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) => {
|
||||
@@ -296,12 +301,16 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
||||
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
|
||||
users: cloneRows(payload.users || []).map((row) => ({
|
||||
...row,
|
||||
verify_devices: row.verify_devices ?? 1,
|
||||
verify_devices: row.verify_devices ?? 0,
|
||||
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 0, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||
'ALTER TABLE users ADD COLUMN 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 verify_devices INTEGER NOT NULL DEFAULT 0',
|
||||
'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 {
|
||||
@@ -23,9 +23,15 @@ function mapUserRow(row: any): User {
|
||||
securityStamp: row.security_stamp,
|
||||
role: row.role === 'admin' ? 'admin' : 'user',
|
||||
status: row.status === 'banned' ? 'banned' : 'active',
|
||||
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
||||
verifyDevices: row.verify_devices == null ? false : !!row.verify_devices,
|
||||
totpSecret: row.totp_secret ?? null,
|
||||
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);
|
||||
}
|
||||
|
||||
+77
-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;
|
||||
@@ -307,6 +374,7 @@ export interface DeviceResponse {
|
||||
type: number;
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
lastActivityDate?: string | null;
|
||||
lastSeenAt?: string | null;
|
||||
hasStoredDevice?: boolean;
|
||||
isTrusted: boolean;
|
||||
@@ -501,6 +569,7 @@ export interface ProfileResponse {
|
||||
masterPasswordHint: string | null;
|
||||
culture: string;
|
||||
twoFactorEnabled: boolean;
|
||||
yubikeyEnabled?: boolean;
|
||||
key: string;
|
||||
privateKey: string | null;
|
||||
accountKeys: any | null;
|
||||
@@ -531,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,50 @@
|
||||
import type { Env } from '../types';
|
||||
|
||||
// Keep this list aligned with Bitwarden server's default FIDO2 origins.
|
||||
// These are the stable store IDs for the official Chromium-based extensions.
|
||||
export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
|
||||
'chrome-extension://nngceckbapebfimnlniiiahkandclblb',
|
||||
'chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh',
|
||||
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
|
||||
] as const;
|
||||
|
||||
export function normalizeOrigin(value: unknown): string | null {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (!url.protocol || !url.host) return null;
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isBrowserExtensionOrigin(origin: unknown): boolean {
|
||||
const normalized = normalizeOrigin(origin);
|
||||
return !!normalized && (
|
||||
normalized.startsWith('chrome-extension://')
|
||||
|| normalized.startsWith('moz-extension://')
|
||||
|| normalized.startsWith('safari-web-extension://')
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfiguredWebAuthnAllowedOrigins(
|
||||
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
|
||||
): string[] {
|
||||
const seen = new Set<string>(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS);
|
||||
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
|
||||
const origin = normalizeOrigin(item);
|
||||
if (origin) seen.add(origin);
|
||||
}
|
||||
return Array.from(seen);
|
||||
}
|
||||
|
||||
export function isConfiguredWebAuthnAllowedOrigin(
|
||||
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>,
|
||||
origin: unknown
|
||||
): boolean {
|
||||
const normalized = normalizeOrigin(origin);
|
||||
return !!normalized && getConfiguredWebAuthnAllowedOrigins(env).includes(normalized);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -28,7 +30,9 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
|
||||
forcePasswordReset: false,
|
||||
avatarColor: null,
|
||||
creationDate: user.createdAt,
|
||||
verifyDevices: user.verifyDevices !== false,
|
||||
// New-device verification is not supported without an email delivery channel.
|
||||
// Always report disabled so clients do not present a false security posture.
|
||||
verifyDevices: false,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
object: 'profile',
|
||||
|
||||
+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;
|
||||
}
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
script-src 'self' 'unsafe-inline';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data:;
|
||||
connect-src 'self';
|
||||
connect-src 'self' https://api.pwnedpasswords.com;
|
||||
font-src 'self';
|
||||
form-action 'self';
|
||||
base-uri 'self';
|
||||
|
||||
@@ -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>
|
||||
+89
-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';
|
||||
@@ -67,6 +68,7 @@ import { t } from '@/lib/i18n';
|
||||
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
|
||||
import { clearPasswordSecurityCache } from '@/lib/password-security-cache';
|
||||
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
||||
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
|
||||
import {
|
||||
@@ -110,6 +112,8 @@ const APP_ROUTE_PATHS = [
|
||||
'/',
|
||||
'/vault',
|
||||
'/vault/totp',
|
||||
'/security/password-health',
|
||||
'/generator',
|
||||
'/sends',
|
||||
'/admin',
|
||||
'/logs',
|
||||
@@ -152,6 +156,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 +231,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 +299,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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -380,6 +388,10 @@ export default function App() {
|
||||
}
|
||||
}, [phase, profile, session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== 'app') clearPasswordSecurityCache();
|
||||
}, [phase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
|
||||
@@ -654,19 +666,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);
|
||||
}
|
||||
@@ -847,6 +878,7 @@ export default function App() {
|
||||
setDecryptedFolders([]);
|
||||
setDecryptedCiphers([]);
|
||||
setDecryptedSends([]);
|
||||
clearPasswordSecurityCache();
|
||||
setUnlockPassword('');
|
||||
setPendingTotp(null);
|
||||
setPendingTotpMode(null);
|
||||
@@ -868,6 +900,7 @@ export default function App() {
|
||||
setSession(null);
|
||||
clearProfileSnapshot();
|
||||
clearOfflineUnlockRecord();
|
||||
clearPasswordSecurityCache();
|
||||
setProfile(null);
|
||||
setUnlockPreparing(false);
|
||||
setPendingTotp(null);
|
||||
@@ -951,11 +984,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 +1117,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 +1176,6 @@ export default function App() {
|
||||
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
|
||||
await respondToAuthRequest(authedFetch, authRequest.id, {
|
||||
key,
|
||||
masterPasswordHash: null,
|
||||
deviceIdentifier: getCurrentDeviceIdentifier(),
|
||||
requestApproved: true,
|
||||
});
|
||||
@@ -1816,11 +1851,13 @@ export default function App() {
|
||||
onNotify: pushToast,
|
||||
onProfileUpdated: setProfile,
|
||||
onSetConfirm: setConfirm,
|
||||
refetchTotpStatus: totpStatusQuery.refetch,
|
||||
refetchTwoFactorStatus: twoFactorStatusQuery.refetch,
|
||||
refetchAuthorizedDevices: authorizedDevicesQuery.refetch,
|
||||
});
|
||||
const adminActions = useAdminActions({
|
||||
authedFetch,
|
||||
email: String(profile?.email || session?.email || ''),
|
||||
defaultKdfIterations,
|
||||
onNotify: pushToast,
|
||||
onSetConfirm: setConfirm,
|
||||
refetchUsers: usersQuery.refetch,
|
||||
@@ -1837,7 +1874,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(/\/+$/, '');
|
||||
@@ -1876,13 +1912,17 @@ export default function App() {
|
||||
const mobilePrimaryRoute =
|
||||
location === '/sends'
|
||||
? '/sends'
|
||||
: location === '/generator'
|
||||
? '/generator'
|
||||
: location === '/vault/totp'
|
||||
? '/vault/totp'
|
||||
: location === '/vault'
|
||||
? '/vault'
|
||||
: '/settings';
|
||||
const currentPageTitle = (() => {
|
||||
if (location === '/security/password-health') return t('txt_password_security');
|
||||
if (location === '/vault/totp') return t('txt_verification_code');
|
||||
if (location === '/generator') return t('txt_password_generator');
|
||||
if (location === '/sends') return t('nav_sends');
|
||||
if (location === '/admin') return t('nav_admin_panel');
|
||||
if (location === '/logs') return t('nav_log_center');
|
||||
@@ -1939,6 +1979,7 @@ export default function App() {
|
||||
session,
|
||||
mobileLayout,
|
||||
mobileSidebarToggleKey,
|
||||
themePreference,
|
||||
importRoute: IMPORT_ROUTE,
|
||||
settingsHomeRoute: SETTINGS_HOME_ROUTE,
|
||||
settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE,
|
||||
@@ -1953,7 +1994,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 +2009,7 @@ export default function App() {
|
||||
onNavigate: navigate,
|
||||
onLogout: handleLogout,
|
||||
onNotify: pushToast,
|
||||
onThemePreferenceChange: setThemePreference,
|
||||
onImport: vaultSendActions.importVault,
|
||||
onImportEncryptedRaw: vaultSendActions.importEncryptedRaw,
|
||||
onExport: vaultSendActions.exportVault,
|
||||
@@ -2002,9 +2046,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 +2065,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 +2135,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 +2268,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 +2330,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, ShieldCheck, ShieldUser, SlidersHorizontal, Sparkles, Users } from 'lucide-preact';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Link } from 'wouter';
|
||||
@@ -55,11 +55,12 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
const isDomainRulesRoute = props.location === '/settings/domain-rules';
|
||||
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 vaultActive = props.location === '/vault' || props.location === '/vault/totp' || props.location === '/security/password-health';
|
||||
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);
|
||||
@@ -174,14 +175,15 @@ 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('/security/password-health', props.location === '/security/password-health', <ShieldCheck size={16} />, t('nav_password_security'))}
|
||||
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
|
||||
{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'))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -195,8 +197,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<>
|
||||
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
|
||||
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
|
||||
{renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
|
||||
</>
|
||||
)}
|
||||
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
|
||||
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
||||
{renderNavGroup(
|
||||
'settings',
|
||||
@@ -206,6 +210,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 +231,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'))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -327,6 +331,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<Clock3 size={18} />
|
||||
<span>{t('txt_verification_code')}</span>
|
||||
</Link>
|
||||
<Link href="/generator" className={`mobile-tab ${props.mobilePrimaryRoute === '/generator' ? 'active' : ''}`}>
|
||||
<Sparkles size={18} />
|
||||
<span>{t('nav_generator')}</span>
|
||||
</Link>
|
||||
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
|
||||
<SendIcon size={18} />
|
||||
<span>{t('nav_sends')}</span>
|
||||
|
||||
@@ -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';
|
||||
@@ -11,7 +12,9 @@ export interface AppConfirmState {
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
hideCancel?: boolean;
|
||||
onConfirm: () => void;
|
||||
/** When true, dialog shows a master-password field and passes it to onConfirm. */
|
||||
requireMasterPassword?: boolean;
|
||||
onConfirm: (masterPassword?: string) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
@@ -21,11 +24,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 +43,46 @@ 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 [confirmPassword, setConfirmPassword] = useState('');
|
||||
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;
|
||||
const requireMasterPassword = !!props.confirm?.requireMasterPassword;
|
||||
|
||||
useEffect(() => {
|
||||
setMethodChooserOpen(false);
|
||||
}, [props.pendingTotpOpen, props.pendingTotpProviderType]);
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmPassword('');
|
||||
}, [props.confirm?.title, props.confirm?.message, requireMasterPassword]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
@@ -49,16 +94,43 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
confirmText={props.confirm?.confirmText}
|
||||
cancelText={props.confirm?.cancelText}
|
||||
hideCancel={props.confirm?.hideCancel}
|
||||
onConfirm={() => props.confirm?.onConfirm()}
|
||||
onCancel={props.confirm?.onCancel || props.onCancelConfirm}
|
||||
confirmDisabled={requireMasterPassword && !confirmPassword.trim()}
|
||||
onConfirm={() => {
|
||||
if (requireMasterPassword && !confirmPassword.trim()) return;
|
||||
props.confirm?.onConfirm(requireMasterPassword ? confirmPassword : undefined);
|
||||
setConfirmPassword('');
|
||||
}}
|
||||
onCancel={() => {
|
||||
setConfirmPassword('');
|
||||
(props.confirm?.onCancel || props.onCancelConfirm)();
|
||||
}}
|
||||
>
|
||||
{requireMasterPassword && (
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={confirmPassword}
|
||||
onInput={(e) => setConfirmPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
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 +139,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>
|
||||
)}
|
||||
>
|
||||
{isWebAuthn ? (
|
||||
<p className="muted-inline settings-field-note">{t('txt_touch_your_passkey_when_prompted')}</p>
|
||||
) : (
|
||||
<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)} />
|
||||
<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 +196,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}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { lazy, Suspense } from 'preact/compat';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { Link, Route, Switch } from 'wouter';
|
||||
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldCheck, ShieldUser } from 'lucide-preact';
|
||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||
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'));
|
||||
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||
const PasswordGeneratorPage = lazy(() => import('@/components/PasswordGeneratorPage'));
|
||||
const PasswordSecurityPage = lazy(() => import('@/components/PasswordSecurityPage'));
|
||||
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
|
||||
@@ -39,6 +41,7 @@ export interface AppMainRoutesProps {
|
||||
session: SessionState | null;
|
||||
mobileLayout: boolean;
|
||||
mobileSidebarToggleKey: number;
|
||||
themePreference: 'system' | 'light' | 'dark';
|
||||
importRoute: string;
|
||||
settingsHomeRoute: string;
|
||||
settingsAccountRoute: string;
|
||||
@@ -54,6 +57,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 +71,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 +116,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 +132,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 +170,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>;
|
||||
}
|
||||
@@ -193,6 +209,16 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/security/password-health">
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<PasswordSecurityPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} />
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path="/generator">
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<PasswordGeneratorPage />
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path="/sends">
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<SendsPage
|
||||
@@ -266,12 +292,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 +319,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,12 +332,18 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
</Route>
|
||||
<Route path="/settings">
|
||||
{props.profile ? (
|
||||
<section className="card mobile-settings-card">
|
||||
<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>
|
||||
<Link href="/security/password-health" className="mobile-settings-link">
|
||||
<ShieldCheck size={18} />
|
||||
<span>{t('nav_password_security')}</span>
|
||||
</Link>
|
||||
<Link href="/settings/security/device-management" className="mobile-settings-link">
|
||||
<Shield size={18} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
@@ -305,22 +352,15 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<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="/admin" className="mobile-settings-link">
|
||||
<ShieldUser size={18} />
|
||||
<span>{t('nav_admin_panel')}</span>
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link href="/logs" className="mobile-settings-link">
|
||||
<FileClock size={18} />
|
||||
<span>{t('nav_log_center')}</span>
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link href="/backup" className="mobile-settings-link">
|
||||
<Cloud size={18} />
|
||||
@@ -328,6 +368,23 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
</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')}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { AlertTriangle, ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { getCurrentNetworkStatus, subscribeNetworkStatus, type NetworkStatus } from '@/lib/network-status';
|
||||
|
||||
interface LoginValues {
|
||||
email: string;
|
||||
@@ -81,6 +82,36 @@ function PasswordField(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function OfflineModeNotice() {
|
||||
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
|
||||
|
||||
useEffect(() => subscribeNetworkStatus(setStatus), []);
|
||||
|
||||
if (status !== 'offline') return null;
|
||||
|
||||
return (
|
||||
<div className="offline-mode-notice" role="alert" aria-live="assertive">
|
||||
<div>
|
||||
<strong>{t('txt_offline_mode_notice_title')}</strong>
|
||||
<div className="offline-shortcut-list">
|
||||
<div className="offline-shortcut-row">
|
||||
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_windows')}</span>
|
||||
<span className="offline-shortcut-value">
|
||||
<span className="offline-shortcut-chord"><kbd>Ctrl</kbd><span>+</span><kbd>F5</kbd></span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="offline-shortcut-row">
|
||||
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_macos')}</span>
|
||||
<span className="offline-shortcut-value">
|
||||
<span className="offline-shortcut-chord"><kbd>Command</kbd><span>+</span><kbd>Shift</kbd><span>+</span><kbd>R</kbd></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthViews(props: AuthViewsProps) {
|
||||
const loginBusy = props.pendingAction === 'login';
|
||||
const passkeyBusy = props.pendingAction === 'passkey';
|
||||
@@ -99,6 +130,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
props.onSubmitUnlock();
|
||||
}}
|
||||
>
|
||||
<OfflineModeNotice />
|
||||
<p className="muted standalone-muted">{props.emailForLock}</p>
|
||||
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
|
||||
<PasswordField
|
||||
@@ -245,6 +277,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
props.onSubmitLogin();
|
||||
}}
|
||||
>
|
||||
<OfflineModeNotice />
|
||||
{passkeyPasswordPending ? (
|
||||
<>
|
||||
<p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p>
|
||||
|
||||
@@ -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('');
|
||||
@@ -247,10 +249,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
? t('txt_backup_run_manual')
|
||||
: 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;
|
||||
setLoadingSettings(true);
|
||||
@@ -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;
|
||||
@@ -85,13 +87,16 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const cardRef = useRef<HTMLFormElement | null>(null);
|
||||
const maskPointerStartedRef = useRef(false);
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
const lastTitleRef = useRef<ComponentChildren>(props.title);
|
||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||
const titleId = `${dialogId}-title`;
|
||||
const messageId = `${dialogId}-message`;
|
||||
const hasMessage = !!props.message;
|
||||
const canDismiss = !props.cancelDisabled && !closing;
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open) {
|
||||
lastTitleRef.current = props.title;
|
||||
setPresent(true);
|
||||
setClosing(false);
|
||||
return;
|
||||
@@ -191,7 +196,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,9 +216,24 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<h3 id={titleId} className="dialog-title">{props.title}</h3>
|
||||
<div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
||||
{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.open ? props.title : lastTitleRef.current}</h3>
|
||||
{hasMessage && <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>}
|
||||
{props.children}
|
||||
{!props.hideConfirm && (
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||
@@ -222,6 +242,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
>
|
||||
{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,8 +21,6 @@ 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');
|
||||
|
||||
const isMissing = props.reason === 'missing';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Check, Copy, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { EFFLongWordList } from '@/lib/eff-word-list';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
type GeneratorMode = 'password' | 'passphrase';
|
||||
|
||||
interface PasswordOptions {
|
||||
length: number;
|
||||
uppercase: boolean;
|
||||
lowercase: boolean;
|
||||
numbers: boolean;
|
||||
special: boolean;
|
||||
minNumbers: number;
|
||||
minSpecial: number;
|
||||
avoidAmbiguous: boolean;
|
||||
}
|
||||
|
||||
interface PassphraseOptions {
|
||||
words: number;
|
||||
separator: string;
|
||||
capitalize: boolean;
|
||||
includeNumber: boolean;
|
||||
}
|
||||
|
||||
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v1';
|
||||
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const DIGITS = '0123456789';
|
||||
const SPECIAL = '!@#$%^&*';
|
||||
const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1']);
|
||||
|
||||
const defaultPasswordOptions: PasswordOptions = {
|
||||
length: 14,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
numbers: true,
|
||||
special: false,
|
||||
minNumbers: 1,
|
||||
minSpecial: 1,
|
||||
avoidAmbiguous: false,
|
||||
};
|
||||
|
||||
const defaultPassphraseOptions: PassphraseOptions = {
|
||||
words: 6,
|
||||
separator: '-',
|
||||
capitalize: false,
|
||||
includeNumber: false,
|
||||
};
|
||||
|
||||
function clamp(value: unknown, minimum: number, maximum: number, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, Math.round(parsed))) : fallback;
|
||||
}
|
||||
|
||||
function readSettings(): { mode: GeneratorMode; password: PasswordOptions; passphrase: PassphraseOptions } {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') as Partial<{ mode: GeneratorMode; password: Partial<PasswordOptions>; passphrase: Partial<PassphraseOptions> }>;
|
||||
return {
|
||||
mode: stored.mode === 'passphrase' ? 'passphrase' : 'password',
|
||||
password: {
|
||||
...defaultPasswordOptions,
|
||||
...stored.password,
|
||||
length: clamp(stored.password?.length, 5, 128, defaultPasswordOptions.length),
|
||||
minNumbers: clamp(stored.password?.minNumbers, 0, 9, defaultPasswordOptions.minNumbers),
|
||||
minSpecial: clamp(stored.password?.minSpecial, 0, 9, defaultPasswordOptions.minSpecial),
|
||||
},
|
||||
passphrase: {
|
||||
...defaultPassphraseOptions,
|
||||
...stored.passphrase,
|
||||
words: clamp(stored.passphrase?.words, 3, 20, defaultPassphraseOptions.words),
|
||||
separator: String(stored.passphrase?.separator ?? defaultPassphraseOptions.separator).slice(0, 1),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { mode: 'password', password: defaultPasswordOptions, passphrase: defaultPassphraseOptions };
|
||||
}
|
||||
}
|
||||
|
||||
function randomIndex(length: number): number {
|
||||
const range = 0x1_0000_0000;
|
||||
const upperBound = Math.floor(range / length) * length;
|
||||
const buffer = new Uint32Array(1);
|
||||
do crypto.getRandomValues(buffer); while (buffer[0] >= upperBound);
|
||||
return buffer[0] % length;
|
||||
}
|
||||
|
||||
function pick(characters: string): string {
|
||||
return characters[randomIndex(characters.length)];
|
||||
}
|
||||
|
||||
function shuffle(value: string[]): string[] {
|
||||
for (let index = value.length - 1; index > 0; index -= 1) {
|
||||
const next = randomIndex(index + 1);
|
||||
[value[index], value[next]] = [value[next], value[index]];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function filtered(characters: string, avoidAmbiguous: boolean): string {
|
||||
return avoidAmbiguous ? characters.split('').filter((character) => !AMBIGUOUS.has(character)).join('') : characters;
|
||||
}
|
||||
|
||||
function generatePassword(options: PasswordOptions): string {
|
||||
const sets: Array<{ chars: string; minimum: number }> = [];
|
||||
if (options.uppercase) sets.push({ chars: filtered(UPPERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||
if (options.lowercase) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||
if (options.numbers) sets.push({ chars: filtered(DIGITS, options.avoidAmbiguous), minimum: options.minNumbers });
|
||||
if (options.special) sets.push({ chars: SPECIAL, minimum: options.minSpecial });
|
||||
if (!sets.length) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||
|
||||
const minimumLength = sets.reduce((total, set) => total + set.minimum, 0);
|
||||
const length = Math.max(options.length, minimumLength, 5);
|
||||
const allCharacters = sets.map((set) => set.chars).join('');
|
||||
const characters = sets.flatMap((set) => Array.from({ length: set.minimum }, () => pick(set.chars)));
|
||||
while (characters.length < length) characters.push(pick(allCharacters));
|
||||
return shuffle(characters).join('');
|
||||
}
|
||||
|
||||
function generatePassphrase(options: PassphraseOptions): string {
|
||||
const words = Array.from({ length: options.words }, () => EFFLongWordList[randomIndex(EFFLongWordList.length)]);
|
||||
if (options.capitalize) {
|
||||
for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1);
|
||||
}
|
||||
if (options.includeNumber) words[randomIndex(words.length)] += String(randomIndex(10));
|
||||
return words.join(options.separator);
|
||||
}
|
||||
|
||||
function strengthLabel(mode: GeneratorMode, value: string): { label: string; score: number } {
|
||||
const score = mode === 'password' ? Math.min(4, Math.max(1, Math.floor(value.length / 5))) : Math.min(4, Math.max(1, Math.floor(value.split(/[-_. ]/).filter(Boolean).length / 2)));
|
||||
return { score, label: t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][score - 1]) };
|
||||
}
|
||||
|
||||
export default function PasswordGeneratorPage() {
|
||||
const initial = useMemo(readSettings, []);
|
||||
const [mode, setMode] = useState<GeneratorMode>(initial.mode);
|
||||
const [passwordOptions, setPasswordOptions] = useState<PasswordOptions>(initial.password);
|
||||
const [passphraseOptions, setPassphraseOptions] = useState<PassphraseOptions>(initial.passphrase);
|
||||
const [seed, setSeed] = useState(0);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const generated = useMemo(
|
||||
() => (mode === 'password' ? generatePassword(passwordOptions) : generatePassphrase(passphraseOptions)),
|
||||
[mode, passwordOptions, passphraseOptions, seed]
|
||||
);
|
||||
const strength = useMemo(() => strengthLabel(mode, generated), [generated, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ mode, password: passwordOptions, passphrase: passphraseOptions }));
|
||||
} catch {
|
||||
// The generator remains fully usable when browser storage is unavailable.
|
||||
}
|
||||
}, [mode, passwordOptions, passphraseOptions]);
|
||||
|
||||
const regenerate = () => {
|
||||
setCopied(false);
|
||||
setSeed((value) => value + 1);
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
await copyTextToClipboard(generated, { onSuccess: () => setCopied(true), onError: () => setCopied(false) });
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
|
||||
const changePasswordOption = <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => {
|
||||
setPasswordOptions((current) => ({ ...current, [key]: value }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const changePassphraseOption = <K extends keyof PassphraseOptions>(key: K, value: PassphraseOptions[K]) => {
|
||||
setPassphraseOptions((current) => ({ ...current, [key]: value }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="generator-page" aria-label={t('txt_password_generator')}>
|
||||
<div className="generator-layout">
|
||||
<section className="generator-output-card" aria-live="polite">
|
||||
<div className="settings-category-tabs" role="tablist" aria-label={t('txt_generator_type')}>
|
||||
<button type="button" role="tab" aria-selected={mode === 'password'} className={`settings-category-tab ${mode === 'password' ? 'active' : ''}`} onClick={() => setMode('password')}>{t('txt_password')}</button>
|
||||
<button type="button" role="tab" aria-selected={mode === 'passphrase'} className={`settings-category-tab ${mode === 'passphrase' ? 'active' : ''}`} onClick={() => setMode('passphrase')}>{t('txt_passphrase')}</button>
|
||||
</div>
|
||||
<output className="generator-value" aria-label={t('txt_generated_password')}>{generated}</output>
|
||||
<div className="generator-strength-row">
|
||||
<div className="generator-strength" aria-label={`${t('txt_password_strength')}: ${strength.label}`}>
|
||||
{[1, 2, 3, 4].map((level) => <span key={level} className={level <= strength.score ? `active level-${strength.score}` : ''} />)}
|
||||
</div>
|
||||
<span><ShieldCheck size={15} /> {strength.label}</span>
|
||||
</div>
|
||||
<div className="actions generator-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={regenerate}><RefreshCw size={16} className="btn-icon" />{t('txt_regenerate')}</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void copy()}><Copy size={16} className="btn-icon" />{copied ? t('txt_copied') : t('txt_copy')}</button>
|
||||
</div>
|
||||
<p className="generator-security-note"><Check size={15} />{t('txt_generator_security_note')}</p>
|
||||
</section>
|
||||
|
||||
<section className="generator-options-card" aria-labelledby="generator-options-title">
|
||||
<h2 id="generator-options-title">{t('txt_options')}</h2>
|
||||
{mode === 'password' ? (
|
||||
<>
|
||||
<GeneratorNumberStepper id="length" label={t('txt_generator_length')} value={passwordOptions.length} minimum={5} maximum={128} fallback={14} onChange={(value) => changePasswordOption('length', value)} />
|
||||
<fieldset className="generator-option-group"><legend>{t('txt_generator_character_types')}</legend>
|
||||
<GeneratorToggle checked={passwordOptions.uppercase} onChange={(checked) => changePasswordOption('uppercase', checked)} label={t('txt_generator_uppercase')} />
|
||||
<GeneratorToggle checked={passwordOptions.lowercase} onChange={(checked) => changePasswordOption('lowercase', checked)} label={t('txt_generator_lowercase')} />
|
||||
<GeneratorToggle checked={passwordOptions.numbers} onChange={(checked) => changePasswordOption('numbers', checked)} label={t('txt_generator_numbers')} />
|
||||
{passwordOptions.numbers && <GeneratorNumberStepper id="min-numbers" compact label={t('txt_generator_minimum')} value={passwordOptions.minNumbers} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minNumbers', value)} />}
|
||||
<GeneratorToggle checked={passwordOptions.special} onChange={(checked) => changePasswordOption('special', checked)} label={t('txt_generator_special')} />
|
||||
{passwordOptions.special && <GeneratorNumberStepper id="min-special" compact label={t('txt_generator_minimum')} value={passwordOptions.minSpecial} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minSpecial', value)} />}
|
||||
</fieldset>
|
||||
<GeneratorToggle checked={passwordOptions.avoidAmbiguous} onChange={(checked) => changePasswordOption('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={passphraseOptions.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
|
||||
<label className="generator-number-field" htmlFor="generator-separator"><span>{t('txt_generator_separator')}</span><input id="generator-separator" className="input" type="text" maxLength={1} value={passphraseOptions.separator} onInput={(event) => changePassphraseOption('separator', event.currentTarget.value.slice(0, 1))} /></label>
|
||||
<div className="generator-option-group">
|
||||
<GeneratorToggle checked={passphraseOptions.capitalize} onChange={(checked) => changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} />
|
||||
<GeneratorToggle checked={passphraseOptions.includeNumber} onChange={(checked) => changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratorToggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) {
|
||||
return <label className="generator-toggle"><input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.currentTarget.checked)} /><span aria-hidden="true" /><strong>{props.label}</strong></label>;
|
||||
}
|
||||
|
||||
function GeneratorNumberStepper(props: { id: string; label: string; value: number; minimum: number; maximum: number; fallback: number; compact?: boolean; onChange: (value: number) => void }) {
|
||||
const id = `generator-stepper-${props.id}`;
|
||||
const setValue = (value: number) => props.onChange(clamp(value, props.minimum, props.maximum, props.fallback));
|
||||
return (
|
||||
<div className={`generator-number-field ${props.compact ? 'compact' : ''}`}>
|
||||
<label htmlFor={id}>{props.label}</label>
|
||||
<div className="generator-stepper">
|
||||
<button type="button" aria-label={`${props.label} -`} disabled={props.value <= props.minimum} onClick={() => setValue(props.value - 1)}><Minus size={15} /></button>
|
||||
<input id={id} className="input" type="text" inputMode="numeric" pattern="[0-9]*" value={props.value} onInput={(event) => setValue(Number(event.currentTarget.value))} />
|
||||
<button type="button" aria-label={`${props.label} +`} disabled={props.value >= props.maximum} onClick={() => setValue(props.value + 1)}><Plus size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { AlertTriangle, CheckCircle2, ExternalLink, Eye, EyeOff, RefreshCw, ScanSearch, ShieldAlert, ShieldCheck, Unplug } from 'lucide-preact';
|
||||
import { Link } from 'wouter';
|
||||
import { maskSecret } from '@/components/vault/vault-page-helpers';
|
||||
import { getPasswordSecurityState, readPasswordSecurityState, startPasswordSecurityScan, subscribePasswordSecurityState } from '@/lib/password-security-cache';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
|
||||
interface PasswordSecurityPageProps {
|
||||
ciphers: Cipher[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
type PasswordSecurityFilter = 'exposed' | 'reused' | 'weak' | 'all';
|
||||
|
||||
function vaultFingerprint(ciphers: Cipher[]): string {
|
||||
return JSON.stringify(ciphers.map((cipher) => ({
|
||||
id: cipher.id,
|
||||
type: cipher.type,
|
||||
revisionDate: cipher.revisionDate || '',
|
||||
deletedDate: cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt || '',
|
||||
})));
|
||||
}
|
||||
|
||||
function formatCheckedAt(value: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(value);
|
||||
}
|
||||
|
||||
export default function PasswordSecurityPage(props: PasswordSecurityPageProps) {
|
||||
const fingerprint = vaultFingerprint(props.ciphers);
|
||||
const [securityState, setSecurityState] = useState(() => getPasswordSecurityState(fingerprint));
|
||||
const [filter, setFilter] = useState<PasswordSecurityFilter>('all');
|
||||
const [revealedPasswordIds, setRevealedPasswordIds] = useState<Set<string>>(() => new Set());
|
||||
useEffect(() => {
|
||||
setSecurityState(getPasswordSecurityState(fingerprint));
|
||||
setFilter('all');
|
||||
setRevealedPasswordIds(new Set());
|
||||
return subscribePasswordSecurityState(() => {
|
||||
const next = readPasswordSecurityState(fingerprint);
|
||||
if (next) setSecurityState(next);
|
||||
});
|
||||
}, [fingerprint]);
|
||||
|
||||
const { report, scannedAt, scanning, progress, scanError } = securityState;
|
||||
|
||||
const eligibleCount = useMemo(
|
||||
() => props.ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length,
|
||||
[props.ciphers],
|
||||
);
|
||||
const ciphersById = useMemo(() => new Map(props.ciphers.map((cipher) => [cipher.id, cipher])), [props.ciphers]);
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!report || filter === 'all') return report?.items || [];
|
||||
if (filter === 'exposed') return report.items.filter((item) => (item.exposedCount || 0) > 0);
|
||||
if (filter === 'reused') return report.items.filter((item) => item.reusedCount > 1);
|
||||
return report.items.filter((item) => item.weak);
|
||||
}, [filter, report]);
|
||||
const allPasswordsVisible = !!report?.items.length && report.items.every((item) => revealedPasswordIds.has(item.cipherId));
|
||||
|
||||
const togglePasswordVisibility = (cipherId: string) => {
|
||||
setRevealedPasswordIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(cipherId)) next.delete(cipherId);
|
||||
else next.add(cipherId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAllPasswordVisibility = () => {
|
||||
if (!report) return;
|
||||
setRevealedPasswordIds(allPasswordsVisible ? new Set() : new Set(report.items.map((item) => item.cipherId)));
|
||||
};
|
||||
|
||||
const scan = () => {
|
||||
setRevealedPasswordIds(new Set());
|
||||
setFilter('all');
|
||||
startPasswordSecurityScan(fingerprint, props.ciphers);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="password-security-page" aria-label={t('txt_password_security')}>
|
||||
<div className="password-security-intro card">
|
||||
<div className="password-security-intro-icon"><ShieldCheck size={22} /></div>
|
||||
<div>
|
||||
<h2>{t('txt_password_security')}</h2>
|
||||
<p>{t('txt_password_security_privacy')}</p>
|
||||
{scannedAt && <p className="password-security-checked-at">{t('txt_password_security_last_checked', { value: formatCheckedAt(scannedAt) })}</p>}
|
||||
</div>
|
||||
<div className="password-security-intro-actions">
|
||||
{report && <button type="button" className="btn btn-secondary password-security-toggle-all" onClick={toggleAllPasswordVisibility}>
|
||||
{allPasswordsVisible ? <EyeOff size={16} className="btn-icon" /> : <Eye size={16} className="btn-icon" />}
|
||||
{allPasswordsVisible ? t('txt_password_security_hide_all') : t('txt_password_security_show_all')}
|
||||
</button>}
|
||||
<button type="button" className="btn btn-primary password-security-scan" disabled={props.loading || scanning || eligibleCount === 0} onClick={scan}>
|
||||
{scanning ? <RefreshCw size={16} className="btn-icon spin" /> : <ScanSearch size={16} className="btn-icon" />}
|
||||
{scanning ? t('txt_checking_password_security') : report ? t('txt_recheck_password_security') : t('txt_check_password_security')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!report && !scanning && !props.loading && (
|
||||
<div className="password-security-empty card">
|
||||
<ShieldCheck size={26} aria-hidden="true" />
|
||||
<strong>{eligibleCount ? t('txt_password_security_ready') : t('txt_password_security_no_login')}</strong>
|
||||
<span>{eligibleCount ? t('txt_password_security_manual') : t('txt_password_security_no_login_help')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(scanning || report) && (
|
||||
<div className="password-security-summary" aria-live="polite">
|
||||
<SecurityMetric icon={<ShieldAlert size={18} />} tone="danger" label={t('txt_exposed_passwords')} value={report?.exposedCount ?? 0} active={filter === 'exposed'} disabled={!report} onClick={() => setFilter('exposed')} />
|
||||
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_reused_passwords')} value={report?.reusedCount ?? 0} active={filter === 'reused'} disabled={!report} onClick={() => setFilter('reused')} />
|
||||
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_weak_passwords')} value={report?.weakCount ?? 0} active={filter === 'weak'} disabled={!report} onClick={() => setFilter('weak')} />
|
||||
<SecurityMetric icon={<CheckCircle2 size={18} />} tone="primary" label={t('txt_passwords_checked')} value={`${scanning ? progress.checked : report?.checkedCount || 0} / ${scanning ? progress.total : report?.eligibleCount || 0}`} active={filter === 'all'} disabled={!report} onClick={() => setFilter('all')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scanError && <div className="password-security-notice warning card" role="alert"><Unplug size={16} />{t('txt_password_security_check_failed')}</div>}
|
||||
|
||||
{report && (
|
||||
<section className="password-security-results card">
|
||||
{report.unavailableCount > 0 && (
|
||||
<div className="password-security-notice warning"><Unplug size={16} />{t('txt_password_security_unavailable', { count: report.unavailableCount })}</div>
|
||||
)}
|
||||
{!report.items.length ? (
|
||||
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks')}</strong></div>
|
||||
) : !filteredItems.length ? (
|
||||
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks_in_filter')}</strong></div>
|
||||
) : (
|
||||
<div className="password-security-list">
|
||||
{filteredItems.map((item) => {
|
||||
const cipher = ciphersById.get(item.cipherId);
|
||||
const name = String(cipher?.decName || cipher?.name || '');
|
||||
const password = String(cipher?.login?.decPassword || '');
|
||||
const passwordVisible = revealedPasswordIds.has(item.cipherId);
|
||||
return <article className="password-security-item" key={item.cipherId}>
|
||||
<div className="password-security-item-main">
|
||||
<div className="password-security-item-header">
|
||||
<strong>{name || t('txt_no_name')}</strong>
|
||||
<div className="password-security-badges">
|
||||
{item.exposedCount === null && <span className="risk-badge muted">{t('txt_password_security_not_checked')}</span>}
|
||||
{(item.exposedCount || 0) > 0 && <span className="risk-badge danger">{t('txt_password_security_exposed_short', { count: item.exposedCount || 0 })}</span>}
|
||||
{item.weak && <span className="risk-badge weak">{t('txt_password_security_weak_short')}</span>}
|
||||
{item.reusedCount > 1 && <span className="risk-badge reused">{t('txt_password_security_reused_short')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="password-security-password">{passwordVisible ? password : maskSecret(password)}</span>
|
||||
</div>
|
||||
<div className="password-security-item-actions">
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => togglePasswordVisibility(item.cipherId)}>
|
||||
{passwordVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
||||
{passwordVisible ? t('txt_hide') : t('txt_reveal')}
|
||||
</button>
|
||||
<Link href={`/vault?cipher=${encodeURIComponent(item.cipherId)}`} className="btn btn-secondary small password-security-open">
|
||||
<ExternalLink size={14} className="btn-icon" />{t('txt_password_security_jump')}
|
||||
</Link>
|
||||
</div>
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityMetric(props: { icon: preact.ComponentChildren; tone: 'danger' | 'warning' | 'primary'; label: string; value: string | number; active: boolean; disabled: boolean; onClick: () => void }) {
|
||||
return <button type="button" className={`password-security-metric ${props.tone}`} aria-pressed={props.active} disabled={props.disabled} onClick={props.onClick}><span>{props.icon}</span><div><strong>{props.value}</strong><small>{props.label}</small></div></button>;
|
||||
}
|
||||
@@ -53,6 +53,13 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function formatSendDate(value: string | null | undefined): string {
|
||||
if (!value) return '';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return '';
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
@@ -283,7 +290,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: sendData.expirationDate })}</p>}
|
||||
{!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: formatSendDate(sendData.expirationDate) })}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -32,6 +32,13 @@ function daysFromNow(iso: string | null | undefined, fallback: number): string {
|
||||
return String(Math.max(days, 0));
|
||||
}
|
||||
|
||||
function formatSendDate(value: string | null | undefined): string {
|
||||
if (!value) return t('txt_dash');
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return t('txt_dash');
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function buildDefaultDraft(): SendDraft {
|
||||
return {
|
||||
type: 'text',
|
||||
@@ -43,6 +50,7 @@ function buildDefaultDraft(): SendDraft {
|
||||
expirationDays: '0',
|
||||
maxAccessCount: '',
|
||||
password: '',
|
||||
hasPassword: false,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
@@ -59,6 +67,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 +389,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 +481,23 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_password')}</span>
|
||||
{draft.hasPassword ? (
|
||||
<div className="password-wrap">
|
||||
<input className="input" type="password" value="••••••••" disabled />
|
||||
{!isCreating && (
|
||||
<button type="button" className="password-toggle text-red-600 hover:text-red-700" onClick={() => setDraft({ ...draft, hasPassword: false, password: '' })} title={t('txt_remove')}>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="password-wrap">
|
||||
<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>
|
||||
@@ -523,8 +544,8 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
<div className="card stagger-item stagger-delay-2">
|
||||
<h4>{t('txt_send_details')}</h4>
|
||||
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{selectedSend.expirationDate || t('txt_dash')}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{formatSendDate(selectedSend.deletionDate)}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{formatSendDate(selectedSend.expirationDate)}</strong></div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
|
||||
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';
|
||||
@@ -84,6 +87,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
||||
const [selectedCipherId, setSelectedCipherId] = useState('');
|
||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||
const pendingFocusCipherIdRef = useRef<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [createMenuOpen, setCreateMenuOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -106,7 +110,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 +312,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),
|
||||
@@ -405,7 +420,36 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
return !!meta?.searchText.includes(searchQuery);
|
||||
});
|
||||
|
||||
// Pre-compute group min name for duplicates group ordering
|
||||
const groupMinName = new Map<string, string>();
|
||||
if (sidebarFilter.kind === 'duplicates' && duplicateSignatureInfo) {
|
||||
for (const cipher of next) {
|
||||
const gk = (duplicateSignatureInfo.byId.get(cipher.id) || [])
|
||||
.filter(s => (duplicateSignatureInfo.counts.get(s) || 0) >= 2)
|
||||
.sort()[0] || '';
|
||||
if (!gk) continue;
|
||||
const name = cipherMetaById.get(cipher.id)?.name || '';
|
||||
const cur = groupMinName.get(gk);
|
||||
if (!cur || nameCollator.compare(name, cur) < 0) groupMinName.set(gk, name);
|
||||
}
|
||||
}
|
||||
|
||||
next.sort((a, b) => {
|
||||
// Duplicates view: group by color, sort A-Z within each group
|
||||
if (sidebarFilter.kind === 'duplicates' && duplicateSignatureInfo) {
|
||||
const gk = (id: string) => (duplicateSignatureInfo.byId.get(id) || [])
|
||||
.filter(s => (duplicateSignatureInfo.counts.get(s) || 0) >= 2)
|
||||
.sort()[0] || '';
|
||||
const gA = gk(a.id), gB = gk(b.id);
|
||||
if (gA !== gB) return !gA ? 1 : !gB ? -1 : nameCollator.compare(
|
||||
groupMinName.get(gA) || '', groupMinName.get(gB) || ''
|
||||
) || (gA < gB ? -1 : 1);
|
||||
return nameCollator.compare(
|
||||
cipherMetaById.get(a.id)?.name || '',
|
||||
cipherMetaById.get(b.id)?.name || ''
|
||||
) || String(a.id || '').localeCompare(String(b.id || ''));
|
||||
}
|
||||
|
||||
const metaA = cipherMetaById.get(a.id);
|
||||
const metaB = cipherMetaById.get(b.id);
|
||||
if (sortMode === 'edited') {
|
||||
@@ -454,8 +498,59 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
|
||||
}, [sidebarFilter.kind, duplicateMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const focusId = String(new URLSearchParams(window.location.search || '').get('cipher') || '').trim();
|
||||
if (!focusId) return;
|
||||
pendingFocusCipherIdRef.current = focusId;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const focusId = pendingFocusCipherIdRef.current;
|
||||
if (!focusId) return;
|
||||
const cipher = cipherById.get(focusId);
|
||||
if (!cipher) {
|
||||
if (!props.loading && props.ciphers.length > 0) pendingFocusCipherIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFilter: SidebarFilter = isCipherVisibleInTrash(cipher)
|
||||
? { kind: 'trash' }
|
||||
: isCipherVisibleInArchive(cipher)
|
||||
? { kind: 'archive' }
|
||||
: { kind: 'all' };
|
||||
setSidebarFilter((prev) => (prev.kind === nextFilter.kind ? prev : nextFilter));
|
||||
setSearchInput('');
|
||||
setSearchQuery('');
|
||||
setIsEditing(false);
|
||||
setIsCreating(false);
|
||||
setDraft(null);
|
||||
}, [cipherById, props.ciphers.length, props.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreating) return;
|
||||
|
||||
const focusId = pendingFocusCipherIdRef.current;
|
||||
if (focusId) {
|
||||
if (!filteredCipherIds.has(focusId)) return;
|
||||
setSelectedCipherId(focusId);
|
||||
setRepromptApprovedCipherId(null);
|
||||
setShowPassword(false);
|
||||
setHiddenFieldVisibleMap({});
|
||||
if (isMobileLayout) setMobilePanel('detail');
|
||||
setMobileSidebarOpen(false);
|
||||
pendingFocusCipherIdRef.current = null;
|
||||
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has('cipher')) {
|
||||
url.searchParams.delete('cipher');
|
||||
const next = `${url.pathname}${url.search}${url.hash}`;
|
||||
window.history.replaceState(null, '', next || '/vault');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filteredCiphers.length) {
|
||||
if (selectedCipherId) setSelectedCipherId('');
|
||||
return;
|
||||
@@ -463,7 +558,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
|
||||
setSelectedCipherId(filteredCiphers[0].id);
|
||||
}
|
||||
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]);
|
||||
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating, isMobileLayout]);
|
||||
|
||||
const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
|
||||
const virtualRange = useMemo(() => {
|
||||
@@ -542,6 +637,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]);
|
||||
|
||||
@@ -1032,6 +1130,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
}
|
||||
setSelectedMap(map);
|
||||
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
|
||||
const handleSelectUniqueFromDuplicates = useCallback(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
const seen = new Set<number>();
|
||||
for (const cipher of filteredCiphers) {
|
||||
const groupIndex = duplicateGroupIndexById.get(cipher.id);
|
||||
if (groupIndex === undefined) continue;
|
||||
if (seen.has(groupIndex)) {
|
||||
map[cipher.id] = true;
|
||||
} else {
|
||||
seen.add(groupIndex);
|
||||
}
|
||||
}
|
||||
setSelectedMap(map);
|
||||
}, [filteredCiphers, duplicateGroupIndexById]);
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
||||
@@ -1146,6 +1258,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
onSyncVault={handleSyncVault}
|
||||
onOpenBulkDelete={handleOpenBulkDelete}
|
||||
onSelectDuplicates={handleSelectDuplicates}
|
||||
onSelectUniqueFromDuplicates={handleSelectUniqueFromDuplicates}
|
||||
onSelectAll={handleSelectAll}
|
||||
onToggleCreateMenu={handleToggleCreateMenu}
|
||||
onStartCreate={startCreate}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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 { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { AlertTriangle, Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RefreshCw, RotateCcw, ShieldCheck, ShieldAlert, Trash2, X } from 'lucide-preact';
|
||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||
import type { TotpCodeResult } from '@/lib/crypto';
|
||||
import { checkPasswordLeaked, type PasswordBreachResult } from '@/lib/password-security';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
CardBrandIcon,
|
||||
TOTP_PERIOD_SECONDS,
|
||||
TOTP_RING_CIRCUMFERENCE,
|
||||
VaultListIcon,
|
||||
copyToClipboard,
|
||||
@@ -21,11 +22,15 @@ import {
|
||||
toBooleanFieldValue,
|
||||
} from '@/components/vault/vault-page-helpers';
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return !!error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError';
|
||||
}
|
||||
|
||||
interface VaultDetailViewProps {
|
||||
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 +47,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 }>;
|
||||
@@ -85,6 +95,9 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
||||
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
||||
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
|
||||
const [breachResult, setBreachResult] = useState<PasswordBreachResult | null>(null);
|
||||
const [checkingBreach, setCheckingBreach] = useState(false);
|
||||
const breachControllerRef = useRef<AbortController | null>(null);
|
||||
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
|
||||
const isDeleted = isCipherDeleted(props.selectedCipher);
|
||||
const passwordHistoryEntries = useMemo(
|
||||
@@ -98,9 +111,39 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
[props.selectedCipher.passwordHistory]
|
||||
);
|
||||
useEffect(() => {
|
||||
breachControllerRef.current?.abort();
|
||||
breachControllerRef.current = null;
|
||||
setShowSshPrivateKey(false);
|
||||
setPasswordHistoryOpen(false);
|
||||
}, [props.selectedCipher.id]);
|
||||
setBreachResult(null);
|
||||
setCheckingBreach(false);
|
||||
return () => {
|
||||
breachControllerRef.current?.abort();
|
||||
breachControllerRef.current = null;
|
||||
};
|
||||
}, [props.selectedCipher.id, props.selectedCipher.login?.decPassword]);
|
||||
const checkBreach = async () => {
|
||||
const password = String(props.selectedCipher.login?.decPassword || '');
|
||||
if (!password) return;
|
||||
breachControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
breachControllerRef.current = controller;
|
||||
setCheckingBreach(true);
|
||||
setBreachResult(null);
|
||||
try {
|
||||
const result = await checkPasswordLeaked(password, fetch, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setBreachResult(result);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || isAbortError(error)) return;
|
||||
setBreachResult({ count: null, available: false });
|
||||
} finally {
|
||||
if (breachControllerRef.current === controller) {
|
||||
breachControllerRef.current = null;
|
||||
setCheckingBreach(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
const formatDownloadLabel = (attachmentId: string) => {
|
||||
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||
@@ -167,8 +210,18 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}>
|
||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" disabled={checkingBreach || !props.selectedCipher.login?.decPassword} onClick={() => void checkBreach()}>
|
||||
{checkingBreach ? <RefreshCw size={14} className="btn-icon spin" /> : <ShieldCheck size={14} className="btn-icon" />}
|
||||
{checkingBreach ? t('txt_checking_password_security') : t('txt_check_password_breach')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{breachResult && (
|
||||
<div className={`password-breach-inline ${breachResult.available ? (breachResult.count ? 'danger' : 'safe') : 'warning'}`} role="status">
|
||||
{breachResult.available ? (breachResult.count ? <ShieldAlert size={15} /> : <ShieldCheck size={15} />) : <AlertTriangle size={15} />}
|
||||
<span>{breachResult.available ? (breachResult.count ? t('txt_password_exposed_count', { count: breachResult.count }) : t('txt_password_not_found_in_breaches')) : t('txt_password_security_check_failed')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!!props.selectedCipher.login.decTotp && (
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_totp')}</span>
|
||||
@@ -191,8 +244,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||
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 +379,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) return false;
|
||||
return applyTotpQrValue(value);
|
||||
if (value && applyTotpQrValue(value)) return true;
|
||||
} catch {
|
||||
// Fall back to jsQR when the native detector is present but not usable.
|
||||
}
|
||||
}
|
||||
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 () => {
|
||||
@@ -229,9 +257,26 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
totpQrFrameRef.current = window.requestAnimationFrame(scan);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let value = '';
|
||||
if (detector) {
|
||||
try {
|
||||
const results = await detector.detect(video);
|
||||
const value = String(results[0]?.rawValue || '').trim();
|
||||
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>
|
||||
|
||||
@@ -81,6 +81,7 @@ interface VaultListPanelProps {
|
||||
onSyncVault: () => void;
|
||||
onOpenBulkDelete: () => void;
|
||||
onSelectDuplicates: () => void;
|
||||
onSelectUniqueFromDuplicates: () => void;
|
||||
onSelectAll: () => void;
|
||||
onToggleCreateMenu: () => void;
|
||||
onStartCreate: (type: number) => void;
|
||||
@@ -319,13 +320,17 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="search-input-wrap">
|
||||
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
||||
<div className="duplicate-mode-head-menu">
|
||||
<div className="duplicate-mode-head-menu mobile-duplicate-toolbar">
|
||||
<div className="mobile-duplicate-mode-select-wrap">
|
||||
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onSelectUniqueFromDuplicates}>
|
||||
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="search-input-wrap">
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
|
||||
@@ -350,9 +355,8 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
||||
<div className="duplicate-mode-head-menu">
|
||||
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||
@@ -387,7 +391,13 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||
</button>
|
||||
{!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu}
|
||||
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout ? (
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onSelectUniqueFromDuplicates}>
|
||||
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
||||
</button>
|
||||
) : (
|
||||
!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RefObject } from 'preact';
|
||||
import {
|
||||
Archive,
|
||||
ArrowUpDown,
|
||||
BookUser,
|
||||
Check,
|
||||
Copy,
|
||||
CreditCard,
|
||||
@@ -10,15 +11,19 @@ import {
|
||||
FolderPlus,
|
||||
FolderX,
|
||||
Globe,
|
||||
IdCard,
|
||||
KeyRound,
|
||||
Landmark,
|
||||
LayoutGrid,
|
||||
Pencil,
|
||||
ShieldUser,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
StickyNote,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-preact';
|
||||
import { Link } from 'wouter';
|
||||
import type { Folder } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers';
|
||||
@@ -92,6 +97,9 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar-block">
|
||||
<Link href="/security/password-health" className="tree-btn">
|
||||
<ShieldCheck size={14} className="tree-icon" /> <span className="tree-label">{t('nav_password_security')}</span>
|
||||
</Link>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'all' })}>
|
||||
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
|
||||
</button>
|
||||
@@ -117,9 +125,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 || '');
|
||||
@@ -445,13 +561,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
openRemoveAllDevices() {
|
||||
onSetConfirm({
|
||||
title: t('txt_remove_all_devices'),
|
||||
message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
|
||||
message: `${t('txt_remove_all_devices_and_sign_out_all_sessions')}\n${t('txt_enter_master_password_to_continue')}`,
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
requireMasterPassword: true,
|
||||
onConfirm: (masterPassword) => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await deleteAllAuthorizedDevices(authedFetch);
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalizedPassword = String(masterPassword || '');
|
||||
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
|
||||
await deleteAllAuthorizedDevices(authedFetch, derived.hash);
|
||||
onNotify('success', t('txt_all_devices_removed'));
|
||||
onLogoutNow();
|
||||
} catch (error) {
|
||||
@@ -476,7 +597,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
session?.symEncKey,
|
||||
session?.symMacKey,
|
||||
refetchAuthorizedDevices,
|
||||
refetchTotpStatus,
|
||||
refetchTwoFactorStatus,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin';
|
||||
import { deriveLoginHash } from '@/lib/api/auth';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||
import type { AuthedFetch } from '@/lib/api/shared';
|
||||
@@ -8,6 +9,8 @@ type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
|
||||
interface UseAdminActionsOptions {
|
||||
authedFetch: AuthedFetch;
|
||||
email: string;
|
||||
defaultKdfIterations: number;
|
||||
onNotify: Notify;
|
||||
onSetConfirm: (next: AppConfirmState | null) => void;
|
||||
refetchUsers: () => Promise<unknown>;
|
||||
@@ -15,7 +18,24 @@ interface UseAdminActionsOptions {
|
||||
}
|
||||
|
||||
export default function useAdminActions(options: UseAdminActionsOptions) {
|
||||
const { authedFetch, onNotify, onSetConfirm, refetchUsers, refetchInvites } = options;
|
||||
const {
|
||||
authedFetch,
|
||||
email,
|
||||
defaultKdfIterations,
|
||||
onNotify,
|
||||
onSetConfirm,
|
||||
refetchUsers,
|
||||
refetchInvites,
|
||||
} = options;
|
||||
|
||||
async function withMasterPasswordHash(masterPassword: string | undefined): Promise<string> {
|
||||
const normalizedEmail = String(email || '').trim().toLowerCase();
|
||||
const normalizedPassword = String(masterPassword || '');
|
||||
if (!normalizedEmail) throw new Error(t('txt_profile_unavailable'));
|
||||
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(normalizedEmail, normalizedPassword, defaultKdfIterations);
|
||||
return derived.hash;
|
||||
}
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
@@ -26,35 +46,61 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
||||
},
|
||||
|
||||
async createInvite(hours: number) {
|
||||
onSetConfirm({
|
||||
title: t('txt_create_timed_invite'),
|
||||
message: t('txt_enter_master_password_to_continue'),
|
||||
requireMasterPassword: true,
|
||||
onConfirm: (masterPassword) => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await createInvite(authedFetch, hours);
|
||||
const hash = await withMasterPasswordHash(masterPassword);
|
||||
await createInvite(authedFetch, hours, hash);
|
||||
await refetchInvites();
|
||||
onNotify('success', t('txt_invite_created'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async toggleUserStatus(userId: string, status: 'active' | 'banned') {
|
||||
const nextStatus = status === 'active' ? 'banned' : 'active';
|
||||
onSetConfirm({
|
||||
title: nextStatus === 'banned' ? t('txt_ban') : t('txt_unban'),
|
||||
message: t('txt_enter_master_password_to_continue'),
|
||||
danger: nextStatus === 'banned',
|
||||
requireMasterPassword: true,
|
||||
onConfirm: (masterPassword) => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
||||
const hash = await withMasterPasswordHash(masterPassword);
|
||||
await setUserStatus(authedFetch, userId, nextStatus, hash);
|
||||
await refetchUsers();
|
||||
onNotify('success', t('txt_user_status_updated'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async deleteInvite(code: string) {
|
||||
onSetConfirm({
|
||||
title: t('txt_delete_invite'),
|
||||
message: t('txt_delete_invite_confirm_message'),
|
||||
message: `${t('txt_delete_invite_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
requireMasterPassword: true,
|
||||
onConfirm: (masterPassword) => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await deleteInvite(authedFetch, code);
|
||||
const hash = await withMasterPasswordHash(masterPassword);
|
||||
await deleteInvite(authedFetch, code, hash);
|
||||
await refetchInvites();
|
||||
onNotify('success', t('txt_invite_deleted'));
|
||||
} catch (error) {
|
||||
@@ -68,13 +114,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
||||
async deleteInvalidInvites() {
|
||||
onSetConfirm({
|
||||
title: t('txt_delete_invalid_invites'),
|
||||
message: t('txt_delete_invalid_invites_confirm_message'),
|
||||
message: `${t('txt_delete_invalid_invites_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
requireMasterPassword: true,
|
||||
onConfirm: (masterPassword) => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await deleteInvalidInvites(authedFetch);
|
||||
const hash = await withMasterPasswordHash(masterPassword);
|
||||
await deleteInvalidInvites(authedFetch, hash);
|
||||
await refetchInvites();
|
||||
onNotify('success', t('txt_invalid_invites_deleted'));
|
||||
} catch (error) {
|
||||
@@ -88,13 +136,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
||||
async deleteAllInvites() {
|
||||
onSetConfirm({
|
||||
title: t('txt_delete_all_invites'),
|
||||
message: t('txt_delete_all_invite_codes_active_inactive'),
|
||||
message: `${t('txt_delete_all_invite_codes_active_inactive')}\n${t('txt_enter_master_password_to_continue')}`,
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
requireMasterPassword: true,
|
||||
onConfirm: (masterPassword) => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await deleteAllInvites(authedFetch);
|
||||
const hash = await withMasterPasswordHash(masterPassword);
|
||||
await deleteAllInvites(authedFetch, hash);
|
||||
await refetchInvites();
|
||||
onNotify('success', t('txt_all_invites_deleted'));
|
||||
} catch (error) {
|
||||
@@ -108,13 +158,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
||||
async deleteUser(userId: string) {
|
||||
onSetConfirm({
|
||||
title: t('txt_delete_user'),
|
||||
message: t('txt_delete_this_user_and_all_user_data'),
|
||||
message: `${t('txt_delete_this_user_and_all_user_data')}\n${t('txt_enter_master_password_to_continue')}`,
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
requireMasterPassword: true,
|
||||
onConfirm: (masterPassword) => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await deleteUser(authedFetch, userId);
|
||||
const hash = await withMasterPasswordHash(masterPassword);
|
||||
await deleteUser(authedFetch, userId, hash);
|
||||
await refetchUsers();
|
||||
onNotify('success', t('txt_user_deleted'));
|
||||
} catch (error) {
|
||||
@@ -125,6 +177,6 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
||||
});
|
||||
},
|
||||
}),
|
||||
[authedFetch, onNotify, onSetConfirm, refetchInvites, refetchUsers]
|
||||
[authedFetch, defaultKdfIterations, email, onNotify, onSetConfirm, refetchInvites, refetchUsers]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface PendingAccountPasskeyCredential {
|
||||
deviceResponse: PublicKeyCredential;
|
||||
request: Record<string, unknown>;
|
||||
supportsPrf: boolean;
|
||||
prfKey?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface AccountPasskeyPrfKeySet {
|
||||
@@ -82,20 +83,9 @@ async function getLoginWithPrfSalt(): Promise<Uint8Array> {
|
||||
return new Uint8Array(hash);
|
||||
}
|
||||
|
||||
function credentialIdToBase64Url(id: BufferSource): string | null {
|
||||
try {
|
||||
const bytes = id instanceof ArrayBuffer
|
||||
? new Uint8Array(id)
|
||||
: new Uint8Array(id.buffer, id.byteOffset, id.byteLength);
|
||||
return bytesToBase64Url(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type PrfEvalInput = { first: Uint8Array };
|
||||
|
||||
function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> {
|
||||
function buildPrfExtension(salt: Uint8Array): Record<string, unknown> {
|
||||
const evalInput: PrfEvalInput = { first: salt };
|
||||
return {
|
||||
prf: {
|
||||
@@ -104,34 +94,23 @@ function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function buildCredentialPrfExtension(
|
||||
salt: Uint8Array,
|
||||
credentialIds: Array<string | null | undefined>
|
||||
): Record<string, unknown> {
|
||||
const evalInput = { first: salt };
|
||||
const evalByCredential = credentialIds
|
||||
.filter((id): id is string => !!id)
|
||||
.reduce<Record<string, PrfEvalInput>>((out, id) => {
|
||||
out[id] = evalInput;
|
||||
return out;
|
||||
}, {});
|
||||
if (!Object.keys(evalByCredential).length) return buildLegacyPrfExtension(salt);
|
||||
return {
|
||||
prf: {
|
||||
evalByCredential,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withPrfExtension(
|
||||
options: PublicKeyCredentialCreationOptions,
|
||||
salt: Uint8Array
|
||||
): PublicKeyCredentialCreationOptions;
|
||||
function withPrfExtension(
|
||||
options: PublicKeyCredentialRequestOptions,
|
||||
extension: Record<string, unknown>
|
||||
): PublicKeyCredentialRequestOptions {
|
||||
salt: Uint8Array
|
||||
): PublicKeyCredentialRequestOptions;
|
||||
function withPrfExtension(
|
||||
options: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions,
|
||||
salt: Uint8Array
|
||||
): PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions {
|
||||
return {
|
||||
...options,
|
||||
extensions: {
|
||||
...((options as any).extensions || {}),
|
||||
...extension,
|
||||
...buildPrfExtension(salt),
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
@@ -154,70 +133,17 @@ function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | unde
|
||||
return result instanceof ArrayBuffer ? result : undefined;
|
||||
}
|
||||
|
||||
function hasPrfExtensionResult(credential: PublicKeyCredential): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(credential.getClientExtensionResults() as any, 'prf');
|
||||
}
|
||||
|
||||
function shouldRetryWithLegacyPrf(error: unknown): boolean {
|
||||
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
|
||||
return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError';
|
||||
}
|
||||
|
||||
function shouldRetryCreateWithoutPrf(error: unknown): boolean {
|
||||
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
|
||||
const message = error instanceof DOMException || error instanceof Error ? error.message : '';
|
||||
return (
|
||||
name === 'NotSupportedError' ||
|
||||
name === 'SyntaxError' ||
|
||||
name === 'TypeError' ||
|
||||
(name === 'UnknownError' && /transient/i.test(message))
|
||||
);
|
||||
}
|
||||
|
||||
async function canRequestPrfExtension(): Promise<boolean> {
|
||||
if (/\bFirefox\//i.test(navigator.userAgent)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getPublicKeyCredentialWithPrf(
|
||||
options: PublicKeyCredentialRequestOptions,
|
||||
salt: Uint8Array,
|
||||
credentialIds: string[] = []
|
||||
salt: Uint8Array
|
||||
): Promise<PublicKeyCredential> {
|
||||
const attempts = credentialIds.length
|
||||
? [
|
||||
buildCredentialPrfExtension(salt, credentialIds),
|
||||
buildLegacyPrfExtension(salt),
|
||||
]
|
||||
: [buildLegacyPrfExtension(salt)];
|
||||
let lastCredential: PublicKeyCredential | null = null;
|
||||
for (let index = 0; index < attempts.length; index += 1) {
|
||||
try {
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: withPrfExtension(options, attempts[index]),
|
||||
publicKey: withPrfExtension(options, salt),
|
||||
});
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error(t('txt_no_passkey_selected'));
|
||||
}
|
||||
lastCredential = credential;
|
||||
if (readPrfFirstResult(credential) || hasPrfExtensionResult(credential) || index === attempts.length - 1) {
|
||||
return credential;
|
||||
}
|
||||
} catch (error) {
|
||||
if (index === attempts.length - 1 || !shouldRetryWithLegacyPrf(error)) {
|
||||
if (lastCredential) return lastCredential;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastCredential) return lastCredential;
|
||||
throw new Error(t('txt_no_passkey_selected'));
|
||||
}
|
||||
|
||||
function prfCredentialIdsFromAllowCredentials(options: PublicKeyCredentialRequestOptions): string[] {
|
||||
return (options.allowCredentials || [])
|
||||
.map((credential) => credentialIdToBase64Url(credential.id))
|
||||
.filter((id): id is string => !!id);
|
||||
}
|
||||
|
||||
async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> {
|
||||
@@ -282,8 +208,7 @@ export async function assertAccountPasskey(
|
||||
const nativeOptions = cloneRequestOptions(response.options);
|
||||
const credential = await getPublicKeyCredentialWithPrf(
|
||||
nativeOptions,
|
||||
await getLoginWithPrfSalt(),
|
||||
prfCredentialIdsFromAllowCredentials(nativeOptions)
|
||||
await getLoginWithPrfSalt()
|
||||
);
|
||||
const prfResult = readPrfFirstResult(credential);
|
||||
return {
|
||||
@@ -309,37 +234,47 @@ export async function createAccountPasskeyCredential(
|
||||
}
|
||||
return credential;
|
||||
};
|
||||
let credential: PublicKeyCredential;
|
||||
if (requestPrf && await canRequestPrfExtension()) {
|
||||
const prfOptions: PublicKeyCredentialCreationOptions = {
|
||||
...noPrfOptions,
|
||||
extensions: {
|
||||
...((noPrfOptions as any).extensions || {}),
|
||||
prf: {},
|
||||
} as any,
|
||||
};
|
||||
try {
|
||||
credential = await createWithOptions(prfOptions);
|
||||
} catch (error) {
|
||||
if (!shouldRetryCreateWithoutPrf(error)) throw error;
|
||||
credential = await createWithOptions(noPrfOptions);
|
||||
}
|
||||
} else {
|
||||
credential = await createWithOptions(noPrfOptions);
|
||||
}
|
||||
const prfSalt = requestPrf ? await getLoginWithPrfSalt() : null;
|
||||
const credential = await createWithOptions(
|
||||
prfSalt ? withPrfExtension(noPrfOptions, prfSalt) : noPrfOptions
|
||||
);
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error(t('txt_no_passkey_created'));
|
||||
}
|
||||
const supportsPrf = !!(credential.getClientExtensionResults() as any).prf?.enabled;
|
||||
const prfResult = readPrfFirstResult(credential);
|
||||
const supportsPrf = !!prfResult || (credential.getClientExtensionResults() as any).prf?.enabled === true;
|
||||
return {
|
||||
token: response.token,
|
||||
createOptions: nativeOptions,
|
||||
deviceResponse: credential,
|
||||
request: attestationRequest(credential),
|
||||
supportsPrf,
|
||||
prfKey: prfResult ? await prfOutputToKey(prfResult) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
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('.');
|
||||
@@ -351,8 +286,10 @@ export async function buildAccountPasskeyPrfKeySet(
|
||||
pending: PendingAccountPasskeyCredential,
|
||||
userKey: { symEncKey: string; symMacKey: string }
|
||||
): Promise<AccountPasskeyPrfKeySet> {
|
||||
if (pending.prfKey) {
|
||||
return buildAccountPasskeyPrfKeySetFromPrfKey(pending.prfKey, userKey);
|
||||
}
|
||||
const rawId = new Uint8Array(pending.deviceResponse.rawId);
|
||||
const credentialId = bytesToBase64Url(rawId);
|
||||
const assertionOptions: PublicKeyCredentialRequestOptions = {
|
||||
challenge: pending.createOptions?.challenge!,
|
||||
rpId: pending.createOptions?.rp?.id,
|
||||
@@ -362,8 +299,7 @@ export async function buildAccountPasskeyPrfKeySet(
|
||||
};
|
||||
const assertion = await getPublicKeyCredentialWithPrf(
|
||||
assertionOptions,
|
||||
await getLoginWithPrfSalt(),
|
||||
[credentialId]
|
||||
await getLoginWithPrfSalt()
|
||||
);
|
||||
const prfResult = readPrfFirstResult(assertion);
|
||||
if (!prfResult) {
|
||||
|
||||
+29
-12
@@ -15,45 +15,62 @@ export async function listAdminInvites(authedFetch: AuthedFetch): Promise<AdminI
|
||||
return body?.data || [];
|
||||
}
|
||||
|
||||
export async function createInvite(authedFetch: AuthedFetch, hours: number): Promise<void> {
|
||||
export async function createInvite(authedFetch: AuthedFetch, hours: number, masterPasswordHash: string): Promise<void> {
|
||||
const resp = await authedFetch('/api/admin/invites', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expiresInHours: hours }),
|
||||
body: JSON.stringify({ expiresInHours: hours, masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Create invite failed');
|
||||
}
|
||||
|
||||
export async function deleteInvite(authedFetch: AuthedFetch, code: string): Promise<void> {
|
||||
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' });
|
||||
export async function deleteInvite(authedFetch: AuthedFetch, code: string, masterPasswordHash: string): Promise<void> {
|
||||
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Delete invite failed');
|
||||
}
|
||||
|
||||
export async function deleteInvalidInvites(authedFetch: AuthedFetch): Promise<void> {
|
||||
const resp = await authedFetch('/api/admin/invites?scope=invalid', { method: 'DELETE' });
|
||||
export async function deleteInvalidInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
|
||||
const resp = await authedFetch('/api/admin/invites?scope=invalid', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Delete invalid invites failed');
|
||||
}
|
||||
|
||||
export async function deleteAllInvites(authedFetch: AuthedFetch): Promise<void> {
|
||||
const resp = await authedFetch('/api/admin/invites', { method: 'DELETE' });
|
||||
export async function deleteAllInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
|
||||
const resp = await authedFetch('/api/admin/invites', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Delete all invites failed');
|
||||
}
|
||||
|
||||
export async function setUserStatus(
|
||||
authedFetch: AuthedFetch,
|
||||
userId: string,
|
||||
status: 'active' | 'banned'
|
||||
status: 'active' | 'banned',
|
||||
masterPasswordHash: string
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
body: JSON.stringify({ status, masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Update user status failed');
|
||||
}
|
||||
|
||||
export async function deleteUser(authedFetch: AuthedFetch, userId: string): Promise<void> {
|
||||
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' });
|
||||
export async function deleteUser(authedFetch: AuthedFetch, userId: string, masterPasswordHash: string): Promise<void> {
|
||||
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Delete user failed');
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ export async function respondToAuthRequest(
|
||||
requestId: string,
|
||||
payload: {
|
||||
key?: string | null;
|
||||
masterPasswordHash?: string | null;
|
||||
deviceIdentifier: string;
|
||||
requestApproved: boolean;
|
||||
}
|
||||
|
||||
+247
-17
@@ -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(
|
||||
@@ -914,8 +1140,12 @@ export async function updateAuthorizedDeviceName(
|
||||
if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
|
||||
}
|
||||
|
||||
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> {
|
||||
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
||||
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
|
||||
const resp = await authedFetch('/api/devices', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user